diff --git a/.cargo/config.toml b/.cargo/config.toml index 1302091e0..bc8c20d71 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -26,10 +26,10 @@ test_details = "test --target aarch64-apple-darwin" # native crate needs no change here. Axum (native), Cloudflare # (wasm32-unknown-unknown), Spin, the CLI (native), and integration-tests # (native) are simply not listed. -build-fastly = "build -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" -check-fastly = "check -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" -clippy-fastly = "clippy -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --all-targets --all-features --target wasm32-wasip1 -- -D warnings" -test-fastly = "test -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +build-fastly = "build -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +check-fastly = "check -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +clippy-fastly = "clippy -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --all-targets --all-features --target wasm32-wasip1 -- -D warnings" +test-fastly = "test -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" # --- Axum adapter (native dev server) --- build-axum = "build -p trusted-server-adapter-axum" diff --git a/.github/workflows/inspector.yml b/.github/workflows/inspector.yml new file mode 100644 index 000000000..5d58b3d93 --- /dev/null +++ b/.github/workflows/inspector.yml @@ -0,0 +1,26 @@ +name: "Permissions Inspector" + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + build-inspector-wasm: + name: build inspector wasm + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Retrieve Rust version + id: rust-version + run: echo "rust-version=$(grep -oP 'channel = "\K[^"]+' rust-toolchain.toml)" >> "$GITHUB_OUTPUT" + - name: Set up Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ steps.rust-version.outputs.rust-version }} + target: wasm32-unknown-unknown + - name: Build the inspector engine + run: ./scripts/build-inspector-wasm.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b7a145e02..5a3af67c8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -65,8 +65,12 @@ jobs: run: BID_DELAY=3 ./scripts/template-cache-local-test.sh inline test-axum: - name: cargo test (axum native) - runs-on: ubuntu-latest + name: cargo test (axum native, ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@v4 @@ -103,12 +107,33 @@ jobs: run: | cargo test --package trusted-server-openrtb-codegen --target "$(rustc -vV | sed -n 's/host: //p')" + # The core library's unit tests otherwise run only on the WebAssembly + # targets, which build with panic=abort, so their harness stops at the + # first failing test and reports every later one as never run. A run that + # looks like a single failure can hide many more. This native run reports + # them all at once, which is what makes a red build readable. + - name: Run host-target core library tests + run: | + cargo test --package trusted-server-core --target "$(rustc -vV | sed -n 's/host: //p')" + + # The seam-probe fixture crate's own unit tests only run when invoked on + # the host target, because no adapter alias compiles its test target. Run + # them here so a fault in the seam fixture cannot sit unseen behind a + # green gate set. + - name: Run host-target seam-probe integration tests + run: | + cargo test --package trusted-server-integration-seam-probe --target "$(rustc -vV | sed -n 's/host: //p')" + - name: Verify Fastly WASM release build run: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1 test-cloudflare: - name: cargo check (cloudflare native + wasm32-unknown-unknown) - runs-on: ubuntu-latest + name: cargo check (cloudflare native + wasm32-unknown-unknown, ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 24b9e06aa..a4a0af36c 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ /spin /spin.sig +# logs +*.log + # EdgeZero local KV store (created by edgezero-adapter-axum framework) .edgezero/ /dist/prebid/ diff --git a/CLAUDE.md b/CLAUDE.md index 546a3bf52..719f34539 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,11 @@ crates/ trusted-server-adapter-cloudflare/ # Cloudflare Workers entry point (wasm32-unknown-unknown binary) trusted-server-adapter-spin/ # Fermyon Spin entry point (wasm32-wasip1 component) trusted-server-cli/ # Host-target `ts` operator CLI + device/ + fastly/ # trusted-server-device-fastly (opt-in TLS/H2 device provider) + edgecookie/ # vendor Edge Cookie provider crates (built-in HMAC provider is in core) + geo/ # vendor geo provider crates (host geo is injected by the adapter) + integrations/ # Integration modules that ship outside core (seam-probe) trusted-server-js/ # TypeScript/JS build — per-integration IIFE bundles lib/ # TS source, Vitest tests, esbuild pipeline ``` @@ -58,7 +63,9 @@ fastly compute serve # Deploy to Fastly fastly compute publish -# Run Axum dev server (native — no Viceroy) +# Run Axum dev server (native — no Viceroy). Settings load at runtime from the +# platform config store on every adapter; publish an operator config with +# `ts config push` (see trusted-server.example.toml for the template). cargo run -p trusted-server-adapter-axum # Test Axum adapter only @@ -142,6 +149,20 @@ cd crates/trusted-server-js/lib && node build-all.mjs cargo install viceroy --version 0.17.0 --locked --force ``` +### Windows (use WSL for the Linux-only tests) + +The Rust adapter tests run natively on Windows through the cargo aliases +(`cargo test-fastly` via Viceroy, `cargo test-axum`, `cargo test-cloudflare`), +and CI runs these on both `ubuntu-latest` and `windows-latest`. + +The Docker-based integration suite (`scripts/integration-tests.sh`) and the +Cloudflare worker build (`crates/trusted-server-adapter-cloudflare/build.sh`, +which uses `worker-build` + `wrangler dev`) are Linux tools. On Windows run them +inside WSL (Ubuntu) with Docker Desktop's WSL integration enabled. Provision the +WSL distro with the same toolchain as `.tool-versions` (rustup + the +`wasm32-wasip1` / `wasm32-unknown-unknown` targets, Node, Viceroy, wrangler), then +run the scripts from a clone on the WSL native filesystem for fast builds. + --- ## Coding Conventions @@ -269,12 +290,48 @@ impl core::error::Error for MyError {} ## Other guidelines +- Use US English spelling everywhere: code, identifiers, comments, + documentation, tests, commit messages, and configuration. For example, write + `color`, `behavior`, and `optimize`, not `colour`, `behaviour`, or `optimise`. + Where a term comes from an external source (for example the IAB TCF purpose + names), match that source's spelling even when it is not US English. - Use only example or fictional information in comments, tests, docs, examples, and similar non-runtime materials. (eg. for urls use: example.com domains only) - Do not write or commit real domains, customer names, credentials, configuration values, or other potentially sensitive real-world information in comments, tests, docs, or examples. +### Permission model terminology + +Permissions are the primitive. A provider declares the permissions it requires +(`required_permissions`) and the system decides whether each is _set_. Consent +is only one of many ways a permission may be established. Country or +jurisdiction rules (a `Granted` group baseline), legitimate interest, or +configuration can set a permission with no consent at all. + +- A provider that needs nothing **requires no permission**. Never write that it + "runs without any consent". +- A gated provider **runs once its required permissions are set**, by whatever + method. + +**Evidence is not rationed, use is.** Every provider and every integration sees +all the evidence available for a request, including host signals such as the TLS +JA4 and HTTP/2 signals. The core never decides which vendor may see what, +because withholding a signal from one vendor and not another discriminates +between them, and the core stays neutral. What a vendor may *do* with the +evidence is governed by the permissions it declares and the system sets. Access +is universal, use is gated. + +The practical consequence: never "fix" a vendor's access to a signal by hiding +the signal. If a use needs controlling, express it as a permission. A change +that removes evidence from a provider's reach is working against the +architecture, not protecting it. + +- Reserve "consent" for the consent subsystem (`consent/`, `ConsentContext`, + GDPR and TCF strings) where it genuinely means a consent signal. In the + permission layer prefer "permission", "set" / "unset", and "signal" (consent + is one kind of signal, alongside privacy and opt-out signals). + --- ## Git Commit Conventions @@ -291,6 +348,41 @@ Bad: `"fix: added feature flags"` --- +## Provider Architecture + +Each vendor-differentiated capability is pluggable behind its own trait, so a +deployment selects an implementation and the core stays neutral: + +| Capability | Trait | Selector | Built-in (core) | Vendor / host crates | +| --------------------- | ---------------------------------------- | ------------------- | --------------------------------------- | ---------------------------- | +| Edge Cookie identity | `EdgeCookieProvider` (`ec/provider.rs`) | `[ec] provider` | HMAC, client-fixed (opt-in, no default) | `crates/edgecookie/` | +| Device detection | `DeviceProvider` (`ec/device.rs`) | `[device] provider` | User-Agent only (default) | `crates/device/` | +| Geo / IP intelligence | `PlatformGeo` (`platform/traits.rs`) | `[geo] provider` | Disabled, no location (default) | `crates/geo/` | + +Principles for adding or changing a provider: + +- **Core stays neutral.** The trait and the host-neutral default live in + `trusted-server-core`. Host-specific and vendor implementations live in their + own crates and are injected by the adapter (for example `build_device_provider` + and `build_geo_provider`), so core never depends on a host SDK or a vendor, and + the default request path makes no host-specific calls. +- **Providers read request evidence, not a fixed parameter set.** A provider must + be able to see everything about the request it needs (User-Agent, headers, and + host signals such as the TLS JA4 and HTTP/2 signals) through an evidence + abstraction rather than a hard-coded struct of fields. Host signals come from + the host (the Fastly SDK) and are opt-in, so a neutral provider triggers no + host signal calls. +- **Providers are separated by capability but composed per request, and one may + need another's output.** Geo resolves the country and region the permission + model uses, and the permission model gates whether the Edge Cookie provider + runs. Device signals gate Edge Cookie writes (the browser / bot gate). When + multiple vendor providers share a backend (for example a vendor's Edge Cookie, + geo, and device provider on one cloud pipeline) they share a single call per + request rather than calling independently. Give a provider the inputs and + upstream results it needs explicitly, rather than having it reach into globals. + +--- + ## Integration System Integrations register in Rust via: @@ -308,6 +400,27 @@ IntegrationRegistration::builder(ID) - Integrations opt into deferred loading via `.with_deferred_js()` on the registration builder. Deferred modules are served as separate `".to_string()); renderer.bid_id = Some("upstream-renderer-bid".to_string()); renderer.creative_id = None; - renderer.renderer = Some(BidRenderer::Aps(ApsRendererV1 { - version: 1, - account_id: "example-account".to_string(), - bid_id: "upstream-renderer-bid".to_string(), - creative_id: None, - tag_type: ApsTagType::Iframe, - creative_url: "https://creative.example/render".to_string(), - aax_response: "fictional-base64".to_string(), - width: 300, - height: 250, - })); + renderer.renderer = Some( + BidRenderer::from_typed( + APS_RENDERER_TYPE, + &ApsRendererV1 { + version: 1, + account_id: "example-account".to_string(), + bid_id: "upstream-renderer-bid".to_string(), + creative_id: None, + tag_type: ApsTagType::Iframe, + creative_url: "https://creative.example/render".to_string(), + aax_response: "fictional-base64".to_string(), + width: 300, + height: 250, + }, + ) + .expect("should build APS renderer descriptor"), + ); let result = OrchestrationResult { provider_responses: vec![], mediator_response: None, @@ -1565,17 +1570,23 @@ mod tests { let settings = make_settings(); let auction_request = make_auction_request(); let mut bid = make_bid("div-gpt-top", "aps", Some(2.75)); - bid.renderer = Some(BidRenderer::Aps(ApsRendererV1 { - version: 1, - account_id: "example-account".to_string(), - bid_id: "fictional-bid".to_string(), - creative_id: None, - tag_type: ApsTagType::Iframe, - creative_url: "https://creative.example/render".to_string(), - aax_response: "fictional-base64".to_string(), - width: 300, - height: 250, - })); + bid.renderer = Some( + BidRenderer::from_typed( + APS_RENDERER_TYPE, + &ApsRendererV1 { + version: 1, + account_id: "example-account".to_string(), + bid_id: "fictional-bid".to_string(), + creative_id: None, + tag_type: ApsTagType::Iframe, + creative_url: "https://creative.example/render".to_string(), + aax_response: "fictional-base64".to_string(), + width: 300, + height: 250, + }, + ) + .expect("should build APS renderer descriptor"), + ); let result = make_result(bid); let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) @@ -1605,17 +1616,23 @@ mod tests { bid.bid_id = Some("fictional-bid".to_string()); bid.ad_id = Some("fictional-ad".to_string()); bid.creative_id = Some("fictional-creative".to_string()); - bid.renderer = Some(BidRenderer::Aps(ApsRendererV1 { - version: 1, - account_id: "example-account".to_string(), - bid_id: "fictional-bid".to_string(), - creative_id: Some("fictional-creative".to_string()), - tag_type: ApsTagType::Iframe, - creative_url: "https://creative.example/render".to_string(), - aax_response: "fictional-base64".to_string(), - width: 300, - height: 250, - })); + bid.renderer = Some( + BidRenderer::from_typed( + APS_RENDERER_TYPE, + &ApsRendererV1 { + version: 1, + account_id: "example-account".to_string(), + bid_id: "fictional-bid".to_string(), + creative_id: Some("fictional-creative".to_string()), + tag_type: ApsTagType::Iframe, + creative_url: "https://creative.example/render".to_string(), + aax_response: "fictional-base64".to_string(), + width: 300, + height: 250, + }, + ) + .expect("should build APS renderer descriptor"), + ); let result = make_result(bid); let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) diff --git a/crates/trusted-server-core/src/auction/mod.rs b/crates/trusted-server-core/src/auction/mod.rs index 986beb984..bfe4b2974 100644 --- a/crates/trusted-server-core/src/auction/mod.rs +++ b/crates/trusted-server-core/src/auction/mod.rs @@ -37,27 +37,157 @@ pub use types::{ AdFormat, AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStatus, MediaType, }; -/// Type alias for provider builder functions. -type ProviderBuilder = +/// Builds the auction providers an integration contributes from settings. +pub type AuctionProviderBuilderFn = fn(&Settings) -> Result>, Report>; -/// Returns the list of all available provider builder functions. +/// Validates the provider's configuration for deployment and reports whether +/// it is enabled. /// -/// This list is used to auto-discover and register auction providers from settings. -/// Each builder function checks the settings for its specific provider configuration -/// and returns any enabled providers. -fn provider_builders() -> &'static [ProviderBuilder] { - &[ +/// Runs for every builder, enabled or not, so a typo in a disabled block is +/// still caught. +pub type AuctionProviderValidateFn = fn(&Settings) -> Result>; + +/// A named factory for one auction provider, the unit an adapter or a vendor +/// crate hands to [`build_orchestrator_with_providers`]. +/// +/// # Examples +/// +/// ``` +/// use std::sync::Arc; +/// +/// use error_stack::Report; +/// use trusted_server_core::auction::{ +/// AuctionProvider, AuctionProviderBuilder, build_orchestrator_with_providers, +/// }; +/// use trusted_server_core::error::TrustedServerError; +/// use trusted_server_core::settings::Settings; +/// +/// fn build( +/// _settings: &Settings, +/// ) -> Result>, Report> { +/// Ok(Vec::new()) +/// } +/// +/// fn validate(_settings: &Settings) -> Result> { +/// Ok(false) +/// } +/// +/// # fn demo(settings: &Settings) -> Result<(), Report> { +/// let builder = AuctionProviderBuilder::new("example", "example-crate", build, validate); +/// let orchestrator = build_orchestrator_with_providers(settings, &[builder])?; +/// assert_eq!(orchestrator.provider_count(), 0); +/// # Ok(()) +/// # } +/// ``` +#[derive(Clone, Copy, Debug)] +pub struct AuctionProviderBuilder { + name: &'static str, + source: &'static str, + build: AuctionProviderBuilderFn, + validate: AuctionProviderValidateFn, +} + +impl AuctionProviderBuilder { + /// Creates a builder for the auction provider `name`, attributed to + /// `source` (a crate or package name used in duplicate-name errors). + #[must_use] + pub const fn new( + name: &'static str, + source: &'static str, + build: AuctionProviderBuilderFn, + validate: AuctionProviderValidateFn, + ) -> Self { + Self { + name, + source, + build, + validate, + } + } + + /// The auction provider name this builder produces. + #[must_use] + pub const fn name(&self) -> &'static str { + self.name + } + + /// The source label used in diagnostics. + #[must_use] + pub const fn source(&self) -> &'static str { + self.source + } + + /// Builds the providers this builder contributes, empty when the provider + /// is not enabled. + /// + /// # Errors + /// + /// Returns an error when the provider is enabled with invalid + /// configuration. + pub(crate) fn build( + &self, + settings: &Settings, + ) -> Result>, Report> { + (self.build)(settings) + } + + /// Validates the provider's configuration for deployment and reports + /// whether the provider is enabled. + /// + /// # Errors + /// + /// Returns an error when the configuration cannot be parsed or fails + /// validation. + pub(crate) fn validate(&self, settings: &Settings) -> Result> { + (self.validate)(settings) + } +} + +/// The built-in auction providers, in registration order. +const BUILT_IN_PROVIDER_BUILDERS: &[AuctionProviderBuilder] = &[ + AuctionProviderBuilder::new( + "prebid", + crate::integrations::CORE_SOURCE, crate::integrations::prebid::register_auction_provider, + crate::integrations::prebid::validate, + ), + AuctionProviderBuilder::new( + "aps", + crate::integrations::CORE_SOURCE, crate::integrations::aps::register_providers, + crate::integrations::aps::validate, + ), + AuctionProviderBuilder::new( + "adserver_mock", + crate::integrations::CORE_SOURCE, crate::integrations::adserver_mock::register_providers, - ] + crate::integrations::adserver_mock::validate, + ), +]; + +/// The built-in auction provider builders, in registration order. +pub(crate) fn provider_builders() -> &'static [AuctionProviderBuilder] { + BUILT_IN_PROVIDER_BUILDERS +} + +/// Every auction provider builder the orchestrator will consider: the built-in +/// set followed by `extra`, in that order, so registration order for the +/// built-ins never changes. +pub(crate) fn all_provider_builders( + extra: &[AuctionProviderBuilder], +) -> impl Iterator + '_ { + provider_builders() + .iter() + .copied() + .chain(extra.iter().copied()) } /// Build a new auction orchestrator for the current settings. /// -/// This constructor registers all auction providers discovered from the provided settings. -/// Callers can reuse the returned [`AuctionOrchestrator`] across requests. +/// This constructor registers all built-in auction providers discovered from +/// the provided settings. Callers can reuse the returned +/// [`AuctionOrchestrator`] across requests. /// /// # Arguments /// * `settings` - Application settings used to configure the orchestrator and providers @@ -67,15 +197,34 @@ fn provider_builders() -> &'static [ProviderBuilder] { /// Returns an error when an enabled auction provider has invalid configuration. pub fn build_orchestrator( settings: &Settings, +) -> Result> { + build_orchestrator_with_providers(settings, &[]) +} + +/// Build a new auction orchestrator from the built-in provider builders +/// followed by the externally supplied builders an adapter registers. +/// +/// # Arguments +/// * `settings` - Application settings used to configure the orchestrator and providers +/// * `extra` - Provider builders supplied by an adapter or a vendor crate +/// +/// # Errors +/// +/// Returns an error when an enabled auction provider has invalid +/// configuration, when two builders produce the same provider name, or when a +/// configured provider name has no registered provider. +pub fn build_orchestrator_with_providers( + settings: &Settings, + extra: &[AuctionProviderBuilder], ) -> Result> { log::info!("Building auction orchestrator"); let mut orchestrator = AuctionOrchestrator::new(settings.auction.clone()); // Auto-discover and register all auction providers from settings - for builder in provider_builders() { - for provider in builder(settings)? { - orchestrator.register_provider(provider); + for builder in all_provider_builders(extra) { + for provider in builder.build(settings)? { + orchestrator.register_provider(provider, builder.source())?; } } @@ -94,7 +243,16 @@ mod tests { use crate::settings::Settings; use crate::test_support::tests::crate_test_settings_str; - use super::build_orchestrator; + use std::sync::Arc; + + use error_stack::Report; + + use super::test_support::NamedTestProvider; + use super::{ + AuctionProvider, AuctionProviderBuilder, build_orchestrator, + build_orchestrator_with_providers, + }; + use crate::error::TrustedServerError; fn settings_with_auction_config(auction_config: &str) -> Settings { let settings_str = format!("{}\n{auction_config}", crate_test_settings_str()); @@ -165,4 +323,80 @@ mod tests { "Auction provider `missing-mediator` is listed in [auction] but no enabled integration provides it", ); } + + /// Builds one provider named `seam-probe`, standing in for a provider a + /// vendor crate contributes. + fn build_probe_provider( + _settings: &Settings, + ) -> Result>, Report> { + Ok(vec![Arc::new(NamedTestProvider::new("seam-probe"))]) + } + + /// Builds one provider that claims the built-in `prebid` name. + fn build_conflicting_prebid_provider( + _settings: &Settings, + ) -> Result>, Report> { + Ok(vec![Arc::new(NamedTestProvider::new("prebid"))]) + } + + fn probe_enabled(_settings: &Settings) -> Result> { + Ok(true) + } + + #[test] + fn build_orchestrator_with_providers_registers_an_external_provider() { + let settings = settings_with_auction_config( + r#" + [auction] + enabled = true + providers = ["prebid", "seam-probe"] + timeout_ms = 2000 + "#, + ); + let extra = [AuctionProviderBuilder::new( + "seam-probe", + "seam-probe-crate", + build_probe_provider, + probe_enabled, + )]; + + let orchestrator = build_orchestrator_with_providers(&settings, &extra) + .expect("should register the external auction provider"); + + assert_eq!( + orchestrator.provider_count(), + 2, + "should register the built-in prebid provider and the external provider" + ); + } + + #[test] + fn build_orchestrator_with_providers_rejects_a_duplicate_provider_name() { + let settings = settings_with_auction_config( + r#" + [auction] + enabled = true + providers = ["prebid"] + timeout_ms = 2000 + "#, + ); + let extra = [AuctionProviderBuilder::new( + "prebid", + "seam-probe-crate", + build_conflicting_prebid_provider, + probe_enabled, + )]; + + let error = build_orchestrator_with_providers(&settings, &extra) + .err() + .expect("should reject a duplicate auction provider name"); + + let message = error.to_string(); + assert!( + message.contains("prebid") + && message.contains(crate::integrations::CORE_SOURCE) + && message.contains("seam-probe-crate"), + "error should name the provider and both sources: {message}" + ); + } } diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 728cc1efe..e63d74d9d 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -205,6 +205,9 @@ fn snapshot_context_request(request: &Request) -> Request { pub struct AuctionOrchestrator { config: AuctionConfig, providers: HashMap>, + /// Provider names in registration order, each paired with the source that + /// registered it, so a duplicate can name the source that came first. + provider_sources: Vec<(String, &'static str)>, } impl AuctionOrchestrator { @@ -214,14 +217,36 @@ impl AuctionOrchestrator { Self { config, providers: HashMap::new(), + provider_sources: Vec::new(), } } - /// Register an auction provider. - pub fn register_provider(&mut self, provider: Arc) { + /// Register an auction provider attributed to `source`. + /// + /// # Errors + /// + /// Returns an error when a provider with the same name is already registered. + pub fn register_provider( + &mut self, + provider: Arc, + source: &'static str, + ) -> Result<(), Report> { let name = provider.provider_name().to_string(); - log::info!("Registering auction provider: {}", name); + if let Some((_, first_source)) = self + .provider_sources + .iter() + .find(|(registered, _)| *registered == name) + { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "auction provider `{name}` is registered twice, by `{first_source}` and by `{source}`" + ), + })); + } + log::info!("Registering auction provider: {name}"); + self.provider_sources.push((name.clone(), source)); self.providers.insert(name, provider); + Ok(()) } /// Get the number of registered providers. @@ -1518,10 +1543,11 @@ mod tests { use crate::auction::provider::{AuctionProvider, ProviderRequestOutcome}; use crate::auction::test_support::create_test_auction_context; use crate::auction::types::{ - AdFormat, AdSlot, ApsRendererV1, ApsTagType, AuctionContext, AuctionRequest, - AuctionResponse, Bid, BidRenderer, BidStatus, MediaType, PublisherInfo, UserInfo, + AdFormat, AdSlot, AuctionContext, AuctionRequest, AuctionResponse, Bid, BidRenderer, + BidStatus, MediaType, PublisherInfo, UserInfo, }; use crate::error::TrustedServerError; + use crate::integrations::aps::{APS_RENDERER_TYPE, ApsRendererV1, ApsTagType}; use crate::platform::test_support::{ StubHttpClient, build_services_with_backend_and_http_client, build_services_with_http_client, noop_services, @@ -1789,17 +1815,21 @@ mod tests { fn auction_bid(bidder: &str, price: f64) -> Bid { let renderer = (bidder == "aps").then(|| { - BidRenderer::Aps(ApsRendererV1 { - version: 1, - account_id: "example-account".to_string(), - bid_id: "aps-selected-bid".to_string(), - creative_id: None, - tag_type: ApsTagType::Iframe, - creative_url: "https://creative.example/render".to_string(), - aax_response: "fictional-base64".to_string(), - width: 300, - height: 250, - }) + BidRenderer::from_typed( + APS_RENDERER_TYPE, + &ApsRendererV1 { + version: 1, + account_id: "example-account".to_string(), + bid_id: "aps-selected-bid".to_string(), + creative_id: None, + tag_type: ApsTagType::Iframe, + creative_url: "https://creative.example/render".to_string(), + aax_response: "fictional-base64".to_string(), + width: 300, + height: 250, + }, + ) + .expect("should build APS renderer descriptor") }); Bid { slot_id: "slot-1".to_string(), @@ -1971,11 +2001,21 @@ mod tests { ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "bidder", - backend: "bidder-backend", - })); - orchestrator.register_provider(Arc::new(CacheRestoringMediator)); + orchestrator + .register_provider( + Arc::new(StubAuctionProvider { + name: "bidder", + backend: "bidder-backend", + }), + crate::integrations::CORE_SOURCE, + ) + .expect("should register"); + orchestrator + .register_provider( + Arc::new(CacheRestoringMediator), + crate::integrations::CORE_SOURCE, + ) + .expect("should register"); let request = create_test_auction_request(); let settings = create_test_settings(); @@ -2027,11 +2067,21 @@ mod tests { ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "bidder", - backend: "bidder-backend", - })); - orchestrator.register_provider(Arc::new(ImmediateMediator)); + orchestrator + .register_provider( + Arc::new(StubAuctionProvider { + name: "bidder", + backend: "bidder-backend", + }), + crate::integrations::CORE_SOURCE, + ) + .expect("should register"); + orchestrator + .register_provider( + Arc::new(ImmediateMediator), + crate::integrations::CORE_SOURCE, + ) + .expect("should register"); let request = create_test_auction_request(); let settings = create_test_settings(); let downstream = http::Request::new(edgezero_core::body::Body::empty()); @@ -2215,7 +2265,12 @@ mod tests { ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(ImmediateNoBidProvider)); + orchestrator + .register_provider( + Arc::new(ImmediateNoBidProvider), + crate::integrations::CORE_SOURCE, + ) + .expect("should register"); let settings = create_test_settings(); let services = noop_services(); let downstream = http::Request::new(edgezero_core::body::Body::empty()); @@ -2240,7 +2295,12 @@ mod tests { ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(ImmediateNoBidProvider)); + orchestrator + .register_provider( + Arc::new(ImmediateNoBidProvider), + crate::integrations::CORE_SOURCE, + ) + .expect("should register"); let settings = create_test_settings(); let services = noop_services(); let downstream = http::Request::new(edgezero_core::body::Body::empty()); @@ -2271,11 +2331,21 @@ mod tests { ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(ImmediateNoBidProvider)); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "pending", - backend: "pending-backend", - })); + orchestrator + .register_provider( + Arc::new(ImmediateNoBidProvider), + crate::integrations::CORE_SOURCE, + ) + .expect("should register"); + orchestrator + .register_provider( + Arc::new(StubAuctionProvider { + name: "pending", + backend: "pending-backend", + }), + crate::integrations::CORE_SOURCE, + ) + .expect("should register"); let stub = Arc::new(StubHttpClient::new()); stub.push_response(200, b"{}".to_vec()); let services = build_services_with_http_client(stub); @@ -2528,7 +2598,12 @@ mod tests { ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(LaunchFailingProvider)); + orchestrator + .register_provider( + Arc::new(LaunchFailingProvider), + crate::integrations::CORE_SOURCE, + ) + .expect("should register"); let request = create_test_auction_request(); let settings = create_test_settings(); @@ -2592,14 +2667,24 @@ mod tests { ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-a", - backend: "shared-backend", - })); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-b", - backend: "shared-backend", - })); + orchestrator + .register_provider( + Arc::new(StubAuctionProvider { + name: "provider-a", + backend: "shared-backend", + }), + crate::integrations::CORE_SOURCE, + ) + .expect("should register"); + orchestrator + .register_provider( + Arc::new(StubAuctionProvider { + name: "provider-b", + backend: "shared-backend", + }), + crate::integrations::CORE_SOURCE, + ) + .expect("should register"); let stub = Arc::new(StubHttpClient::new()); stub.push_response(200, b"{}".to_vec()); let services = build_services_with_http_client(stub); @@ -2706,13 +2791,18 @@ mod tests { timeout_ms: 2000, ..Default::default() }); - orchestrator.register_provider(Arc::new(recording_provider( - "bidder", - "bidder-backend", - 1000, - &predicted, - &requested, - ))); + orchestrator + .register_provider( + Arc::new(recording_provider( + "bidder", + "bidder-backend", + 1000, + &predicted, + &requested, + )), + crate::integrations::CORE_SOURCE, + ) + .expect("should register"); let settings = create_test_settings(); let downstream = http::Request::new(edgezero_core::body::Body::empty()); let context = immediate_test_context(&settings, &downstream, &services); @@ -2750,13 +2840,18 @@ mod tests { timeout_ms: 2000, ..Default::default() }); - orchestrator.register_provider(Arc::new(recording_provider( - "bidder", - "bidder-backend", - 1000, - &predicted, - &requested, - ))); + orchestrator + .register_provider( + Arc::new(recording_provider( + "bidder", + "bidder-backend", + 1000, + &predicted, + &requested, + )), + crate::integrations::CORE_SOURCE, + ) + .expect("should register"); let settings = create_test_settings(); let downstream = http::Request::new(edgezero_core::body::Body::empty()); let context = immediate_test_context(&settings, &downstream, &services); @@ -2794,17 +2889,27 @@ mod tests { timeout_ms: 2000, ..Default::default() }); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "bidder", - backend: "bidder-backend", - })); - orchestrator.register_provider(Arc::new(recording_provider( - "mediator", - "mediator-backend", - 2000, - &predicted, - &requested, - ))); + orchestrator + .register_provider( + Arc::new(StubAuctionProvider { + name: "bidder", + backend: "bidder-backend", + }), + crate::integrations::CORE_SOURCE, + ) + .expect("should register"); + orchestrator + .register_provider( + Arc::new(recording_provider( + "mediator", + "mediator-backend", + 2000, + &predicted, + &requested, + )), + crate::integrations::CORE_SOURCE, + ) + .expect("should register"); let settings = create_test_settings(); let downstream = http::Request::new(edgezero_core::body::Body::empty()); let context = immediate_test_context(&settings, &downstream, &services); @@ -2844,20 +2949,30 @@ mod tests { timeout_ms: 2000, ..Default::default() }); - orchestrator.register_provider(Arc::new(recording_provider( - "bidder", - "bidder-backend", - 2000, - &bidder_predicted, - &bidder_requested, - ))); - orchestrator.register_provider(Arc::new(recording_provider( - "mediator", - "mediator-backend", - 2000, - &mediator_predicted, - &mediator_requested, - ))); + orchestrator + .register_provider( + Arc::new(recording_provider( + "bidder", + "bidder-backend", + 2000, + &bidder_predicted, + &bidder_requested, + )), + crate::integrations::CORE_SOURCE, + ) + .expect("should register"); + orchestrator + .register_provider( + Arc::new(recording_provider( + "mediator", + "mediator-backend", + 2000, + &mediator_predicted, + &mediator_requested, + )), + crate::integrations::CORE_SOURCE, + ) + .expect("should register"); let settings = create_test_settings(); let downstream = http::Request::new(edgezero_core::body::Body::empty()); let context = immediate_test_context(&settings, &downstream, &services); @@ -2905,11 +3020,16 @@ mod tests { timeout_ms: 2000, ..Default::default() }); - orchestrator.register_provider(Arc::new(DivergentBackendProvider { - name: "provider-a", - predicted: "predicted-backend", - resolved: "resolved-backend", - })); + orchestrator + .register_provider( + Arc::new(DivergentBackendProvider { + name: "provider-a", + predicted: "predicted-backend", + resolved: "resolved-backend", + }), + crate::integrations::CORE_SOURCE, + ) + .expect("should register"); let settings = create_test_settings(); let downstream = http::Request::new(edgezero_core::body::Body::empty()); let context = immediate_test_context(&settings, &downstream, &services); @@ -2943,16 +3063,26 @@ mod tests { timeout_ms: 2000, ..Default::default() }); - orchestrator.register_provider(Arc::new(DivergentBackendProvider { - name: "provider-a", - predicted: "predicted-a", - resolved: "shared-resolved", - })); - orchestrator.register_provider(Arc::new(DivergentBackendProvider { - name: "provider-b", - predicted: "predicted-b", - resolved: "shared-resolved", - })); + orchestrator + .register_provider( + Arc::new(DivergentBackendProvider { + name: "provider-a", + predicted: "predicted-a", + resolved: "shared-resolved", + }), + crate::integrations::CORE_SOURCE, + ) + .expect("should register"); + orchestrator + .register_provider( + Arc::new(DivergentBackendProvider { + name: "provider-b", + predicted: "predicted-b", + resolved: "shared-resolved", + }), + crate::integrations::CORE_SOURCE, + ) + .expect("should register"); let settings = create_test_settings(); let downstream = http::Request::new(edgezero_core::body::Body::empty()); let context = immediate_test_context(&settings, &downstream, &services); @@ -3000,14 +3130,24 @@ mod tests { ..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", - })); + orchestrator + .register_provider( + Arc::new(StubAuctionProvider { + name: "provider-a", + backend: "backend-a", + }), + crate::integrations::CORE_SOURCE, + ) + .expect("should register"); + orchestrator + .register_provider( + Arc::new(StubAuctionProvider { + name: "provider-b", + backend: "backend-b", + }), + crate::integrations::CORE_SOURCE, + ) + .expect("should register"); let request = create_test_auction_request(); let settings = create_test_settings(); @@ -3075,10 +3215,15 @@ mod tests { ..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-a", + backend: "backend-a", + }), + crate::integrations::CORE_SOURCE, + ) + .expect("should register"); let request = create_test_auction_request(); let settings = create_test_settings(); let downstream = http::Request::builder() @@ -3153,14 +3298,24 @@ mod tests { ..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", - })); + orchestrator + .register_provider( + Arc::new(StubAuctionProvider { + name: "provider-a", + backend: "backend-a", + }), + crate::integrations::CORE_SOURCE, + ) + .expect("should register"); + orchestrator + .register_provider( + Arc::new(StubAuctionProvider { + name: "provider-b", + backend: "backend-b", + }), + crate::integrations::CORE_SOURCE, + ) + .expect("should register"); let request = create_test_auction_request(); let settings = create_test_settings(); @@ -3218,14 +3373,24 @@ mod tests { ..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", - })); + orchestrator + .register_provider( + Arc::new(StubAuctionProvider { + name: "provider-a", + backend: "backend-a", + }), + crate::integrations::CORE_SOURCE, + ) + .expect("should register"); + orchestrator + .register_provider( + Arc::new(StubAuctionProvider { + name: "provider-b", + backend: "backend-b", + }), + crate::integrations::CORE_SOURCE, + ) + .expect("should register"); let request = create_test_auction_request(); let settings = create_test_settings(); diff --git a/crates/trusted-server-core/src/auction/test_support.rs b/crates/trusted-server-core/src/auction/test_support.rs index e4b953e05..cc8e687d7 100644 --- a/crates/trusted-server-core/src/auction/test_support.rs +++ b/crates/trusted-server-core/src/auction/test_support.rs @@ -3,10 +3,17 @@ use std::sync::LazyLock; use edgezero_core::body::Body as EdgeBody; use http::Request; -use super::AuctionContext; -use crate::platform::{RuntimeServices, test_support::noop_services}; +use error_stack::Report; + +use super::provider::{AuctionProvider, ProviderRequestOutcome}; +use super::types::{AuctionContext, AuctionRequest, AuctionResponse}; +use crate::error::TrustedServerError; +use crate::platform::{PlatformResponse, RuntimeServices, test_support::noop_services}; use crate::settings::Settings; +/// Timeout the named test provider reports; no test asserts on the value. +const NAMED_TEST_PROVIDER_TIMEOUT_MS: u32 = 2000; + static TEST_SERVICES: LazyLock = LazyLock::new(noop_services); pub(crate) fn create_test_auction_context<'a>( @@ -23,3 +30,51 @@ pub(crate) fn create_test_auction_context<'a>( services, } } + +/// A provider that reports the name it was constructed with and answers every +/// bid request immediately, for tests that only need a named registration. +pub(crate) struct NamedTestProvider { + name: &'static str, +} + +impl NamedTestProvider { + /// Creates a provider that reports `name` as its provider name. + pub(crate) const fn new(name: &'static str) -> Self { + Self { name } + } +} + +#[async_trait::async_trait(?Send)] +impl AuctionProvider for NamedTestProvider { + fn provider_name(&self) -> &'static str { + self.name + } + + async fn request_bids( + &self, + _request: &AuctionRequest, + _context: &AuctionContext<'_>, + ) -> Result> { + Ok(ProviderRequestOutcome::Immediate(AuctionResponse::success( + self.name, + vec![], + 0, + ))) + } + + async fn parse_response( + &self, + _response: PlatformResponse, + response_time_ms: u64, + ) -> Result> { + Ok(AuctionResponse::success( + self.name, + vec![], + response_time_ms, + )) + } + + fn timeout_ms(&self) -> u32 { + NAMED_TEST_PROVIDER_TIMEOUT_MS + } +} diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index f61334787..9ee9d7244 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -1,11 +1,14 @@ //! Core types for auction requests and responses. use edgezero_core::body::Body as EdgeBody; +use error_stack::{Report, ResultExt as _, bail, ensure}; use http::Request; +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashMap}; use crate::auction::context::ContextValue; +use crate::error::TrustedServerError; use crate::geo::GeoInfo; use crate::platform::RuntimeServices; use crate::settings::Settings; @@ -175,56 +178,158 @@ pub struct AuctionResponse { pub metadata: HashMap, } -/// APS creative tag type accepted by the Trusted Server renderer. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum ApsTagType { - /// APS loads the creative URL in a nested iframe. - Iframe, - /// APS fetches creative HTML and executes it in its nested renderer frame. - Script, -} - -/// Version 1 APS renderer descriptor shared with browser clients. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ApsRendererV1 { - /// Renderer contract version. - pub version: u8, - /// APS account identifier used to initialize the fixed runner. - pub account_id: String, - /// Selected `OpenRTB` bid identifier. - pub bid_id: String, - /// Optional `OpenRTB` creative identifier. - #[serde(skip_serializing_if = "Option::is_none")] - pub creative_id: Option, - /// APS creative delivery mode. - pub tag_type: ApsTagType, - /// HTTPS creative URL consumed by the fixed APS runner. - pub creative_url: String, - /// Base64-encoded exact one-bid APS response envelope. - pub aax_response: String, - /// Creative width. - pub width: u32, - /// Creative height. - pub height: u32, -} +/// Wire key carrying the renderer type tag. +/// +/// A payload may not use this key, since it would collide with the tag when +/// the descriptor is serialized flat. +const RENDERER_TYPE_KEY: &str = "type"; -/// Typed browser renderer capability carried by a bid. +/// Browser renderer capability carried by a bid: a type tag and the payload +/// the auction provider that produced the bid defines. +/// +/// Serialized flat, as `{"type": "", ...payload}`, so a page receives +/// the same bytes whether the provider lives in core or in its own crate. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "lowercase")] -pub enum BidRenderer { - /// APS renderer version 1. - Aps(ApsRendererV1), +pub struct BidRenderer { + #[serde(rename = "type")] + renderer_type: String, + #[serde(flatten)] + payload: serde_json::Map, } impl BidRenderer { - /// Return the APS renderer descriptor when this is an APS renderer. + /// Build a descriptor from a type tag and the provider's JSON payload. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Auction`] when `payload` is not a JSON + /// object, or when it carries its own `type` key, which would collide with + /// the tag once the descriptor is serialized flat. + /// + /// # Examples + /// + /// ``` + /// use serde_json::json; + /// use trusted_server_core::auction::types::BidRenderer; + /// + /// let renderer = BidRenderer::new("example", json!({ "version": 1 })) + /// .expect("should accept an object payload"); + /// assert_eq!(renderer.renderer_type(), "example"); + /// ``` + pub fn new( + renderer_type: &str, + payload: serde_json::Value, + ) -> Result> { + let serde_json::Value::Object(payload) = payload else { + bail!(TrustedServerError::Auction { + message: format!("Renderer '{renderer_type}' payload must be a JSON object"), + }); + }; + ensure!( + !payload.contains_key(RENDERER_TYPE_KEY), + TrustedServerError::Auction { + message: format!( + "Renderer '{renderer_type}' payload must not carry a '{RENDERER_TYPE_KEY}' key" + ), + } + ); + Ok(Self { + renderer_type: renderer_type.to_string(), + payload, + }) + } + + /// Build a descriptor by serializing a provider's own payload type. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Auction`] when `payload` cannot be + /// serialized, and when the serialized form is rejected by + /// [`new`](Self::new). + /// + /// # Examples + /// + /// ``` + /// use serde::Serialize; + /// use trusted_server_core::auction::types::BidRenderer; + /// + /// #[derive(Serialize)] + /// struct ExampleRendererV1 { + /// version: u8, + /// } + /// + /// let renderer = BidRenderer::from_typed("example", &ExampleRendererV1 { version: 1 }) + /// .expect("should accept a struct payload"); + /// assert_eq!(renderer.renderer_type(), "example"); + /// ``` + pub fn from_typed( + renderer_type: &str, + payload: &T, + ) -> Result> { + let payload = + serde_json::to_value(payload).change_context(TrustedServerError::Auction { + message: format!("Failed to serialize renderer '{renderer_type}' payload"), + })?; + Self::new(renderer_type, payload) + } + + /// Return the renderer type tag a page reads to select its renderer. #[must_use] - pub fn as_aps(&self) -> Option<&ApsRendererV1> { - match self { - Self::Aps(renderer) => Some(renderer), + pub fn renderer_type(&self) -> &str { + &self.renderer_type + } + + /// Deserialize the payload into the provider's own descriptor type. + /// + /// Returns `None` when the descriptor carries a different tag, and when the + /// payload does not match `T`. + /// + /// Clones the whole payload map and deserializes all of it, so use + /// [`payload_field`](Self::payload_field) when the caller wants one field. + /// An APS payload carries a base64 encoding of a creative envelope of up + /// to 256 KB. + #[must_use] + pub fn payload_as(&self, renderer_type: &str) -> Option { + if self.renderer_type != renderer_type { + return None; + } + serde_json::from_value(serde_json::Value::Object(self.payload.clone())).ok() + } + + /// Borrow one field of the payload, copying nothing. + /// + /// Returns `None` when the descriptor carries a different tag, and when + /// the payload has no such key. `key` is the wire key, so a payload type + /// that renames its fields for serialization must be asked for the + /// renamed form. + /// + /// Unlike [`payload_as`](Self::payload_as) this reads the one field + /// asked for and does not check that the rest of the payload matches the + /// provider's descriptor type. + /// + /// # Examples + /// + /// ``` + /// use serde_json::json; + /// use trusted_server_core::auction::types::BidRenderer; + /// + /// let renderer = BidRenderer::new("example", json!({ "bidId": "fictional-bid-id" })) + /// .expect("should accept an object payload"); + /// + /// assert_eq!( + /// renderer + /// .payload_field("example", "bidId") + /// .and_then(serde_json::Value::as_str), + /// Some("fictional-bid-id"), + /// ); + /// assert!(renderer.payload_field("other", "bidId").is_none()); + /// ``` + #[must_use] + pub fn payload_field(&self, renderer_type: &str, key: &str) -> Option<&serde_json::Value> { + if self.renderer_type != renderer_type { + return None; } + self.payload.get(key) } } @@ -399,6 +504,7 @@ impl AuctionResponse { #[cfg(test)] mod tests { use super::*; + use crate::integrations::aps::{APS_RENDERER_TYPE, ApsRendererV1, ApsTagType}; use serde_json::json; fn make_bid(bidder: &str) -> Bid { @@ -575,17 +681,21 @@ mod tests { #[test] fn aps_renderer_serializes_to_versioned_camel_case_contract() { - let renderer = BidRenderer::Aps(ApsRendererV1 { - version: 1, - account_id: "example-account-id".to_string(), - bid_id: "fictional-bid-id".to_string(), - creative_id: Some("fictional-creative-id".to_string()), - tag_type: ApsTagType::Iframe, - creative_url: "https://creative.example/render".to_string(), - aax_response: "base64-data".to_string(), - width: 300, - height: 250, - }); + let renderer = BidRenderer::from_typed( + APS_RENDERER_TYPE, + &ApsRendererV1 { + version: 1, + account_id: "example-account-id".to_string(), + bid_id: "fictional-bid-id".to_string(), + creative_id: Some("fictional-creative-id".to_string()), + tag_type: ApsTagType::Iframe, + creative_url: "https://creative.example/render".to_string(), + aax_response: "base64-data".to_string(), + width: 300, + height: 250, + }, + ) + .expect("should build APS renderer descriptor"); let serialized = serde_json::to_value(&renderer).expect("should serialize renderer"); @@ -609,23 +719,236 @@ mod tests { #[test] fn aps_renderer_omits_absent_creative_id() { - let renderer = BidRenderer::Aps(ApsRendererV1 { + let renderer = BidRenderer::from_typed( + APS_RENDERER_TYPE, + &ApsRendererV1 { + version: 1, + account_id: "example-account-id".to_string(), + bid_id: "fictional-bid-id".to_string(), + creative_id: None, + tag_type: ApsTagType::Iframe, + creative_url: "https://creative.example/render".to_string(), + aax_response: "base64-data".to_string(), + width: 300, + height: 250, + }, + ) + .expect("should build APS renderer descriptor"); + + let serialized = serde_json::to_value(&renderer).expect("should serialize renderer"); + + assert!( + serialized.get("creativeId").is_none(), + "should omit absent creative ID" + ); + } + + /// Rewrites every object in `value` with its keys in sorted order, so + /// serializing the result gives one fixed key order. + /// + /// `serde_json::Map` is a `BTreeMap`, which serializes keys in sorted + /// order, only while the crate's `preserve_order` feature is off. With the + /// feature on it is an `IndexMap` and the order follows insertion instead. + /// Nothing in this crate asks for the feature, but Cargo unifies features + /// across everything built for one target, and `trusted-server-cli` pulls + /// it in through `edgezero-cli` and then `handlebars`. A maintainer + /// running `cargo test --workspace --target ` therefore builds this + /// crate with `preserve_order` on, and a test that pinned insertion order + /// would fail there for no reason. Sorting both sides removes the + /// dependence on which map `serde_json` was built with. + fn with_sorted_keys(value: &serde_json::Value) -> serde_json::Value { + match value { + serde_json::Value::Object(map) => { + let mut keys = map.keys().collect::>(); + keys.sort_unstable(); + let mut sorted = serde_json::Map::with_capacity(keys.len()); + for key in keys { + let child = map.get(key).expect("should find a key the map just listed"); + sorted.insert(key.clone(), with_sorted_keys(child)); + } + serde_json::Value::Object(sorted) + } + serde_json::Value::Array(items) => { + serde_json::Value::Array(items.iter().map(with_sorted_keys).collect()) + } + scalar => scalar.clone(), + } + } + + #[test] + fn the_open_renderer_serializes_to_the_same_bytes_as_the_aps_variant_did() { + // Literal strings captured from the closed-enum form before this + // change, through the same `serde_json::to_value` path production + // uses: `BidExt::to_ext` for the OpenRTB response extension, and + // `build_bid_map` for `window.tsjs.bids`. + // + // Both sides go through `with_sorted_keys` first, because the key + // order `serde_json` emits is not ours to pin, and that function + // explains why. Sorting settles the order without weakening what is + // pinned, since two objects serialize to the same sorted bytes only + // when they carry exactly the same keys with exactly the same values. + let full = BidRenderer::from_typed( + APS_RENDERER_TYPE, + &ApsRendererV1 { + version: 1, + account_id: "example-account-id".to_string(), + bid_id: "fictional-bid-id".to_string(), + creative_id: Some("fictional-creative-id".to_string()), + tag_type: ApsTagType::Iframe, + creative_url: "https://creative.example/render".to_string(), + aax_response: "base64-data".to_string(), + width: 300, + height: 250, + }, + ) + .expect("should build APS renderer descriptor"); + let absent = BidRenderer::from_typed( + APS_RENDERER_TYPE, + &ApsRendererV1 { + version: 1, + account_id: "example-account-id".to_string(), + bid_id: "fictional-bid-id".to_string(), + creative_id: None, + tag_type: ApsTagType::Script, + creative_url: "https://creative.example/render".to_string(), + aax_response: "base64-data".to_string(), + width: 300, + height: 250, + }, + ) + .expect("should build APS renderer descriptor"); + + let full_bytes = serde_json::to_string(&with_sorted_keys( + &serde_json::to_value(&full).expect("should convert renderer to a JSON value"), + )) + .expect("should serialize renderer"); + let absent_bytes = serde_json::to_string(&with_sorted_keys( + &serde_json::to_value(&absent).expect("should convert renderer to a JSON value"), + )) + .expect("should serialize renderer"); + + assert_eq!( + full_bytes, + "{\"aaxResponse\":\"base64-data\",\"accountId\":\"example-account-id\",\"bidId\":\"fictional-bid-id\",\"creativeId\":\"fictional-creative-id\",\"creativeUrl\":\"https://creative.example/render\",\"height\":250,\"tagType\":\"iframe\",\"type\":\"aps\",\"version\":1,\"width\":300}", + "should serialize to the bytes the closed enum produced" + ); + assert_eq!( + absent_bytes, + "{\"aaxResponse\":\"base64-data\",\"accountId\":\"example-account-id\",\"bidId\":\"fictional-bid-id\",\"creativeUrl\":\"https://creative.example/render\",\"height\":250,\"tagType\":\"script\",\"type\":\"aps\",\"version\":1,\"width\":300}", + "should serialize to the bytes the closed enum produced with no creative ID" + ); + } + + #[test] + fn renderer_round_trips_through_its_wire_form() { + let descriptor = ApsRendererV1 { version: 1, account_id: "example-account-id".to_string(), bid_id: "fictional-bid-id".to_string(), - creative_id: None, + creative_id: Some("fictional-creative-id".to_string()), tag_type: ApsTagType::Iframe, creative_url: "https://creative.example/render".to_string(), aax_response: "base64-data".to_string(), width: 300, height: 250, - }); + }; + let renderer = BidRenderer::from_typed(APS_RENDERER_TYPE, &descriptor) + .expect("should build APS renderer descriptor"); - let serialized = serde_json::to_value(&renderer).expect("should serialize renderer"); + let serialized = serde_json::to_string(&renderer).expect("should serialize renderer"); + let restored: BidRenderer = + serde_json::from_str(&serialized).expect("should deserialize renderer"); + + assert_eq!( + restored.renderer_type(), + APS_RENDERER_TYPE, + "should round-trip the renderer type tag" + ); + assert_eq!( + restored + .payload_as::(APS_RENDERER_TYPE) + .expect("should deserialize the APS payload"), + descriptor, + "should round-trip the provider payload" + ); + } + + #[test] + fn renderer_payload_is_hidden_from_a_different_type_tag() { + let renderer = BidRenderer::new(APS_RENDERER_TYPE, json!({ "version": 1 })) + .expect("should build renderer descriptor"); assert!( - serialized.get("creativeId").is_none(), - "should omit absent creative ID" + renderer.payload_as::("example").is_none(), + "should refuse a payload requested under a different tag" + ); + } + + #[test] + fn renderer_payload_field_borrows_the_same_value_the_whole_descriptor_carries() { + let descriptor = ApsRendererV1 { + version: 1, + account_id: "example-account-id".to_string(), + bid_id: "fictional-bid-id".to_string(), + creative_id: Some("fictional-creative-id".to_string()), + tag_type: ApsTagType::Iframe, + creative_url: "https://creative.example/render".to_string(), + aax_response: "base64-data".to_string(), + width: 300, + height: 250, + }; + let renderer = BidRenderer::from_typed(APS_RENDERER_TYPE, &descriptor) + .expect("should build APS renderer descriptor"); + + assert_eq!( + renderer + .payload_field(APS_RENDERER_TYPE, "bidId") + .and_then(serde_json::Value::as_str), + Some(descriptor.bid_id.as_str()), + "should read the same bid id the whole descriptor carries" + ); + assert_eq!( + renderer + .payload_field(APS_RENDERER_TYPE, "bidId") + .and_then(serde_json::Value::as_str), + renderer + .payload_as::(APS_RENDERER_TYPE) + .as_ref() + .map(|full| full.bid_id.as_str()), + "should agree with the field read through the whole descriptor" + ); + assert!( + renderer + .payload_field(APS_RENDERER_TYPE, "notAKey") + .is_none(), + "should return nothing for a key the payload does not carry" + ); + } + + #[test] + fn renderer_payload_field_is_hidden_from_a_different_type_tag() { + let renderer = BidRenderer::new(APS_RENDERER_TYPE, json!({ "bidId": "fictional-bid-id" })) + .expect("should build renderer descriptor"); + + assert!( + renderer.payload_field("example", "bidId").is_none(), + "should refuse a field requested under a different tag" + ); + } + + #[test] + fn renderer_rejects_a_payload_that_is_not_an_object() { + assert!( + BidRenderer::new(APS_RENDERER_TYPE, json!("not-an-object")).is_err(), + "should reject a payload that is not a JSON object" + ); + } + + #[test] + fn renderer_rejects_a_payload_carrying_its_own_type_key() { + assert!( + BidRenderer::new(APS_RENDERER_TYPE, json!({ "type": "other", "version": 1 })).is_err(), + "should reject a payload that would collide with the type tag" ); } diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index ad4f66460..3e79d2ac6 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -13,35 +13,13 @@ use error_stack::Report; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use validator::{Validate, ValidationError, ValidationErrors}; +use crate::auction::AuctionProviderBuilder; use crate::ec::registry::PartnerRegistry; use crate::error::TrustedServerError; -use crate::integrations::{ - adserver_mock::AdServerMockConfig, aps::ApsConfig, datadome::DataDomeConfig, - didomi::DidomiIntegrationConfig, google_tag_manager::GoogleTagManagerConfig, gpt::GptConfig, - gpt_diagnostics::GptDiagnosticsConfig, lockr::LockrConfig, nextjs::NextJsIntegrationConfig, - osano::OsanoConfig, permutive::PermutiveConfig, prebid, sourcepoint::SourcepointConfig, - testlight::TestlightConfig, -}; -use crate::settings::{IntegrationConfig, Settings}; +use crate::integrations::IntegrationBuilder; +use crate::settings::Settings; const DEPLOY_VALIDATION_FIELD: &str = "trusted_server"; -#[cfg(test)] -const DEPLOY_VALIDATED_INTEGRATION_IDS: &[&str] = &[ - "prebid", - "aps", - "adserver_mock", - "testlight", - "nextjs", - "permutive", - "lockr", - "didomi", - "sourcepoint", - "osano", - "google_tag_manager", - "datadome", - "gpt", - "gpt_diagnostics", -]; /// Typed app-config root used by the `ts` CLI. /// @@ -118,69 +96,47 @@ impl edgezero_core::app_config::AppConfigMeta for TrustedServerAppConfig { } } -/// Runs Trusted Server deploy-time validation for pushed app config. +/// Runs Trusted Server deploy-time validation for pushed app config with the +/// built-in integrations and auction providers only. /// /// This supplements [`Settings`] structural validation with checks that should /// fail before an operator publishes a config blob: placeholder secrets, -/// enabled integration startup checks, auction provider references, and EC -/// partner registry construction. +/// integration startup checks, auction provider references, and EC partner +/// registry construction. /// /// # Errors /// /// Returns [`TrustedServerError`] when the config should not be deployed. pub fn validate_settings_for_deploy(settings: &Settings) -> Result<(), Report> { - settings.reject_placeholder_secrets()?; - let enabled_auction_providers = validate_enabled_integrations(settings)?; - validate_auction_provider_names(settings, &enabled_auction_providers)?; - PartnerRegistry::from_config(&settings.ec.partners).map(|_| ())?; - Ok(()) + validate_settings_for_deploy_with(settings, &[], &[]) } -fn validate_enabled_integrations( +/// Validates settings for deployment with the built-in integrations and +/// auction providers followed by the externally supplied builders an adapter +/// registers. Every builder validates, enabled or not, so a typo in a +/// disabled block is still caught. +/// +/// # Errors +/// +/// Returns [`TrustedServerError`] when the config should not be deployed. +pub fn validate_settings_for_deploy_with( settings: &Settings, -) -> Result, Report> { - let mut enabled_auction_providers = HashSet::new(); - - if validate_prebid(settings)? { - enabled_auction_providers.insert("prebid"); - } - if validate_integration::(settings, "aps")? { - enabled_auction_providers.insert("aps"); - } - if validate_integration::(settings, "adserver_mock")? { - enabled_auction_providers.insert("adserver_mock"); + extra_integrations: &[IntegrationBuilder], + extra_auction_providers: &[AuctionProviderBuilder], +) -> Result<(), Report> { + settings.reject_placeholder_secrets()?; + for builder in crate::integrations::all_builders(extra_integrations) { + builder.validate(settings)?; } - validate_integration::(settings, "testlight")?; - validate_integration::(settings, "nextjs")?; - validate_integration::(settings, "permutive")?; - validate_integration::(settings, "lockr")?; - validate_integration::(settings, "didomi")?; - validate_integration::(settings, "sourcepoint")?; - validate_integration::(settings, "osano")?; - validate_integration::(settings, "google_tag_manager")?; - if let Some(config) = settings.integration_config::("datadome")? { - crate::integrations::datadome::DataDomeIntegration::validate_config_for_startup(config)?; + let mut enabled_auction_providers = HashSet::new(); + for builder in crate::auction::all_provider_builders(extra_auction_providers) { + if builder.validate(settings)? { + enabled_auction_providers.insert(builder.name()); + } } - validate_integration::(settings, "gpt")?; - validate_integration::(settings, "gpt_diagnostics")?; - - Ok(enabled_auction_providers) -} - -fn validate_prebid(settings: &Settings) -> Result> { - prebid::validate_config_for_startup(settings).map(|config| config.is_some()) -} - -fn validate_integration( - settings: &Settings, - integration_id: &str, -) -> Result> -where - T: IntegrationConfig, -{ - settings - .integration_config::(integration_id) - .map(|config| config.is_some()) + validate_auction_provider_names(settings, &enabled_auction_providers)?; + PartnerRegistry::from_config(&settings.ec.partners).map(|_| ())?; + Ok(()) } fn validate_auction_provider_names( @@ -220,9 +176,45 @@ fn report_to_validation_errors(report: &Report) -> Validatio #[cfg(test)] mod tests { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use super::*; + use crate::auction::AuctionProvider; + use crate::integrations::{ + IntegrationRegistration, lockr::LockrConfig, permutive::PermutiveConfig, + sourcepoint::SourcepointConfig, + }; use crate::test_support::tests::crate_test_settings_str; + /// Message an external builder rejects with, so the test can prove the + /// rejection reached the caller intact. + const EXTERNAL_REJECTION_MESSAGE: &str = "seam probe refuses to deploy"; + + /// Stands in for a vendor integration builder that never enables. + fn build_nothing( + _settings: &Settings, + ) -> Result, Report> { + Ok(None) + } + + /// Stands in for a vendor auction provider builder that never registers. + fn build_no_providers( + _settings: &Settings, + ) -> Result>, Report> { + Ok(Vec::new()) + } + + fn reject_deploy(_settings: &Settings) -> Result> { + Err(Report::new(TrustedServerError::Configuration { + message: EXTERNAL_REJECTION_MESSAGE.to_string(), + })) + } + + fn report_enabled(_settings: &Settings) -> Result> { + Ok(true) + } + #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] #[allow(dead_code)] @@ -469,7 +461,13 @@ cookie_domain = ".example.com" origin_url = "https://origin.example.com" proxy_secret = "change-me-proxy-secret" +[geo] +assume_single_jurisdiction = true + [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "production-secret-key-32-bytes-min" [[handlers]] @@ -687,17 +685,140 @@ password = "production-admin-password-32-bytes" ); } + /// Counts calls to [`record_validate_call`]. A builder holds plain fn + /// pointers and cannot capture, so the recording has to go through a + /// static. + static RECORDED_VALIDATE_CALLS: AtomicUsize = AtomicUsize::new(0); + + fn record_validate_call(_settings: &Settings) -> Result> { + RECORDED_VALIDATE_CALLS.fetch_add(1, Ordering::SeqCst); + Ok(false) + } + + /// Every builder handed to deploy validation has its `validate` run, and + /// reporting disabled does not excuse a builder from validating. + #[test] + fn deploy_validation_runs_every_builder_it_is_given() { + RECORDED_VALIDATE_CALLS.store(0, Ordering::SeqCst); + let extra_integrations = [IntegrationBuilder::new( + "seam-probe-integration", + "seam-probe-crate", + build_nothing, + record_validate_call, + )]; + let extra_auction_providers = [AuctionProviderBuilder::new( + "seam-probe-provider", + "seam-probe-crate", + build_no_providers, + record_validate_call, + )]; + + validate_settings_for_deploy_with( + &valid_settings(), + &extra_integrations, + &extra_auction_providers, + ) + .expect("should accept settings whose external builders report disabled"); + + assert_eq!( + RECORDED_VALIDATE_CALLS.load(Ordering::SeqCst), + 2, + "both external builders should validate even though each reports disabled" + ); + } + + /// Deploy validation reaches each built-in builder's own config type, one + /// id at a time, by planting a block that type cannot deserialize. Every + /// integration config carries a boolean `enabled`, so a string there fails + /// for all of them. + /// + /// This catches deploy validation ceasing to validate the built-ins, or + /// validating only the enabled ones. It cannot catch a builder deleted + /// from `BUILT_IN_BUILDERS`, because the loop below reads the same + /// constant the validation walks; no independent list of the built-ins + /// exists in the crate. + #[test] + fn deploy_validation_reaches_every_built_in_builder() { + for id in crate::integrations::builders() + .iter() + .map(IntegrationBuilder::id) + .chain( + crate::auction::provider_builders() + .iter() + .map(AuctionProviderBuilder::name), + ) + { + let mut settings = valid_settings(); + settings + .integrations + .insert_config(id, &serde_json::json!({ "enabled": "not-a-boolean" })) + .expect("should insert the probe config"); + + assert!( + validate_settings_for_deploy(&settings).is_err(), + "deploy validation should reach the `{id}` builder and reject its planted config" + ); + } + } + #[test] - fn deploy_validation_covers_registered_integration_builders() { - let validated_ids: HashSet<&'static str> = - DEPLOY_VALIDATED_INTEGRATION_IDS.iter().copied().collect(); - let missing_ids = crate::integrations::registered_builder_ids() - .filter(|id| !validated_ids.contains(id)) - .collect::>(); + fn deploy_validation_surfaces_an_external_integration_builders_rejection() { + let extra = [IntegrationBuilder::new( + "seam-probe", + "seam-probe-crate", + build_nothing, + reject_deploy, + )]; + + let err = validate_settings_for_deploy_with(&valid_settings(), &extra, &[]) + .expect_err("should surface the external integration builder's rejection"); + + assert!( + err.to_string().contains(EXTERNAL_REJECTION_MESSAGE), + "should keep the external builder's message intact: {err:?}" + ); + } + + #[test] + fn deploy_validation_surfaces_an_external_auction_providers_rejection() { + let extra = [AuctionProviderBuilder::new( + "seam-probe", + "seam-probe-crate", + build_no_providers, + reject_deploy, + )]; + + let err = validate_settings_for_deploy_with(&valid_settings(), &[], &extra) + .expect_err("should surface the external auction provider builder's rejection"); + + assert!( + err.to_string().contains(EXTERNAL_REJECTION_MESSAGE), + "should keep the external builder's message intact: {err:?}" + ); + } + + #[test] + fn an_external_auction_provider_satisfies_a_configured_provider_name() { + let mut settings = valid_settings(); + settings.auction.enabled = true; + settings.auction.providers = vec!["probe".to_string()]; + let extra = [AuctionProviderBuilder::new( + "probe", + "seam-probe-crate", + build_no_providers, + report_enabled, + )]; + + validate_settings_for_deploy_with(&settings, &[], &extra) + .expect("an external auction provider should satisfy its configured name"); + + let err = validate_settings_for_deploy(&settings) + .expect_err("should reject the configured name without the external builder"); assert!( - missing_ids.is_empty(), - "deploy validation should cover all registered integration builders: {missing_ids:?}" + err.to_string() + .contains("no enabled integration provides it"), + "should report the unprovided auction provider name: {err:?}" ); } diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index 6ede36e9c..2525a528d 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -154,7 +154,9 @@ mod tests { fn strings_that_look_like_json_scalars_round_trip_as_strings() { let mut original = test_settings(); original.publisher.proxy_secret = Redacted::new("1234567890".to_string()); - original.ec.passphrase = Redacted::new("12345678901234567890123456789012".to_string()); + original.ec.providers.hmac = Some(crate::settings::HmacProviderConfig { + passphrase: Redacted::new("12345678901234567890123456789012".to_string()), + }); original.handlers[0].password = Redacted::new("true".to_string()); let reconstructed = settings_from_config_blob(&envelope_json(&original)) @@ -166,8 +168,22 @@ mod tests { "numeric-looking proxy secret should remain a string" ); assert_eq!( - reconstructed.ec.passphrase.expose(), - original.ec.passphrase.expose(), + reconstructed + .ec + .providers + .hmac + .as_ref() + .expect("should reconstruct the hmac provider") + .passphrase + .expose(), + original + .ec + .providers + .hmac + .as_ref() + .expect("should keep the hmac provider") + .passphrase + .expose(), "numeric-looking passphrase should remain a string" ); assert_eq!( diff --git a/crates/trusted-server-core/src/consent/jurisdiction.rs b/crates/trusted-server-core/src/consent/jurisdiction.rs index 907373073..0c3258f1a 100644 --- a/crates/trusted-server-core/src/consent/jurisdiction.rs +++ b/crates/trusted-server-core/src/consent/jurisdiction.rs @@ -1,32 +1,86 @@ -//! Jurisdiction detection for consent observability. +//! Jurisdiction detection: which privacy regime a request falls under. //! -//! Determines the applicable privacy regime based on geolocation data and -//! publisher configuration. Used for **logging and monitoring only** — the -//! detected jurisdiction never causes consent to be synthesized (see proposal -//! Key Decision #3). +//! The regime comes from the request's location, resolved through the same +//! place tree in `permissions.yaml` that decides the permission baseline, so +//! one file states the policy for both. The detected jurisdiction never causes +//! consent to be synthesized (see proposal Key Decision #3), but it is not +//! only observability either: the server-side auction gate +//! (`consent_allows_server_side_auction`) fails closed on a GDPR or unknown +//! jurisdiction, and a US state jurisdiction is what lets a GPC header be +//! turned into a US Privacy string. use core::fmt; -use crate::consent_config::ConsentConfig; use crate::geo::GeoInfo; +use crate::permissions::PermissionMaps; /// The privacy jurisdiction applicable to a request. /// -/// Derived from the user's geolocation and the publisher's configured -/// country/state lists. Used for observability — not for consent synthesis. +/// Resolved from the request's place through the `rules` tree in +/// `permissions.yaml`, where each node may name the jurisdiction that applies +/// there and a node without one inherits from the node above it. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub enum Jurisdiction { - /// GDPR applies (EU/EEA/UK per `consent.gdpr.applies_in`). + /// GDPR applies (the EU, EEA and UK regime). Gdpr, /// A US state with an active comprehensive privacy law. UsState(String), - /// Geolocation is known but no matching regulation was found. + /// The place is known but no matching regulation was found. NonRegulated, - /// No geolocation data available — jurisdiction cannot be determined. + /// No jurisdiction could be determined, for example after a failed geo + /// lookup. #[default] Unknown, } +impl Jurisdiction { + /// Parses a `jurisdiction:` value written in the `permissions.yaml` + /// `rules` tree. + /// + /// The vocabulary covers exactly the states this type can represent, so a + /// policy owner cannot write a jurisdiction the consent code has no way of + /// applying: + /// + /// - `gdpr` for [`Jurisdiction::Gdpr`], the EU, EEA and UK regime. + /// - `us-state` for [`Jurisdiction::UsState`]. It carries no code, because + /// the node that names it is itself a region, so `region` supplies the + /// state (upper-cased). A node with no region of its own, meaning the top + /// of the tree or a country, cannot name it, and `None` is returned. + /// - `non-regulated` for [`Jurisdiction::NonRegulated`], a place with no + /// matching regulation. + /// - `unknown` for [`Jurisdiction::Unknown`], declining to name one. + /// + /// `region` is the ISO 3166-2 subdivision code of the node carrying the + /// value, or `None` for the top of the tree and for a country. + /// + /// Returns `None` for anything else, which the permission policy parser + /// reports as a configuration error rather than silently defaulting. + /// + /// # Examples + /// + /// ``` + /// use trusted_server_core::consent::jurisdiction::Jurisdiction; + /// + /// assert_eq!(Jurisdiction::from_policy_name("gdpr", None), Some(Jurisdiction::Gdpr)); + /// assert_eq!( + /// Jurisdiction::from_policy_name("us-state", Some("ca")), + /// Some(Jurisdiction::UsState("CA".to_owned())) + /// ); + /// assert_eq!(Jurisdiction::from_policy_name("us-state", None), None); + /// assert_eq!(Jurisdiction::from_policy_name("nonsense", None), None); + /// ``` + #[must_use] + pub fn from_policy_name(value: &str, region: Option<&str>) -> Option { + match value { + "gdpr" => Some(Self::Gdpr), + "us-state" => region.map(|code| Self::UsState(code.to_uppercase())), + "non-regulated" => Some(Self::NonRegulated), + "unknown" => Some(Self::Unknown), + _ => None, + } + } +} + impl fmt::Display for Jurisdiction { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -38,44 +92,29 @@ impl fmt::Display for Jurisdiction { } } -/// Detects the privacy jurisdiction for a request based on geolocation. +/// Detects the privacy jurisdiction for a request from its location. +/// +/// Walks the `permissions.yaml` place tree the same way the permission +/// baseline is resolved: the request's region when it is listed, otherwise its +/// country, otherwise the top of the tree. A node that names no jurisdiction of +/// its own inherits the one above it, which is resolved when the file is +/// parsed. /// -/// Checks the user's country against `config.gdpr.applies_in`, and for US -/// users checks the region against `config.us_states.privacy_states`. +/// The tree also settles the `DE` collision on its own, because ISO 3166-1 +/// `DE` is Germany and sits at the country level, while ISO 3166-2 `DE` is +/// Delaware and sits under `US`. /// -/// Returns [`Jurisdiction::Unknown`] when no geo data is available. +/// With no location this returns the top node's jurisdiction, the policy's +/// declared answer for a visitor whose place is not resolved. A caller that +/// must not apply that declaration, such as one holding a failed geo lookup, +/// resolves [`Jurisdiction::Unknown`] itself rather than calling this. #[must_use] -pub fn detect_jurisdiction(geo: Option<&GeoInfo>, config: &ConsentConfig) -> Jurisdiction { - let Some(geo) = geo else { - return Jurisdiction::Unknown; - }; - - // Check GDPR countries first (EU/EEA/UK). This ordering also resolves - // the `DE` code collision: ISO 3166-1 `DE` is Germany (GDPR), while - // US-Delaware uses ISO 3166-2 `US-DE`. The US state check below only - // triggers when `country == "US"`, so there is no actual ambiguity. - if config - .gdpr - .applies_in - .iter() - .any(|code| code.eq_ignore_ascii_case(&geo.country)) - { - return Jurisdiction::Gdpr; +pub fn detect_jurisdiction(geo: Option<&GeoInfo>) -> Jurisdiction { + let maps = PermissionMaps::standard(); + match geo { + Some(geo) => maps.jurisdiction_for(Some(&geo.country), geo.region.as_deref()), + None => maps.default_jurisdiction(), } - - // For US users, check if the region is a state with a privacy law. - if geo.country.eq_ignore_ascii_case("US") - && let Some(region) = &geo.region - && config - .us_states - .privacy_states - .iter() - .any(|state| state.eq_ignore_ascii_case(region)) - { - return Jurisdiction::UsState(region.to_uppercase()); - } - - Jurisdiction::NonRegulated } // --------------------------------------------------------------------------- @@ -85,14 +124,13 @@ pub fn detect_jurisdiction(geo: Option<&GeoInfo>, config: &ConsentConfig) -> Jur #[cfg(test)] mod tests { use super::{Jurisdiction, detect_jurisdiction}; - use crate::consent_config::ConsentConfig; use crate::geo::GeoInfo; fn make_geo(country: &str, region: Option<&str>) -> GeoInfo { GeoInfo { - city: "Test".to_owned(), + city: "Test City".to_owned(), country: country.to_owned(), - continent: "Test".to_owned(), + continent: "EU".to_owned(), latitude: 0.0, longitude: 0.0, metro_code: 0, @@ -103,10 +141,9 @@ mod tests { #[test] fn gdpr_detected_for_eu_country() { - let config = ConsentConfig::default(); let geo = make_geo("DE", None); assert_eq!( - detect_jurisdiction(Some(&geo), &config), + detect_jurisdiction(Some(&geo)), Jurisdiction::Gdpr, "Germany should trigger GDPR" ); @@ -114,10 +151,9 @@ mod tests { #[test] fn gdpr_detected_for_eea_country() { - let config = ConsentConfig::default(); let geo = make_geo("NO", None); assert_eq!( - detect_jurisdiction(Some(&geo), &config), + detect_jurisdiction(Some(&geo)), Jurisdiction::Gdpr, "Norway (EEA) should trigger GDPR" ); @@ -125,77 +161,98 @@ mod tests { #[test] fn gdpr_detected_for_uk() { - let config = ConsentConfig::default(); let geo = make_geo("GB", None); assert_eq!( - detect_jurisdiction(Some(&geo), &config), + detect_jurisdiction(Some(&geo)), Jurisdiction::Gdpr, - "UK should trigger GDPR" + "the UK should trigger GDPR" ); } #[test] fn us_state_detected_for_california() { - let config = ConsentConfig::default(); let geo = make_geo("US", Some("CA")); assert_eq!( - detect_jurisdiction(Some(&geo), &config), + detect_jurisdiction(Some(&geo)), Jurisdiction::UsState("CA".to_owned()), "California should trigger US state privacy" ); } #[test] - fn us_non_privacy_state_is_non_regulated() { - let config = ConsentConfig::default(); + fn delaware_is_a_us_state_and_germany_is_not() { + // ISO 3166-1 `DE` is Germany and ISO 3166-2 `DE` is Delaware. The tree + // keeps them apart by where they sit, so no ordering rule is needed. + let delaware = make_geo("US", Some("DE")); + assert_eq!( + detect_jurisdiction(Some(&delaware)), + Jurisdiction::UsState("DE".to_owned()), + "US/DE should be Delaware" + ); + let germany = make_geo("DE", None); + assert_eq!( + detect_jurisdiction(Some(&germany)), + Jurisdiction::Gdpr, + "DE at the country level should be Germany" + ); + } + + #[test] + fn us_non_privacy_state_inherits_the_country_node() { let geo = make_geo("US", Some("WY")); assert_eq!( - detect_jurisdiction(Some(&geo), &config), + detect_jurisdiction(Some(&geo)), Jurisdiction::NonRegulated, - "Wyoming should be non-regulated" + "Wyoming is not listed, so it inherits the US node" ); } #[test] fn us_no_region_is_non_regulated() { - let config = ConsentConfig::default(); let geo = make_geo("US", None); assert_eq!( - detect_jurisdiction(Some(&geo), &config), + detect_jurisdiction(Some(&geo)), Jurisdiction::NonRegulated, - "US without region should be non-regulated" + "the US without a region should be non-regulated" ); } #[test] - fn non_gdpr_non_us_is_non_regulated() { - let config = ConsentConfig::default(); + fn an_unlisted_country_inherits_the_top_of_the_tree() { + // Nothing is written for Japan, so it inherits the top node, which the + // shipped policy sets to GDPR. That is the same node an unresolved + // place gets, so an unlisted country is treated no more loosely than a + // visitor with no place at all. let geo = make_geo("JP", None); assert_eq!( - detect_jurisdiction(Some(&geo), &config), - Jurisdiction::NonRegulated, - "Japan should be non-regulated" + detect_jurisdiction(Some(&geo)), + Jurisdiction::Gdpr, + "an unlisted country should inherit the top of the tree" ); } #[test] - fn no_geo_returns_unknown() { - let config = ConsentConfig::default(); + fn no_geo_uses_the_top_of_the_tree() { assert_eq!( - detect_jurisdiction(None, &config), - Jurisdiction::Unknown, - "missing geo should return unknown" + detect_jurisdiction(None), + Jurisdiction::Gdpr, + "with no place the policy's declared top node applies" ); } #[test] - fn case_insensitive_country_matching() { - let config = ConsentConfig::default(); + fn case_insensitive_place_matching() { let geo = make_geo("de", None); assert_eq!( - detect_jurisdiction(Some(&geo), &config), + detect_jurisdiction(Some(&geo)), Jurisdiction::Gdpr, - "lowercase country code should still match" + "a lowercase country code should still match" + ); + let state = make_geo("us", Some("ca")); + assert_eq!( + detect_jurisdiction(Some(&state)), + Jurisdiction::UsState("CA".to_owned()), + "a lowercase region code should still match and upper-case the state" ); } diff --git a/crates/trusted-server-core/src/consent/mod.rs b/crates/trusted-server-core/src/consent/mod.rs index f205a8363..df98bd7aa 100644 --- a/crates/trusted-server-core/src/consent/mod.rs +++ b/crates/trusted-server-core/src/consent/mod.rs @@ -54,6 +54,7 @@ use http::Request; use crate::consent_config::{ConflictMode, ConsentConfig, ConsentMode}; use crate::geo::GeoInfo; +use crate::permissions::{Permission, PermissionState}; /// Number of deciseconds in one day (86 400 seconds × 10). const DECISECONDS_PER_DAY: u64 = 86_400 * 10; @@ -76,6 +77,16 @@ pub struct ConsentPipelineInput<'a> { pub config: &'a ConsentConfig, /// Geolocation data from the request (for jurisdiction detection). pub geo: Option<&'a GeoInfo>, + /// The jurisdiction to apply when `geo` resolved no location. + /// + /// Jurisdiction is detected from geolocation, so with no location every + /// request would be [`Jurisdiction::Unknown`] and the consent gates would + /// fail closed even where the permission policy declares what to do. This + /// carries that declaration (see + /// [`PermissionMaps::default_jurisdiction`](crate::permissions::PermissionMaps::default_jurisdiction)). + /// Pass [`Jurisdiction::Unknown`] where no declaration applies, for example + /// after a failed geo lookup. + pub default_jurisdiction: jurisdiction::Jurisdiction, /// EC ID for KV Store consent persistence. /// /// When set along with `kv_store`, enables: @@ -130,14 +141,14 @@ pub fn build_consent_context(input: &ConsentPipelineInput<'_>) -> ConsentContext { // 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); + ctx.jurisdiction = request_jurisdiction(input); 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); + let jur = request_jurisdiction(input); let gpp_section_ids = signals .raw_gpp_sid .as_deref() @@ -163,7 +174,7 @@ pub fn build_consent_context(input: &ConsentPipelineInput<'_>) -> ConsentContext } let mut ctx = build_context_from_signals(&signals); - ctx.jurisdiction = jurisdiction::detect_jurisdiction(input.geo, input.config); + ctx.jurisdiction = request_jurisdiction(input); apply_tcf_conflict_resolution(&mut ctx, input.config); apply_expiration_check(&mut ctx, input.config); apply_gpc_us_privacy(&mut ctx, input.config); @@ -179,6 +190,19 @@ pub fn build_consent_context(input: &ConsentPipelineInput<'_>) -> ConsentContext ctx } +/// The jurisdiction for a request: detected from its location when one +/// resolved, otherwise the declaration the caller supplied. +/// +/// Keeping the two in one place means every path through the pipeline (proxy +/// mode, the KV read fallback, and the ordinary decode) answers the question +/// the same way. +fn request_jurisdiction(input: &ConsentPipelineInput<'_>) -> jurisdiction::Jurisdiction { + match input.geo { + Some(_) => jurisdiction::detect_jurisdiction(input.geo), + None => input.default_jurisdiction.clone(), + } +} + /// Marks TCF consent as expired when it exceeds the configured maximum age. /// /// Clears whichever decoded TCF source is active (`tcf` or `gpp.eu_tcf`) but @@ -322,7 +346,7 @@ fn has_eu_tcf_signal(raw_tc_present: bool, gpp_section_ids: Option<&[u16]>) -> b /// Returns the effective decoded TCF consent for enforcement decisions. #[must_use] -fn effective_tcf(ctx: &ConsentContext) -> Option<&types::TcfConsent> { +pub(crate) fn effective_tcf(ctx: &ConsentContext) -> Option<&types::TcfConsent> { ctx.tcf.as_ref().or_else(|| { let g = ctx.gpp.as_ref()?; g.eu_tcf.as_ref() @@ -475,46 +499,38 @@ pub fn build_us_privacy_from_gpc(config: &ConsentConfig) -> Option( +pub fn gate_eids_by_permissions( eids: Option>, - consent_ctx: Option<&ConsentContext>, + permissions: &PermissionState, ) -> Option> { let eids = eids?; if eids.is_empty() { return None; } - let tcf = consent_ctx.and_then(effective_tcf); - - match tcf { - Some(tcf) if allows_eid_transmission(tcf) => Some(eids), - Some(_) => { - log::info!("EIDs stripped: TCF Purpose 1 or 4 consent missing"); - None - } - None => { - // No TCF data — if GDPR applies, block EIDs as a precaution. - if consent_ctx.is_some_and(|c| c.gdpr_applies) { - log::info!("EIDs stripped: GDPR applies but no TCF consent available"); - None - } else { - Some(eids) - } - } + if permissions.is_set(Permission::StoreOnDevice) + && permissions.is_set(Permission::SelectPersonalisedAds) + { + Some(eids) + } else { + log::info!( + "EIDs stripped: necessary.operations.storage or advertising_marketing.first_party.targeted is not set in the resolved permissions" + ); + None } } @@ -522,110 +538,30 @@ pub fn gate_eids_by_consent( // EC consent gating // --------------------------------------------------------------------------- -/// Determines whether Edge Cookie (EC) creation is permitted based on the -/// user's consent and detected jurisdiction. +/// Returns `true` when the request carries a US-style storage/sale opt-out +/// signal (GPC, a GPP sale opt-out, or a US Privacy opt-out), independent of +/// jurisdiction. /// -/// The decision follows the jurisdiction's consent model: +/// This reports the signal only. Whether the opt-out changes a permission is +/// decided by the country/region map when the permission state is assembled: it +/// drops a `granted` baseline (for example a US opt-out state) and has nothing to +/// drop where the permission is `requires_signal`. Honoring it everywhere is +/// intentionally conservative. /// -/// - **GDPR (EU/UK)**: opt-in required — TCF Purpose 1 (store/access -/// information on a device) must be explicitly consented. If no TCF data is -/// available under GDPR, consent is assumed absent and EC is blocked. -/// - **US state privacy**: opt-out model — EC is allowed unless the user has -/// explicitly opted out via Global Privacy Control, GPP US sale opt-out, or -/// the US Privacy string. Explicit US opt-out signals take precedence over -/// TCF storage consent. -/// - **Non-regulated**: EC is allowed (no consent requirement). -/// - **Unknown**: fail-closed — jurisdiction cannot be determined so EC is -/// blocked as a precaution. +/// TCF consent or refusal is handled separately by +/// [`crate::ec::consent::permission_signal`], which treats a present TCF record +/// as authoritative, so this helper does not consider TCF. #[must_use] -pub fn allows_ec_creation(ctx: &ConsentContext) -> bool { - match &ctx.jurisdiction { - jurisdiction::Jurisdiction::Gdpr => { - // EU/UK: explicit opt-in required (TCF Purpose 1 = store/access device). - match effective_tcf(ctx) { - Some(tcf) => tcf.has_storage_consent(), - None => false, - } - } - jurisdiction::Jurisdiction::UsState(_) => { - // GPC is an independent opt-out signal — it always blocks EC - // creation regardless of other consent signals. - if ctx.gpc { - return false; - } - // Explicit US opt-out signals take precedence over TCF storage - // consent in US-state jurisdictions. - if ctx.gpp.as_ref().and_then(|gpp| gpp.us_sale_opt_out) == Some(true) { - return false; - } - if ctx - .us_privacy - .as_ref() - .is_some_and(|usp| usp.opt_out_sale == PrivacyFlag::Yes) - { - return false; - } - // When a CMP uses TCF in the US (e.g. Didomi), respect the TCF - // Purpose 1 decision if no explicit US opt-out signal is present. - if let Some(tcf) = effective_tcf(ctx) { - return tcf.has_storage_consent(); - } - // GPP US sale_opt_out=false is an explicit non-opt-out signal. - if let Some(gpp) = &ctx.gpp - && let Some(opted_out) = gpp.us_sale_opt_out - { - return !opted_out; - } - // Check US Privacy string when no TCF decision is present. - if let Some(usp) = &ctx.us_privacy { - return usp.opt_out_sale != PrivacyFlag::Yes; - } - // Spec §6.1.1: "In regulated jurisdictions (GDPR, US state), - // consent cookies/headers must be present for - // allows_ec_creation() to return true." No signals = block. - false - } - jurisdiction::Jurisdiction::NonRegulated => true, - // No geolocation data — cannot determine jurisdiction. - // Fail-closed: block EC creation as a precaution. - jurisdiction::Jurisdiction::Unknown => false, +pub fn has_storage_optout_signal(ctx: &ConsentContext) -> bool { + if ctx.gpc { + return true; } -} - -/// Returns `true` only when the request contains an explicit EC opt-out signal. -/// -/// This is intentionally narrower than [`allows_ec_creation`]. Some requests -/// fail closed because consent cannot be verified yet (for example, missing geo -/// or missing/undecodable consent signals in a regulated jurisdiction). Those -/// cases must block *new* EC creation, but they must not be treated as an -/// authoritative withdrawal of an already-issued EC. -#[must_use] -pub fn has_explicit_ec_withdrawal(ctx: &ConsentContext) -> bool { - match &ctx.jurisdiction { - jurisdiction::Jurisdiction::Gdpr => { - effective_tcf(ctx).is_some_and(|tcf| !tcf.has_storage_consent()) - } - jurisdiction::Jurisdiction::UsState(_) => { - if ctx.gpc { - return true; - } - if ctx.gpp.as_ref().and_then(|gpp| gpp.us_sale_opt_out) == Some(true) { - return true; - } - if ctx - .us_privacy - .as_ref() - .is_some_and(|usp| usp.opt_out_sale == PrivacyFlag::Yes) - { - return true; - } - if let Some(tcf) = effective_tcf(ctx) { - return !tcf.has_storage_consent(); - } - false - } - jurisdiction::Jurisdiction::NonRegulated | jurisdiction::Jurisdiction::Unknown => false, + if ctx.gpp.as_ref().and_then(|gpp| gpp.us_sale_opt_out) == Some(true) { + return true; } + ctx.us_privacy + .as_ref() + .is_some_and(|usp| usp.opt_out_sale == PrivacyFlag::Yes) } // --------------------------------------------------------------------------- @@ -701,11 +637,10 @@ mod tests { use http::Request; use super::{ - ConsentPipelineInput, allows_ec_creation, apply_expiration_check, - apply_tcf_conflict_resolution, build_consent_context, build_context_from_signals, - consent_allows_server_side_auction, has_explicit_ec_withdrawal, + ConsentPipelineInput, apply_expiration_check, apply_tcf_conflict_resolution, + build_consent_context, build_context_from_signals, consent_allows_server_side_auction, + gate_eids_by_permissions, has_storage_optout_signal, jurisdiction::Jurisdiction, }; - use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::{ ConsentContext, GppConsent, PrivacyFlag, RawConsentSignals, TcfConsent, UsPrivacy, }; @@ -894,7 +829,7 @@ mod tests { } #[test] - fn missing_geo_keeps_unknown_jurisdiction_and_blocks_ec_creation() { + fn missing_geo_keeps_unknown_jurisdiction() { let req = build_request(); let config = ConsentConfig::default(); @@ -903,6 +838,7 @@ mod tests { req: &req, config: &config, geo: None, + default_jurisdiction: Jurisdiction::Unknown, ec_id: None, kv_store: None, }); @@ -912,10 +848,6 @@ mod tests { Jurisdiction::Unknown, "missing geo should keep jurisdiction unknown" ); - assert!( - !allows_ec_creation(&ctx), - "missing geo should keep EC creation fail-closed" - ); } #[test] @@ -932,6 +864,7 @@ mod tests { req: &req, config: &config, geo: None, + default_jurisdiction: Jurisdiction::Unknown, ec_id: None, kv_store: None, }); @@ -962,6 +895,7 @@ mod tests { req: &req, config: &config, geo: None, + default_jurisdiction: Jurisdiction::Unknown, ec_id: None, kv_store: None, }); @@ -1071,408 +1005,6 @@ mod tests { ); } - // ----------------------------------------------------------------------- - // allows_ec_creation tests - // ----------------------------------------------------------------------- - - /// Helper: builds a TCF consent with configurable Purpose 1 (storage). - fn make_tcf_with_storage(has_storage: bool) -> TcfConsent { - TcfBuilder::new().with_storage(has_storage).build() - } - - #[test] - fn ec_allowed_gdpr_with_storage_consent() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Gdpr, - tcf: Some(make_tcf_with_storage(true)), - gdpr_applies: true, - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "GDPR + TCF Purpose 1 consented should allow EC" - ); - } - - #[test] - fn ec_blocked_gdpr_without_storage_consent() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Gdpr, - tcf: Some(make_tcf_with_storage(false)), - gdpr_applies: true, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "GDPR + TCF Purpose 1 not consented should block EC" - ); - } - - #[test] - fn ec_blocked_gdpr_no_tcf_data() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Gdpr, - tcf: None, - gpp: None, - gdpr_applies: true, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "GDPR with no TCF data should block EC" - ); - } - - #[test] - fn ec_allowed_gdpr_via_gpp_embedded_tcf() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Gdpr, - tcf: None, - gpp: Some(GppConsent { - version: 1, - section_ids: vec![2], - eu_tcf: Some(make_tcf_with_storage(true)), - us_sale_opt_out: None, - }), - gdpr_applies: true, - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "GDPR + GPP embedded TCF with P1 consent should allow EC" - ); - } - - #[test] - fn ec_allowed_us_state_no_optout() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::Yes, - opt_out_sale: PrivacyFlag::No, - lspa_covered: PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "US state + no opt-out should allow EC" - ); - } - - #[test] - fn ec_blocked_us_state_opted_out() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::Yes, - opt_out_sale: PrivacyFlag::Yes, - lspa_covered: PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US state + opt-out should block EC" - ); - } - - #[test] - fn ec_blocked_us_state_gpc_implies_optout() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - us_privacy: None, - gpc: true, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US state + GPC=true with no US Privacy string should block EC" - ); - } - - #[test] - fn ec_blocked_us_state_no_signals() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - us_privacy: None, - gpc: false, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US state + no consent signals should block EC (spec \u{a7}6.1.1: fail-closed)" - ); - } - - #[test] - fn ec_allowed_non_regulated() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::NonRegulated, - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "non-regulated jurisdiction should always allow EC" - ); - } - - #[test] - fn ec_blocked_unknown_jurisdiction() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Unknown, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "unknown jurisdiction should block EC (fail-closed when geo unavailable)" - ); - assert!( - !has_explicit_ec_withdrawal(&ctx), - "unknown jurisdiction should not be treated as an explicit withdrawal" - ); - } - - #[test] - fn ec_blocked_us_state_gpc_overrides_us_privacy() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::Yes, - opt_out_sale: PrivacyFlag::No, - lspa_covered: PrivacyFlag::NotApplicable, - }), - gpc: true, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "GPC=true should block EC even when US Privacy says no opt-out" - ); - assert!( - has_explicit_ec_withdrawal(&ctx), - "GPC=true should be treated as an explicit withdrawal signal" - ); - } - - #[test] - fn ec_us_privacy_not_applicable_allows_ec() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("VA".to_owned()), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::NotApplicable, - opt_out_sale: PrivacyFlag::NotApplicable, - lspa_covered: PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "US Privacy with opt_out=N/A should allow EC" - ); - } - - #[test] - fn ec_allowed_us_state_tcf_with_storage_consent() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - tcf: Some(make_tcf_with_storage(true)), - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "US state + TCF Purpose 1 consented should allow EC (Didomi-style CMP)" - ); - } - - #[test] - fn ec_blocked_us_state_tcf_without_storage_consent() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - tcf: Some(make_tcf_with_storage(false)), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US state + TCF Purpose 1 denied should block EC" - ); - } - - #[test] - fn ec_blocked_us_state_gpc_overrides_tcf() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - tcf: Some(make_tcf_with_storage(true)), - gpc: true, - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "GPC should block EC even when TCF grants storage consent in US state" - ); - } - - #[test] - fn ec_blocked_us_state_us_privacy_opt_out_overrides_tcf() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - tcf: Some(make_tcf_with_storage(true)), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::Yes, - opt_out_sale: PrivacyFlag::Yes, - lspa_covered: PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US Privacy opt-out should take priority over TCF consent" - ); - assert!( - has_explicit_ec_withdrawal(&ctx), - "US Privacy opt-out should be treated as an explicit withdrawal" - ); - } - - #[test] - fn ec_allowed_us_state_gpp_no_sale_opt_out() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - gpp: Some(GppConsent { - version: 1, - section_ids: vec![7], - eu_tcf: None, - us_sale_opt_out: Some(false), - }), - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "US state + GPP US sale_opt_out=false should allow EC" - ); - } - - #[test] - fn ec_blocked_us_state_gpp_sale_opted_out() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - gpp: Some(GppConsent { - version: 1, - section_ids: vec![7], - eu_tcf: None, - us_sale_opt_out: Some(true), - }), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US state + GPP US sale_opt_out=true should block EC" - ); - assert!( - has_explicit_ec_withdrawal(&ctx), - "GPP US sale opt-out should be treated as an explicit withdrawal" - ); - } - - #[test] - fn ec_blocked_us_state_gpc_overrides_gpp_us() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - gpc: true, - gpp: Some(GppConsent { - version: 1, - section_ids: vec![7], - eu_tcf: None, - us_sale_opt_out: Some(false), - }), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "GPC should block EC even when GPP US says no opt-out" - ); - } - - #[test] - fn ec_us_state_gpp_us_opt_out_overrides_tcf() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - tcf: Some(make_tcf_with_storage(true)), - gpp: Some(GppConsent { - version: 1, - section_ids: vec![7], - eu_tcf: None, - us_sale_opt_out: Some(true), - }), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "GPP US opt-out should take priority over TCF consent" - ); - assert!( - has_explicit_ec_withdrawal(&ctx), - "GPP US opt-out should be treated as an explicit withdrawal" - ); - } - - #[test] - fn ec_us_state_us_privacy_opt_out_overrides_gpp_non_opt_out() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("TN".to_owned()), - gpp: Some(GppConsent { - version: 1, - section_ids: vec![7], - eu_tcf: None, - us_sale_opt_out: Some(false), - }), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::Yes, - opt_out_sale: PrivacyFlag::Yes, - lspa_covered: PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - }; - assert!( - !allows_ec_creation(&ctx), - "US Privacy opt-out should block EC even when GPP US has no sale opt-out" - ); - assert!( - has_explicit_ec_withdrawal(&ctx), - "US Privacy opt-out should be treated as an explicit withdrawal" - ); - } - - #[test] - fn ec_us_state_gpp_no_us_section_falls_through_to_us_privacy() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - gpp: Some(GppConsent { - version: 1, - section_ids: vec![2], - eu_tcf: None, - us_sale_opt_out: None, - }), - us_privacy: Some(UsPrivacy { - version: 1, - notice_given: PrivacyFlag::Yes, - opt_out_sale: PrivacyFlag::No, - lspa_covered: PrivacyFlag::NotApplicable, - }), - ..ConsentContext::default() - }; - assert!( - allows_ec_creation(&ctx), - "GPP without US section should fall through to us_privacy" - ); - } - // ----------------------------------------------------------------------- // Consent KV read-fallback / write-on-change pipeline tests // ----------------------------------------------------------------------- @@ -1554,6 +1086,7 @@ mod tests { req: &req, config: &config, geo: None, + default_jurisdiction: Jurisdiction::Unknown, ec_id: Some("test-ec-id"), kv_store: Some(&store), }); @@ -1585,6 +1118,7 @@ mod tests { req: &req, config: &config, geo: None, + default_jurisdiction: Jurisdiction::Unknown, ec_id: Some("test-ec-id"), kv_store: Some(&store), }); @@ -1596,6 +1130,7 @@ mod tests { req: &bare_req, config: &config, geo: None, + default_jurisdiction: Jurisdiction::Unknown, ec_id: Some("test-ec-id"), kv_store: Some(&store), }); @@ -1620,6 +1155,7 @@ mod tests { req: &req, config: &config, geo: None, + default_jurisdiction: Jurisdiction::Unknown, ec_id: None, kv_store: Some(&store), }); @@ -1629,4 +1165,86 @@ mod tests { "should not persist consent without an EC ID" ); } + + #[test] + fn gate_eids_keeps_eids_when_required_permissions_are_set() { + // US maps to us-opt-out, where necessary.operations.storage and advertising_marketing.first_party.targeted + // are granted with no signal, so bidstream EIDs are transmitted. + let permissions = + crate::permissions::PermissionMaps::standard().resolve(Some("US"), |_| false); + let eids = Some(vec!["eid-1".to_owned()]); + assert!( + gate_eids_by_permissions(eids, &permissions).is_some(), + "EIDs should pass when necessary.operations.storage and advertising_marketing.first_party.targeted are set" + ); + } + + #[test] + fn gate_eids_strips_eids_when_a_required_permission_is_unset() { + // FR maps to gdpr-eu, where every purpose is requires_signal, so with no + // signal neither required permission is set and EIDs are stripped. + let permissions = + crate::permissions::PermissionMaps::standard().resolve(Some("FR"), |_| false); + let eids = Some(vec!["eid-1".to_owned()]); + assert!( + gate_eids_by_permissions(eids, &permissions).is_none(), + "EIDs should be stripped when a required permission is not set" + ); + } + + #[test] + fn gate_eids_returns_none_for_empty_input() { + let permissions = + crate::permissions::PermissionMaps::standard().resolve(Some("US"), |_| false); + assert!( + gate_eids_by_permissions::(None, &permissions).is_none(), + "no EIDs should resolve to None" + ); + assert!( + gate_eids_by_permissions(Some(Vec::::new()), &permissions).is_none(), + "an empty EID list should resolve to None" + ); + } + + #[test] + fn has_storage_optout_signal_detects_us_style_opt_outs() { + let gpc = ConsentContext { + gpc: true, + ..ConsentContext::default() + }; + assert!(has_storage_optout_signal(&gpc), "GPC is a storage opt-out"); + + let gpp_sale_opt_out = ConsentContext { + gpp: Some(GppConsent { + version: 1, + section_ids: vec![8], + eu_tcf: None, + us_sale_opt_out: Some(true), + }), + ..ConsentContext::default() + }; + assert!( + has_storage_optout_signal(&gpp_sale_opt_out), + "a GPP US sale opt-out is a storage opt-out" + ); + + let usp_opt_out = ConsentContext { + us_privacy: Some(UsPrivacy { + version: 1, + notice_given: PrivacyFlag::Yes, + opt_out_sale: PrivacyFlag::Yes, + lspa_covered: PrivacyFlag::No, + }), + ..ConsentContext::default() + }; + assert!( + has_storage_optout_signal(&usp_opt_out), + "a US Privacy sale opt-out is a storage opt-out" + ); + + assert!( + !has_storage_optout_signal(&ConsentContext::default()), + "no signal is not a storage opt-out" + ); + } } diff --git a/crates/trusted-server-core/src/consent/types.rs b/crates/trusted-server-core/src/consent/types.rs index 73c2bbc3f..2cf8a6a8e 100644 --- a/crates/trusted-server-core/src/consent/types.rs +++ b/crates/trusted-server-core/src/consent/types.rs @@ -149,6 +149,21 @@ pub struct ConsentContext { } impl ConsentContext { + /// Whether any consent record is present in raw form but failed to decode. + /// + /// A malformed record is not the same as no record: the visitor expressed + /// a preference that could not be read, so the permission mapping blocks + /// baseline grants (fail-closed) instead of degrading to the no-signal + /// baseline. An expired TCF record is excluded because expiry is its own + /// explicit state ([`expired`](Self::expired)): the raw string is kept for + /// proxy forwarding while the decoded record is deliberately cleared. + #[must_use] + pub fn has_malformed_record(&self) -> bool { + (self.raw_tc_string.is_some() && self.tcf.is_none() && !self.expired) + || (self.raw_gpp_string.is_some() && self.gpp.is_none()) + || (self.raw_us_privacy.is_some() && self.us_privacy.is_none()) + } + /// Returns `true` when no consent signals are present. #[must_use] pub fn is_empty(&self) -> bool { diff --git a/crates/trusted-server-core/src/consent_config.rs b/crates/trusted-server-core/src/consent_config.rs index 465629a51..a20b187e6 100644 --- a/crates/trusted-server-core/src/consent_config.rs +++ b/crates/trusted-server-core/src/consent_config.rs @@ -12,27 +12,6 @@ const MAX_CONSENT_AGE_DAYS: u32 = 395; /// How many days newer one string must be to win under the `newest` strategy. const FRESHNESS_THRESHOLD_DAYS: u32 = 30; -/// EU member states (27) + EEA non-EU (3) + UK GDPR (1). -/// -/// Switzerland (`CH`) is intentionally excluded: the Swiss FADP mirrors GDPR -/// but is a separate legal regime. Publishers operating in Switzerland can add -/// `CH` to `consent.gdpr.applies_in` in their configuration. -const GDPR_COUNTRIES: &[&str] = &[ - "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE", "IT", "LV", - "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE", "IS", "LI", "NO", "GB", -]; - -/// US states with active comprehensive privacy laws (as of 2026). -const US_PRIVACY_STATES: &[&str] = &[ - "CA", "VA", "CO", "CT", "UT", "MT", "OR", "TX", "FL", "DE", "IA", "NE", "NH", "NJ", "TN", "MN", - "MD", "IN", "KY", "RI", -]; - -/// Converts a static `&[&str]` slice to an owned `Vec`. -fn str_vec(codes: &[&str]) -> Vec { - codes.iter().copied().map(String::from).collect() -} - /// Top-level consent configuration (`[consent]` in TOML). #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] @@ -56,14 +35,6 @@ pub struct ConsentConfig { #[serde(default = "default_max_consent_age_days")] pub max_consent_age_days: u32, - /// GDPR jurisdiction configuration. - #[serde(default)] - pub gdpr: GdprConfig, - - /// US state privacy law configuration. - #[serde(default)] - pub us_states: UsStatesConfig, - /// Defaults for constructing a US Privacy string when only `Sec-GPC` /// is present and no explicit `us_privacy` cookie exists. #[serde(default)] @@ -86,8 +57,6 @@ impl Default for ConsentConfig { mode: ConsentMode::Interpreter, check_expiration: true, max_consent_age_days: MAX_CONSENT_AGE_DAYS, - gdpr: GdprConfig::default(), - us_states: UsStatesConfig::default(), us_privacy_defaults: UsPrivacyDefaultsConfig::default(), conflict_resolution: ConflictResolutionConfig::default(), consent_store: None, @@ -165,55 +134,6 @@ impl ConsentForwardingMode { } } -// --------------------------------------------------------------------------- -// GDPR -// --------------------------------------------------------------------------- - -/// GDPR jurisdiction configuration (`[consent.gdpr]`). -/// -/// The `applies_in` list is used for **observability and logging only** — it -/// does NOT cause consent to be synthesized. When a user's country appears in -/// this list, the system logs that GDPR applies, enabling publishers to -/// monitor jurisdiction coverage. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct GdprConfig { - /// ISO 3166-1 alpha-2 country codes where GDPR applies. - #[serde(default = "default_gdpr_countries")] - pub applies_in: Vec, -} - -impl Default for GdprConfig { - fn default() -> Self { - Self { - applies_in: str_vec(GDPR_COUNTRIES), - } - } -} - -// --------------------------------------------------------------------------- -// US States -// --------------------------------------------------------------------------- - -/// US state privacy law configuration (`[consent.us_states]`). -/// -/// Config-driven to avoid recompilation when new state laws take effect. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct UsStatesConfig { - /// US state codes with active comprehensive privacy laws. - #[serde(default = "default_us_privacy_states")] - pub privacy_states: Vec, -} - -impl Default for UsStatesConfig { - fn default() -> Self { - Self { - privacy_states: str_vec(US_PRIVACY_STATES), - } - } -} - // --------------------------------------------------------------------------- // US Privacy defaults (GPC handling) // --------------------------------------------------------------------------- @@ -315,14 +235,6 @@ const fn default_freshness_threshold_days() -> u32 { FRESHNESS_THRESHOLD_DAYS } -fn default_gdpr_countries() -> Vec { - str_vec(GDPR_COUNTRIES) -} - -fn default_us_privacy_states() -> Vec { - str_vec(US_PRIVACY_STATES) -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -354,35 +266,6 @@ mod tests { ); } - #[test] - fn default_gdpr_countries_includes_eu_eea_uk() { - let config = ConsentConfig::default(); - let countries = &config.gdpr.applies_in; - assert!( - countries.contains(&"DE".to_owned()), - "should include Germany" - ); - assert!( - countries.contains(&"NO".to_owned()), - "should include Norway (EEA)" - ); - assert!(countries.contains(&"GB".to_owned()), "should include UK"); - assert_eq!( - countries.len(), - 31, - "should have 31 countries (27 EU + 3 EEA + 1 UK)" - ); - } - - #[test] - fn default_us_privacy_states_includes_california() { - let config = ConsentConfig::default(); - assert!( - config.us_states.privacy_states.contains(&"CA".to_owned()), - "should include California" - ); - } - #[test] fn default_us_privacy_defaults_reflect_common_posture() { let config = ConsentConfig::default(); @@ -469,8 +352,6 @@ mod tests { "mode": "interpreter", "check_expiration": false, "max_consent_age_days": 180, - "gdpr": { "applies_in": ["DE", "FR"] }, - "us_states": { "privacy_states": ["CA"] }, "us_privacy_defaults": { "notice_given": false, "lspa_covered": true, @@ -485,8 +366,6 @@ mod tests { serde_json::from_value(json).expect("should deserialize full config"); assert!(!config.check_expiration); assert_eq!(config.max_consent_age_days, 180); - assert_eq!(config.gdpr.applies_in, vec!["DE", "FR"]); - assert_eq!(config.us_states.privacy_states, vec!["CA"]); assert!(!config.us_privacy_defaults.notice_given); assert!(config.us_privacy_defaults.lspa_covered); assert_eq!(config.conflict_resolution.mode, ConflictMode::Newest); diff --git a/crates/trusted-server-core/src/constants.rs b/crates/trusted-server-core/src/constants.rs index e1152b1e7..15531ec52 100644 --- a/crates/trusted-server-core/src/constants.rs +++ b/crates/trusted-server-core/src/constants.rs @@ -1,6 +1,11 @@ use http::header::HeaderName; pub const COOKIE_TS_EC: &str = "ts-ec"; +/// Non-`HttpOnly` companion to [`COOKIE_TS_EC`], set when a client-cycle +/// resolve succeeds. It carries no identity (the value is `1`); it only lets +/// the page script see that an Edge Cookie exists, which the `HttpOnly` cookie +/// itself cannot, so the script does not re-post on every page view. +pub const COOKIE_TS_EC_RESOLVED: &str = "ts-ecr"; /// Cookie written by the Trusted Server JS SDK containing a standard-base64-encoded /// JSON array of Extended User IDs (`[{ source, uids }]`) from identity providers. pub const COOKIE_TS_EIDS: &str = "ts-eids"; @@ -42,19 +47,31 @@ pub const HEADER_ACCEPT_LANGUAGE: HeaderName = HeaderName::from_static("accept-l pub const HEADER_ACCEPT_ENCODING: HeaderName = HeaderName::from_static("accept-encoding"); pub const HEADER_REFERER: HeaderName = HeaderName::from_static("referer"); -/// TS-internal header names that must NOT be forwarded to downstream third-party services. +/// The fixed response headers that carry Edge Cookie identity output. /// -/// These headers are used internally by Trusted Server for identification, geo-enrichment, -/// debugging, and compression hints. Leaking them to external origins could expose -/// data and internal implementation details. +/// EC finalization strips these from a response the request was not permitted +/// to carry an identity on (see `clear_ec_headers_on_response` in +/// [`finalize`](crate::ec::finalize)), and they are also internal headers, so +/// [`INTERNAL_HEADERS`] is built from this list rather than repeating it. That +/// is the whole reason the list lives here alongside `INTERNAL_HEADERS` and not +/// beside its only reader, because two hand-written copies of one list drift as +/// soon as a header is added to one of them. /// -/// Uses `&str` slices because `HeaderName` has interior mutability and cannot appear -/// in `const` context. -pub const INTERNAL_HEADERS: &[&str] = &[ +/// Uses `&str` slices for the same reason [`INTERNAL_HEADERS`] does. +pub const EC_RESPONSE_HEADERS: &[&str] = &[ "x-ts-ec", "x-ts-eids", "x-ts-ec-consent", "x-ts-eids-truncated", +]; + +/// The internal headers that are not part of the Edge Cookie output surface. +/// +/// Kept apart from [`EC_RESPONSE_HEADERS`] only so [`INTERNAL_HEADERS`] can be +/// assembled from the two without repeating either. Add a header here unless it +/// is one EC finalization has to strip, in which case it belongs in +/// [`EC_RESPONSE_HEADERS`] and reaches [`INTERNAL_HEADERS`] from there. +const NON_EC_INTERNAL_HEADERS: &[&str] = &[ "x-pub-user-id", "x-subject-id", "x-consent-advertising", @@ -76,6 +93,43 @@ pub const INTERNAL_HEADERS: &[&str] = &[ "x-ts-tls-cipher", ]; +/// How many names [`INTERNAL_HEADERS`] holds. +const INTERNAL_HEADER_COUNT: usize = EC_RESPONSE_HEADERS.len() + NON_EC_INTERNAL_HEADERS.len(); + +/// Joins the two source lists into the array [`INTERNAL_HEADERS`] borrows. +/// +/// Written as a `const fn` because slice concatenation is not available in a +/// `const` initializer, and the join has to happen while the crate is compiled +/// so no caller pays for it. +const fn join_internal_headers() -> [&'static str; INTERNAL_HEADER_COUNT] { + let mut joined = [""; INTERNAL_HEADER_COUNT]; + let mut i = 0; + while i < EC_RESPONSE_HEADERS.len() { + joined[i] = EC_RESPONSE_HEADERS[i]; + i += 1; + } + let mut j = 0; + while j < NON_EC_INTERNAL_HEADERS.len() { + joined[i + j] = NON_EC_INTERNAL_HEADERS[j]; + j += 1; + } + joined +} + +/// TS-internal header names that must NOT be forwarded to downstream third-party services. +/// +/// These headers are used internally by Trusted Server for identification, geo-enrichment, +/// debugging, and compression hints. Leaking them to external origins could expose +/// data and internal implementation details. +/// +/// Built at compile time from [`EC_RESPONSE_HEADERS`] followed by +/// [`NON_EC_INTERNAL_HEADERS`], so an Edge Cookie response header cannot be +/// added to one list and missed in the other. +/// +/// Uses `&str` slices because `HeaderName` has interior mutability and cannot appear +/// in `const` context. +pub const INTERNAL_HEADERS: &[&str] = &join_internal_headers(); + // Consent-related cookie names pub const COOKIE_EUCONSENT_V2: &str = "euconsent-v2"; pub const COOKIE_GPP: &str = "__gpp"; @@ -84,3 +138,37 @@ pub const COOKIE_US_PRIVACY: &str = "us_privacy"; // Consent-related header names pub const HEADER_SEC_GPC: HeaderName = HeaderName::from_static("sec-gpc"); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_edge_cookie_response_header_is_an_internal_header() { + // These two lists used to be written out by hand in two files, with + // nothing keeping them in step, so a new Edge Cookie response header + // could be stripped by EC finalization and still forwarded to a third + // party. `INTERNAL_HEADERS` is now assembled from + // `EC_RESPONSE_HEADERS`, and this is the assertion that fails if + // anyone goes back to writing them out separately. + for header in EC_RESPONSE_HEADERS { + assert!( + INTERNAL_HEADERS.contains(header), + "`{header}` carries Edge Cookie output, so it must never be forwarded" + ); + } + + assert_eq!( + INTERNAL_HEADERS.len(), + EC_RESPONSE_HEADERS.len() + NON_EC_INTERNAL_HEADERS.len(), + "every internal header should come from exactly one of the two source lists" + ); + + for (index, header) in INTERNAL_HEADERS.iter().enumerate() { + assert!( + !INTERNAL_HEADERS[index + 1..].contains(header), + "`{header}` is listed twice, so the two source lists overlap" + ); + } + } +} diff --git a/crates/trusted-server-core/src/ec/admin.rs b/crates/trusted-server-core/src/ec/admin.rs index 6219af7a9..d1d2a19dc 100644 --- a/crates/trusted-server-core/src/ec/admin.rs +++ b/crates/trusted-server-core/src/ec/admin.rs @@ -32,7 +32,6 @@ use crate::error::TrustedServerError; use crate::openrtb::Eid; use super::eids::{resolve_partner_ids, to_eids}; -use super::generation::is_valid_ec_id; use super::kv::KvIdentityGraph; use super::kv_backend::EcKvLookup; use super::kv_types::{KvEntry, KvMetadata}; @@ -40,6 +39,7 @@ use super::log_id; use super::prebid_eids::{ analyze_prebid_eids_cookie, collect_sharedid_update, dedupe_partner_updates, is_valid_eid_uid, }; +use super::provider::{AcceptedProviders, EdgeCookieProvider}; use super::registry::PartnerRegistry; /// Route prefix shared by the cookie-based and explicit-ID lookup routes. @@ -276,13 +276,14 @@ struct SkippedPartnerId { pub fn handle_admin_ec_lookup( kv: Option<&KvIdentityGraph>, registry: &PartnerRegistry, + provider: Option<&dyn EdgeCookieProvider>, req: &Request, ) -> Result, Report> { let Some(kv) = kv else { return Ok(admin_ec_lookup_not_supported()); }; - let ec_id = match requested_ec_id(req) { + let ec_id = match requested_ec_id(req, &AcceptedProviders::active(provider)) { Ok(ec_id) => ec_id, Err(response) => return Ok(*response), }; @@ -342,9 +343,17 @@ fn cookie_ec_id(req: &Request) -> Result) -> Result>> { +fn requested_ec_id( + req: &Request, + accepted_providers: &AcceptedProviders<'_>, +) -> Result>> { let remainder = req .uri() .path() @@ -358,10 +367,12 @@ fn requested_ec_id(req: &Request) -> Result &'static str { + "opaque" + } + + fn code(&self) -> super::super::provider::ProviderCode { + crate::provider_code!("t0op") + } + + async fn generate( + &self, + _request_info: &dyn crate::evidence::RequestInfo, + _input: &super::super::provider::IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, + ) -> Result> + { + Ok(super::super::provider::GeneratedEdgeCookie::default()) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + } + + #[test] + fn requested_ec_id_accepts_the_hmac_envelope() { + let coded = format!("hmac~{}", test_ec_id()); + let request = request_with_method(http::Method::GET, &format!("/_ts/admin/ec/{coded}")); + + let ec_id = requested_ec_id(&request, &AcceptedProviders::active(None)) + .unwrap_or_else(|_| panic!("should accept a coded HMAC identifier in the path")); + + assert_eq!(ec_id, coded, "should look up the identifier as given"); + } + + #[test] + fn requested_ec_id_accepts_the_active_non_hmac_provider_and_rejects_others() { + // The diagnostic must be usable on a deployment whose provider is not + // the built-in HMAC one. Before the dispatch every non-`hmac` code was + // a 400, so an operator could not look up the identifier in the very + // cookie the browser was carrying. + let accepted = AcceptedProviders::active(Some(&OpaqueProvider)); + + let opaque = "t0op~Opaque_Value_MixedCase"; + let request = request_with_method(http::Method::GET, &format!("/_ts/admin/ec/{opaque}")); + let ec_id = requested_ec_id(&request, &accepted) + .unwrap_or_else(|_| panic!("should accept the active provider's identifier")); + assert_eq!(ec_id, opaque, "should look up the identifier as given"); + + // A code no configured provider reads stays a 400, even in the built-in + // HMAC shape, so one deployment cannot inspect another's identifiers. + let foreign = format!("t0zz~{}", test_ec_id()); + let request = request_with_method(http::Method::GET, &format!("/_ts/admin/ec/{foreign}")); + let response = requested_ec_id(&request, &accepted) + .expect_err("an unread provider code should be rejected"); + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "an unread provider code should be a 400" + ); + } } diff --git a/crates/trusted-server-core/src/ec/batch_sync.rs b/crates/trusted-server-core/src/ec/batch_sync.rs index 0e0f3b900..1d4a39159 100644 --- a/crates/trusted-server-core/src/ec/batch_sync.rs +++ b/crates/trusted-server-core/src/ec/batch_sync.rs @@ -20,9 +20,9 @@ use serde::{Deserialize, Serialize}; use crate::error::TrustedServerError; use super::auth::authenticate_bearer; -use super::generation::{is_valid_ec_id, normalize_ec_id_for_kv}; use super::kv::{KvIdentityGraph, UpsertResult}; use super::log_id; +use super::provider::{AcceptedProviders, EdgeCookieProvider}; use super::rate_limiter::RateLimiter; use super::registry::PartnerRegistry; @@ -101,15 +101,17 @@ pub fn handle_batch_sync( kv: &KvIdentityGraph, registry: &PartnerRegistry, rate_limiter: &dyn RateLimiter, + provider: Option<&dyn EdgeCookieProvider>, req: Request, ) -> Result, Report> { - handle_batch_sync_with_writer(kv, registry, rate_limiter, req) + handle_batch_sync_with_writer(kv, registry, rate_limiter, provider, req) } fn handle_batch_sync_with_writer( writer: &dyn BatchSyncWriter, registry: &PartnerRegistry, rate_limiter: &dyn RateLimiter, + provider: Option<&dyn EdgeCookieProvider>, req: Request, ) -> Result, Report> { // 1. Authenticate @@ -153,7 +155,12 @@ fn handle_batch_sync_with_writer( } // 4. Process mappings with per-item validation and rejection reasons. - let (accepted, errors) = process_mappings(writer, &partner.source_domain, &body.mappings); + let (accepted, errors) = process_mappings( + writer, + &partner.source_domain, + &body.mappings, + &AcceptedProviders::active(provider), + ); let rejected = errors.len(); let status = if rejected > 0 { @@ -183,19 +190,24 @@ fn process_mappings( writer: &dyn BatchSyncWriter, partner_id: &str, mappings: &[SyncMapping], + accepted_providers: &AcceptedProviders<'_>, ) -> (usize, Vec) { let mut accepted: usize = 0; let mut errors = Vec::new(); for (idx, mapping) in mappings.iter().enumerate() { - let ec_id = normalize_ec_id_for_kv(&mapping.ec_id); - if !is_valid_ec_id(&ec_id) { + // The global cookie bounds, then the provider that owns the + // identifier's code, which canonicalizes its own value part and decides + // whether the canonical form is one of its own. A partner echoing back + // an identifier a non-HMAC provider created is accepted here; an + // identifier under a code this deployment does not read is not. + let Some(ec_id) = accepted_providers.canonical_kv_key(&mapping.ec_id) else { errors.push(MappingError { index: idx, reason: REASON_INVALID_EC_ID, }); continue; - } + }; if mapping.partner_uid.trim().is_empty() || mapping.partner_uid.len() > MAX_UID_LENGTH { errors.push(MappingError { @@ -266,17 +278,49 @@ mod tests { use super::*; use std::collections::VecDeque; + use crate::ec::provider::{HmacProvider, IdentityInput, ProviderCode}; use crate::error::TrustedServerError; + use crate::evidence::RequestInfo; use crate::redacted::Redacted; use crate::settings::EcPartner; - // EC ID validation tests are in generation.rs (is_valid_ec_id). - // Verify the import works here with a basic smoke test. - #[test] - fn is_valid_ec_id_smoke_test() { - let valid = format!("{}.ABC123", "a".repeat(64)); - assert!(is_valid_ec_id(&valid)); - assert!(!is_valid_ec_id(&"a".repeat(64))); + /// The built-in provider, standing in for a deployment that selected it. + fn hmac_provider() -> HmacProvider { + HmacProvider::new(Redacted::new("test-secret-key-32-bytes-minimum".to_owned())) + } + + /// A non-HMAC provider whose identifiers are opaque, modeling the + /// host-signal provider PR #1044 adds: valid identifiers that the built-in + /// HMAC grammar rejects outright. + #[derive(Debug)] + struct OpaqueProvider; + + #[async_trait::async_trait(?Send)] + impl crate::ec::provider::EdgeCookieProvider for OpaqueProvider { + fn id(&self) -> &'static str { + "opaque" + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0op") + } + + async fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, + ) -> Result> { + Ok(crate::ec::provider::GeneratedEdgeCookie::default()) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + value.to_owned() + } } struct MockRateLimiter { @@ -421,7 +465,7 @@ mod tests { .body(EdgeBody::from("not-json")) .expect("should build test request"); - let response = handle_batch_sync_with_writer(&writer, ®istry, &limiter, req) + let response = handle_batch_sync_with_writer(&writer, ®istry, &limiter, None, req) .expect("should return oversized response"); assert_eq!( @@ -447,7 +491,7 @@ mod tests { .body(EdgeBody::from(oversized_body)) .expect("should build test request"); - let response = handle_batch_sync_with_writer(&writer, ®istry, &limiter, req) + let response = handle_batch_sync_with_writer(&writer, ®istry, &limiter, None, req) .expect("should return oversized response"); assert_eq!( @@ -466,7 +510,13 @@ mod tests { mapping(&format!("{}.ABC123", "a".repeat(64)), "u3", 1), ]; - let (accepted, errors) = process_mappings(&writer, "partner", &mappings); + let provider = hmac_provider(); + let (accepted, errors) = process_mappings( + &writer, + "partner", + &mappings, + &AcceptedProviders::active(Some(&provider)), + ); assert_eq!(accepted, 1, "should count successful writes as accepted"); assert_eq!(errors.len(), 2, "should reject invalid mappings only"); @@ -493,7 +543,13 @@ mod tests { mapping(&format!("{}.ABC123", "c".repeat(64)), "u3", 1), ]; - let (accepted, errors) = process_mappings(&writer, "partner", &mappings); + let provider = hmac_provider(); + let (accepted, errors) = process_mappings( + &writer, + "partner", + &mappings, + &AcceptedProviders::active(Some(&provider)), + ); assert_eq!(accepted, 1, "should keep accepted count before failure"); assert_eq!( @@ -521,7 +577,7 @@ mod tests { .expect("should build test request"); let response = - handle_batch_sync(&kv, ®istry, &limiter, req).expect("should return response"); + handle_batch_sync(&kv, ®istry, &limiter, None, req).expect("should return response"); assert_eq!( response.status(), StatusCode::UNAUTHORIZED, @@ -581,7 +637,13 @@ mod tests { let ec_id = format!("{}.ABC123", "a".repeat(64)); let mappings = vec![mapping(&ec_id, "uid-1", 100), mapping(&ec_id, "uid-2", 101)]; - let (accepted, errors) = process_mappings(&writer, "partner", &mappings); + let provider = hmac_provider(); + let (accepted, errors) = process_mappings( + &writer, + "partner", + &mappings, + &AcceptedProviders::active(Some(&provider)), + ); assert_eq!(accepted, 0, "should not accept ineligible mappings"); assert_eq!(errors.len(), 2, "should report both errors"); @@ -591,13 +653,104 @@ mod tests { assert_eq!(errors[1].reason, REASON_INELIGIBLE); } + #[test] + fn process_mappings_accepts_an_identifier_from_the_active_non_hmac_provider() { + // A deployment whose active provider is not the built-in HMAC one still + // has to accept the identifiers that provider created. Before the + // dispatch these were rejected outright by the HMAC grammar, so a + // partner could never sync a mapping against them. + let writer = MockWriter::new(vec![Ok(UpsertResult::Written)]); + let mappings = vec![mapping("t0op~Opaque_Value_MixedCase", "uid-1", 100)]; + + let (accepted, errors) = process_mappings( + &writer, + "partner", + &mappings, + &AcceptedProviders::active(Some(&OpaqueProvider)), + ); + + assert_eq!(accepted, 1, "the active provider's identifier is accepted"); + assert!( + errors.is_empty(), + "should report no errors, got: {errors:?}" + ); + } + + #[test] + fn process_mappings_rejects_a_code_no_configured_provider_reads() { + // The other side of the dispatch: a code belonging to a provider this + // deployment neither runs nor reads is not an identifier here, whatever + // its shape. + let writer = MockWriter::new(vec![]); + let hmac_shaped = format!("t0zz~{}.ABC123", "a".repeat(64)); + let mappings = vec![ + mapping("t0zz~Opaque_Value", "uid-1", 100), + mapping(&hmac_shaped, "uid-2", 100), + ]; + + let (accepted, errors) = process_mappings( + &writer, + "partner", + &mappings, + &AcceptedProviders::active(Some(&OpaqueProvider)), + ); + + assert_eq!(accepted, 0, "an unknown provider code is not accepted"); + assert_eq!(errors.len(), 2, "both mappings should be rejected"); + assert!( + errors + .iter() + .all(|error| error.reason == REASON_INVALID_EC_ID), + "should reject as an invalid EC ID, got: {errors:?}" + ); + } + + #[test] + fn process_mappings_canonicalizes_through_the_owning_provider() { + // KV normalization is dispatched the same way as validation. The + // built-in provider lowercases its hash segment, so a partner echoing + // uppercase hex still writes the row created at generation time, while + // the opaque provider's own normalization leaves its value untouched. + let writer = MockWriter::new(vec![Ok(UpsertResult::Written)]); + let uppercase = format!("hmac~{}.ABC123", "A".repeat(64)); + let provider = hmac_provider(); + let accepted_providers = AcceptedProviders::active(Some(&provider)); + + assert_eq!( + accepted_providers.canonical_kv_key(&uppercase), + Some(format!("hmac~{}.ABC123", "a".repeat(64))), + "the built-in provider should lowercase only its hash segment" + ); + assert_eq!( + AcceptedProviders::active(Some(&OpaqueProvider)) + .canonical_kv_key("t0op~Opaque_Value_MixedCase"), + Some("t0op~Opaque_Value_MixedCase".to_owned()), + "an opaque provider's identifier should be keyed verbatim" + ); + + let mappings = vec![mapping(&uppercase, "uid-1", 100)]; + let (accepted, errors) = + process_mappings(&writer, "partner", &mappings, &accepted_providers); + assert_eq!(accepted, 1, "uppercase hex should still be accepted"); + assert!( + errors.is_empty(), + "should report no errors, got: {errors:?}" + ); + } + #[test] fn process_mappings_counts_unchanged_as_accepted() { let writer = MockWriter::new(vec![Ok(UpsertResult::Unchanged)]); let ec_id = format!("{}.ABC123", "a".repeat(64)); let mappings = vec![mapping(&ec_id, "uid-1", 100)]; - let (accepted, errors) = process_mappings(&writer, "partner", &mappings); + let provider = hmac_provider(); + let (accepted, errors) = process_mappings( + &writer, + "partner", + &mappings, + &AcceptedProviders::active(Some(&provider)), + ); assert_eq!(accepted, 1, "should count unchanged mappings as accepted"); assert!( @@ -615,7 +768,13 @@ mod tests { mapping(&ec_id, "uid-old", 100), ]; - let (accepted, errors) = process_mappings(&writer, "partner", &mappings); + let provider = hmac_provider(); + let (accepted, errors) = process_mappings( + &writer, + "partner", + &mappings, + &AcceptedProviders::active(Some(&provider)), + ); assert_eq!( accepted, 2, @@ -623,4 +782,27 @@ mod tests { ); assert!(errors.is_empty(), "should accept valid mappings"); } + + #[test] + fn process_mappings_accepts_a_minted_coded_ec_id() { + let writer = MockWriter::new(vec![Ok(UpsertResult::Written)]); + // Partners echo the identifier identify gave them, which carries the + // provider-code envelope since the creation path applies it. + let ec_id = format!("hmac~{}.ABC123", "a".repeat(64)); + let mappings = vec![mapping(&ec_id, "uid-1", 1)]; + + let provider = hmac_provider(); + let (accepted, errors) = process_mappings( + &writer, + "partner", + &mappings, + &AcceptedProviders::active(Some(&provider)), + ); + + assert_eq!(accepted, 1, "should accept a coded HMAC identifier"); + assert!( + errors.is_empty(), + "should report no format error for a coded HMAC identifier" + ); + } } diff --git a/crates/trusted-server-core/src/ec/consent.rs b/crates/trusted-server-core/src/ec/consent.rs index ad9f5dd29..f87d811d5 100644 --- a/crates/trusted-server-core/src/ec/consent.rs +++ b/crates/trusted-server-core/src/ec/consent.rs @@ -1,77 +1,645 @@ -//! EC-specific consent gating. +//! EC-specific permission gating, resolved through the permission model. //! -//! This module provides the public consent-check API for the EC subsystem. -//! The underlying logic lives in [`crate::consent::allows_ec_creation`]; this -//! wrapper exists so that EC callers can import from `ec::consent` and the -//! eventual migration path (renaming, adding EC-specific conditions) is -//! contained here. +//! The Edge Cookie provider advertises the [`Permission`]s its data use +//! requires. [`assemble_permissions`] resolves which permissions are set for a +//! request, from its session signals and the country it maps to, and the +//! context construction gates the provider on that state. The EC permission +//! decision lives here, in the EC subsystem, and nowhere else, so callers +//! route every EC permission check through this module rather than +//! re-deriving one. use crate::consent::ConsentContext; +use crate::consent::jurisdiction::Jurisdiction; +use crate::permissions::{ + Acquisition, ConsentSignal, OptOutSource, Permission, PermissionMaps, PermissionState, + SignalPolicy, +}; +use crate::platform::GeoInfo; -/// Determines whether Edge Cookie creation is permitted based on the -/// user's consent and detected jurisdiction. +/// The outcome of the geo lookup for a request, separating "no location +/// resolved" from "the lookup failed". /// -/// This is the canonical entry point for EC consent checks. It delegates -/// to [`crate::consent::allows_ec_creation`] today but may diverge as -/// EC-specific consent rules evolve. +/// The two must not collapse: with no location (the provider is disabled, or +/// had no data for the address) the permission policy's top node applies, but +/// when the lookup errored the request's place is unknown in a way that top +/// node must not paper over, so every permission resolves to the +/// requires-signal floor instead. +#[derive(Debug, Clone, Copy)] +pub enum GeoStatus<'a> { + /// The provider resolved a location. + Located(&'a GeoInfo), + /// The provider resolved no location, so the policy's top node applies. + NoLocation, + /// The lookup errored, so the requires-signal floor applies. + Failed, +} + +impl<'a> GeoStatus<'a> { + /// The resolved location, when one exists. + #[must_use] + pub fn info(self) -> Option<&'a GeoInfo> { + match self { + GeoStatus::Located(info) => Some(info), + GeoStatus::NoLocation | GeoStatus::Failed => None, + } + } +} + +impl<'a> From> for GeoStatus<'a> { + fn from(geo: Option<&'a GeoInfo>) -> Self { + match geo { + Some(info) => GeoStatus::Located(info), + None => GeoStatus::NoLocation, + } + } +} + +/// The jurisdiction the consent gates apply to a request, from its resolved +/// location or, with none, from the permission policy's top node. +/// +/// The consent gates (for example the server-side auction gate) detect a +/// jurisdiction from geolocation. With no location they would resolve +/// `Unknown` and fail closed even where the policy declares what to do, so the +/// same fallback the permission model applies is offered here: the top node's +/// `jurisdiction` stands in for the missing location. A failed lookup stays +/// unknown, so the consent gates fail closed alongside the requires-signal +/// floor. +#[must_use] +pub fn default_jurisdiction(geo: GeoStatus<'_>) -> Jurisdiction { + match geo { + GeoStatus::NoLocation => PermissionMaps::standard().default_jurisdiction(), + GeoStatus::Located(_) | GeoStatus::Failed => Jurisdiction::Unknown, + } +} + +/// Assembles the permission state for a request: the place baseline from the +/// tree in `permissions.yaml`, augmented by the session's signals. +/// +/// Permissions exist without a consent model. With no signal present the result +/// is simply the baseline for the request's country and region. When the geo +/// provider resolves no location, or a country/region that has no rule, the +/// policy's top node applies, and the top node's `group` is required so one is +/// always available. A failed lookup ([`GeoStatus::Failed`]) instead resolves +/// every permission to the requires-signal floor, so an outage is handled +/// protectively rather than as the policy's declared default. +#[must_use] +pub fn assemble_permissions(consent: &ConsentContext, geo: GeoStatus<'_>) -> PermissionState { + let maps = PermissionMaps::standard(); + let signal = permission_signal(consent, maps.signals()); + match geo { + GeoStatus::Failed => PermissionMaps::floor_with(signal), + GeoStatus::Located(_) | GeoStatus::NoLocation => { + let info = geo.info(); + maps.resolve_with( + info.map(|info| info.country.as_str()), + info.and_then(|info| info.region.as_deref()), + signal, + ) + } + } +} + +/// The acquisition rule for Edge Cookie storage in the request's resolved +/// jurisdiction, used to scope destructive withdrawal. /// -/// See [`crate::consent::allows_ec_creation`] for the full decision matrix. +/// Resolves the same rules as [`assemble_permissions`] (the request's +/// country/region, the policy's top node when unmatched, and the +/// requires-signal floor when the lookup failed) and returns the rule for +/// [`Permission::StoreOnDevice`]. #[must_use] -pub fn ec_consent_granted(consent_context: &ConsentContext) -> bool { - crate::consent::allows_ec_creation(consent_context) +pub fn storage_acquisition(geo: GeoStatus<'_>) -> Acquisition { + match geo { + GeoStatus::Failed => Acquisition::RequiresSignal, + GeoStatus::Located(_) | GeoStatus::NoLocation => { + let info = geo.info(); + PermissionMaps::standard() + .rules_or_default( + info.map(|info| info.country.as_str()), + info.and_then(|info| info.region.as_deref()), + ) + .map_or(Acquisition::RequiresSignal, |rules| { + rules.rule_for(Permission::StoreOnDevice) + }) + } + } +} + +/// Maps a consent context to a [`ConsentSignal`] for each permission, applying +/// the [`SignalPolicy`] the permission model parsed from `permissions.yaml`. +/// +/// This is the only place the EC subsystem reads consent signals. The policy, +/// not this function, decides which sources are authoritative, which TCF purpose +/// maps to which Data Use, and what a US-style opt-out revokes. This function +/// only decodes the request and applies that policy, so no signal-to-permission +/// policy lives in the code. +/// +/// It considers every source the policy names: a TCF record (a standalone TC +/// string or the EU TCF section of a GPP string), and the US-style opt-out +/// signals (GPC, a GPP sale opt-out, or a US Privacy opt-out). Precedence is +/// most-restrictive-first and is fixed in code, not policy: +/// +/// 1. A US-style opt-out revokes the Data Uses the policy lists, even when a +/// TCF record consents. An opt-out is an explicit user signal, so no other +/// signal may override it. +/// 2. A consent record that is present but cannot be decoded revokes +/// everything, so an unreadable expression of preference fails closed +/// instead of degrading to the no-signal baseline. +/// 3. When the policy marks TCF authoritative, a present TCF record then +/// decides the mapped Data Uses: granted where the record consents to the +/// mapped purpose, revoked where it does not, and neutral where no purpose +/// is mapped. The `authoritative` flag governs only whether TCF grants and +/// revokes apply, never whether an opt-out may be overridden. +/// +/// Whether a `Revoke` changes anything is decided by the country/region map, +/// which drops a `granted` baseline and has nothing to drop where the +/// permission is `requires_signal` or `denied`. +fn permission_signal<'a>( + consent: &'a ConsentContext, + signals: &'a SignalPolicy, +) -> impl Fn(Permission) -> ConsentSignal + 'a { + move |permission| { + if opt_out_present(consent, signals.opt_out_sources()) + && signals.opt_out_revokes(permission) + { + return ConsentSignal::Revoke; + } + if consent.has_malformed_record() { + return ConsentSignal::Revoke; + } + if signals.tcf_authoritative() + && let Some(tcf) = crate::consent::effective_tcf(consent) + { + return match signals.tcf_purpose(permission) { + Some(purpose) => { + if tcf.has_purpose_consent(usize::from(purpose)) { + ConsentSignal::Grant + } else { + ConsentSignal::Revoke + } + } + None => ConsentSignal::Neutral, + }; + } + ConsentSignal::Neutral + } +} + +/// Whether the request carries any of the `sources` a US-style opt-out is +/// declared to use. Decoding only, so the policy (not this function) decides +/// which sources count and what the opt-out revokes. +fn opt_out_present(consent: &ConsentContext, sources: &[OptOutSource]) -> bool { + sources.iter().any(|source| match source { + OptOutSource::Gpc => consent.gpc, + OptOutSource::GppSaleOptOut => { + consent.gpp.as_ref().and_then(|gpp| gpp.us_sale_opt_out) == Some(true) + } + OptOutSource::UsPrivacyOptOut => consent + .us_privacy + .as_ref() + .is_some_and(|usp| usp.opt_out_sale == crate::consent::PrivacyFlag::Yes), + }) } -/// Returns `true` when the request carries an explicit EC withdrawal signal. +/// Reports whether the request carries an explicit signal withdrawing Edge +/// Cookie storage, rather than merely lacking the permission. /// -/// This is intentionally stricter than [`ec_consent_granted`]. A fail-closed -/// result such as unknown jurisdiction or missing consent data must not be -/// treated as an authoritative withdrawal of an already-issued EC. +/// This separates an affirmative withdrawal (which expires the browser cookie +/// and writes the authoritative identity-graph tombstone) from suppression, +/// where the permission is simply not set for this request (which strips EC +/// response headers but must not destroy an already-issued identifier, or a +/// returning user would be permanently withdrawn before they ever get to +/// consent). +/// +/// Only a TCF record refusing storage (Purpose 1) withdraws, and only where +/// the jurisdiction's storage baseline is not `granted`: under a +/// `requires_signal` baseline the refusal is the visitor declining the very +/// signal storage depends on, while under a `granted` baseline storage never +/// depended on the record, so the refusal suppresses use without destroying +/// the identifier. US-style opt-outs (GPC, a GPP sale opt-out, or a US +/// Privacy opt-out) suppress the permissions the policy revokes but are +/// never destructive, and no signal at all is not a withdrawal. #[must_use] -pub fn ec_consent_withdrawn(consent_context: &ConsentContext) -> bool { - crate::consent::has_explicit_ec_withdrawal(consent_context) +pub fn ec_storage_withdrawn(consent: &ConsentContext, storage_baseline: Acquisition) -> bool { + if let Some(tcf) = crate::consent::effective_tcf(consent) { + return !tcf.has_storage_consent() && !matches!(storage_baseline, Acquisition::Granted); + } + false } #[cfg(test)] mod tests { use super::*; - use crate::consent::jurisdiction::Jurisdiction; + use crate::consent::TcfConsent; + use crate::test_support::tests::create_test_settings; + + /// Builds a minimal decoded TCF record consenting to the given 1-indexed + /// purposes, with everything else refused. + fn tcf_with_purposes(consented: &[usize]) -> TcfConsent { + let mut purpose_consents = vec![false; 24]; + for &purpose in consented { + purpose_consents[purpose - 1] = true; + } + TcfConsent { + version: 2, + cmp_id: 0, + cmp_version: 0, + consent_screen: 0, + consent_language: "EN".to_owned(), + vendor_list_version: 0, + tcf_policy_version: 2, + created_ds: 0, + last_updated_ds: 0, + purpose_consents, + purpose_legitimate_interests: vec![false; 24], + vendor_consents: Vec::new(), + vendor_legitimate_interests: Vec::new(), + special_feature_opt_ins: vec![false; 12], + } + } + + #[test] + fn hmac_provider_is_blocked_without_a_storage_signal() { + let settings = create_test_settings(); + // The test settings select the HMAC provider, which requires + // necessary.operations.storage. The policy's top node resolves storage + // as requires-signal, so with no signal the permission is not set and + // the provider's requirement is not met. + let provider = crate::ec::provider::build_provider(&settings.ec, None, None) + .expect("should build the configured provider") + .expect("should select the hmac provider"); + let state = assemble_permissions(&ConsentContext::default(), GeoStatus::NoLocation); + assert!( + !state.all_set(provider.required_permissions()), + "the requires-signal default should not satisfy the HMAC provider without a signal" + ); + } + + fn us_ca_geo() -> GeoInfo { + GeoInfo { + city: String::new(), + country: "US".to_owned(), + continent: String::new(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: Some("CA".to_owned()), + asn: None, + } + } + + #[test] + fn no_signal_uses_the_us_opt_out_baseline() { + // US/CA maps to the us-opt-out group, where every purpose is granted + // without a signal, so EC identity and bidstream EIDs are both permitted. + let geo = us_ca_geo(); + let state = assemble_permissions(&ConsentContext::default(), GeoStatus::Located(&geo)); + assert!( + state.is_set(Permission::StoreOnDevice) + && state.is_set(Permission::SelectPersonalisedAds), + "a US opt-out state should grant necessary.operations.storage and advertising_marketing.first_party.targeted" + ); + } + + #[test] + fn gpc_revokes_the_granted_baseline_in_a_us_opt_out_state() { + // A US-style opt-out drops a granted baseline with no jurisdiction match: + // the map granted these purposes, and GPC revokes them. + let consent = ConsentContext { + gpc: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); + assert!( + !state.is_set(Permission::StoreOnDevice) + && !state.is_set(Permission::SelectPersonalisedAds), + "GPC should revoke the granted necessary.operations.storage and advertising_marketing.first_party.targeted baseline" + ); + } + + // ------------------------------------------------------------------ + // Opt-out precedence pinning tests. These reinstate the behavior the + // consent module enforced before the permission model: an explicit + // opt-out signal suppresses storage and sharing even when a TCF record + // consents. The permission model must never let a CMP-written record + // override the visitor's own opt-out. + // ------------------------------------------------------------------ + + #[test] + fn gpc_suppresses_storage_even_with_a_consenting_tcf_record() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1, 4])), + gpc: true, + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); + assert!( + !state.is_set(Permission::StoreOnDevice) + && !state.is_set(Permission::SelectPersonalisedAds), + "GPC should suppress storage and sharing even when the TCF record consents" + ); + } + + #[test] + fn us_privacy_opt_out_suppresses_storage_even_with_a_consenting_tcf_record() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1, 4])), + us_privacy: Some(crate::consent::types::UsPrivacy { + version: 1, + notice_given: crate::consent::PrivacyFlag::Yes, + opt_out_sale: crate::consent::PrivacyFlag::Yes, + lspa_covered: crate::consent::PrivacyFlag::NotApplicable, + }), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); + assert!( + !state.is_set(Permission::StoreOnDevice) + && !state.is_set(Permission::SelectPersonalisedAds), + "a US Privacy opt-out should suppress storage and sharing even when the TCF record consents" + ); + } + + #[test] + fn gpp_sale_opt_out_suppresses_storage_even_with_a_consenting_tcf_record() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1, 4])), + gpp: Some(crate::consent::types::GppConsent { + version: 1, + section_ids: vec![7], + eu_tcf: None, + us_sale_opt_out: Some(true), + }), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); + assert!( + !state.is_set(Permission::StoreOnDevice) + && !state.is_set(Permission::SelectPersonalisedAds), + "a GPP sale opt-out should suppress storage and sharing even when the TCF record consents" + ); + } + + #[test] + fn gpc_suppresses_storage_even_when_us_privacy_reports_no_opt_out() { + let consent = ConsentContext { + gpc: true, + us_privacy: Some(crate::consent::types::UsPrivacy { + version: 1, + notice_given: crate::consent::PrivacyFlag::Yes, + opt_out_sale: crate::consent::PrivacyFlag::No, + lspa_covered: crate::consent::PrivacyFlag::NotApplicable, + }), + ..ConsentContext::default() + }; + let geo = us_ca_geo(); + let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); + assert!( + !state.is_set(Permission::StoreOnDevice), + "any one opt-out source should suppress, whatever the others say" + ); + } + + // ------------------------------------------------------------------ + // Withdrawal scoping: only a TCF storage refusal withdraws, and only + // where the baseline did not grant storage outright. Opt-outs suppress + // use but never destroy an already-issued identifier. + // ------------------------------------------------------------------ + + #[test] + fn tcf_storage_refusal_withdraws_under_a_requires_signal_baseline() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[4])), + ..ConsentContext::default() + }; + assert!( + ec_storage_withdrawn(&consent, Acquisition::RequiresSignal), + "refusing the signal storage depends on should withdraw" + ); + } + + #[test] + fn tcf_storage_refusal_does_not_withdraw_under_a_granted_baseline() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[4])), + ..ConsentContext::default() + }; + assert!( + !ec_storage_withdrawn(&consent, Acquisition::Granted), + "storage never depended on the record here, so refusal suppresses without destroying" + ); + } + + #[test] + fn tcf_storage_consent_is_not_a_withdrawal() { + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&[1])), + ..ConsentContext::default() + }; + assert!( + !ec_storage_withdrawn(&consent, Acquisition::RequiresSignal), + "a consenting record is not a withdrawal" + ); + } + + #[test] + fn gpc_alone_never_withdraws() { + let consent = ConsentContext { + gpc: true, + ..ConsentContext::default() + }; + assert!( + !ec_storage_withdrawn(&consent, Acquisition::Granted) + && !ec_storage_withdrawn(&consent, Acquisition::RequiresSignal), + "GPC suppresses use for the request but never destroys the identifier" + ); + } + + #[test] + fn us_style_opt_outs_never_withdraw() { + let consent = ConsentContext { + us_privacy: Some(crate::consent::types::UsPrivacy { + version: 1, + notice_given: crate::consent::PrivacyFlag::Yes, + opt_out_sale: crate::consent::PrivacyFlag::Yes, + lspa_covered: crate::consent::PrivacyFlag::NotApplicable, + }), + gpp: Some(crate::consent::types::GppConsent { + version: 1, + section_ids: vec![7], + eu_tcf: None, + us_sale_opt_out: Some(true), + }), + ..ConsentContext::default() + }; + assert!( + !ec_storage_withdrawn(&consent, Acquisition::RequiresSignal), + "sale opt-outs suppress use but never destroy the identifier" + ); + } + + #[test] + fn no_signal_is_not_a_withdrawal() { + assert!( + !ec_storage_withdrawn(&ConsentContext::default(), Acquisition::RequiresSignal), + "absence of a signal must never destroy an identifier" + ); + } + + #[test] + fn a_malformed_record_is_not_a_withdrawal() { + let consent = ConsentContext { + raw_tc_string: Some("not-a-tc-string".to_owned()), + ..ConsentContext::default() + }; + assert!( + !ec_storage_withdrawn(&consent, Acquisition::RequiresSignal), + "an unreadable record fails closed (suppression), not destructively" + ); + } + + // ------------------------------------------------------------------ + // Malformed-but-present records block baseline grants (fail closed) + // instead of degrading to the no-signal baseline. + // ------------------------------------------------------------------ #[test] - fn ec_consent_granted_allows_non_regulated_requests() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::NonRegulated, + fn a_malformed_tcf_record_blocks_baseline_grants() { + let consent = ConsentContext { + raw_tc_string: Some("not-a-tc-string".to_owned()), ..ConsentContext::default() }; + let geo = us_ca_geo(); + let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); + assert!( + !state.is_set(Permission::StoreOnDevice), + "an unreadable record should block the granted baseline, not vanish" + ); + } + #[test] + fn a_malformed_gpp_or_us_privacy_record_is_detected() { + let gpp = ConsentContext { + raw_gpp_string: Some("not-a-gpp-string".to_owned()), + ..ConsentContext::default() + }; + let usp = ConsentContext { + raw_us_privacy: Some("bogus".to_owned()), + ..ConsentContext::default() + }; assert!( - ec_consent_granted(&ctx), - "non-regulated requests should be allowed" + gpp.has_malformed_record() && usp.has_malformed_record(), + "each undecodable record form should be detected" ); } #[test] - fn ec_consent_granted_blocks_unknown_jurisdiction() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Unknown, + fn an_expired_tcf_record_is_not_treated_as_malformed() { + let consent = ConsentContext { + raw_tc_string: Some("CPc-old-string".to_owned()), + expired: true, ..ConsentContext::default() }; + let geo = us_ca_geo(); + let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); + assert!( + state.is_set(Permission::StoreOnDevice), + "expiry is its own explicit state, deliberately distinct from malformed" + ); + } + // ------------------------------------------------------------------ + // Geo status: a failed lookup resolves at the requires-signal floor and + // never consults the tree, while no location resolves at the policy's top + // node. + // ------------------------------------------------------------------ + + #[test] + fn a_failed_geo_lookup_resolves_to_the_requires_signal_floor() { + // The same request, located in a US opt-out state, grants storage + // without a signal. A failed lookup must not reach that rule, or any + // other, so nothing is set without a signal. + let geo = us_ca_geo(); + assert!( + assemble_permissions(&ConsentContext::default(), GeoStatus::Located(&geo)) + .is_set(Permission::StoreOnDevice), + "the located baseline must grant storage, or this test proves nothing" + ); + let state = assemble_permissions(&ConsentContext::default(), GeoStatus::Failed); + assert!( + !state.is_set(Permission::StoreOnDevice), + "a lookup failure must not fall back to any node of the policy tree" + ); + assert_eq!( + storage_acquisition(GeoStatus::Failed), + Acquisition::RequiresSignal, + "the storage baseline follows the same floor on failure" + ); + } + + #[test] + fn no_location_falls_back_to_the_policy_top_node() { + // The shipped policy's top node is the gdpr-eu group, which requires a + // signal for storage, so an unplaced visitor gets no identifier until + // one arrives. + let state = assemble_permissions(&ConsentContext::default(), GeoStatus::NoLocation); assert!( - !ec_consent_granted(&ctx), - "unknown jurisdiction should fail closed" + !state.is_set(Permission::StoreOnDevice), + "the top node requires a signal for storage" + ); + assert_eq!( + storage_acquisition(GeoStatus::NoLocation), + Acquisition::RequiresSignal, + "the storage baseline follows the top node on no location" + ); + } + + #[test] + fn no_location_takes_the_jurisdiction_from_the_policy_top_node() { + assert_eq!( + default_jurisdiction(GeoStatus::NoLocation), + Jurisdiction::Gdpr, + "no location should resolve the top node's declared jurisdiction" + ); + assert_eq!( + default_jurisdiction(GeoStatus::Failed), + Jurisdiction::Unknown, + "a failed lookup must not adopt the policy's declared jurisdiction" ); } #[test] - fn ec_consent_withdrawn_does_not_treat_unknown_jurisdiction_as_revocation() { - let ctx = ConsentContext { - jurisdiction: Jurisdiction::Unknown, + fn tcf_resolves_every_mapped_purpose_not_just_storage_and_ads() { + // A TCF record now grants or revokes every one of the eleven mapped + // purposes, not only Purpose 1 and Purpose 4. Consent to all purposes + // except Purpose 7 (measure ad performance), in a US opt-out state where + // the baseline granted them all, so a revoke is observable as a drop. + let consented: Vec = (1..=11).filter(|&p| p != 7).collect(); + let consent = ConsentContext { + tcf: Some(tcf_with_purposes(&consented)), ..ConsentContext::default() }; + let geo = us_ca_geo(); + let state = assemble_permissions(&consent, GeoStatus::Located(&geo)); + // Purpose 2 is now resolved (it was neutral before), so consent sets it. + assert!( + state.is_set(Permission::SelectBasicAds), + "Purpose 2 consent should set advertising_marketing.first_party.contextual" + ); + // Purpose 7 was refused, so the granted baseline is revoked. + assert!( + !state.is_set(Permission::MeasureAdPerformance), + "Purpose 7 refusal should revoke analytics.ad_reporting.measure_ad_performance" + ); + // The originally wired purposes still behave. assert!( - !ec_consent_withdrawn(&ctx), - "unknown jurisdiction should block creation without revoking existing EC" + state.is_set(Permission::StoreOnDevice) + && state.is_set(Permission::SelectPersonalisedAds), + "Purposes 1 and 4 remain resolved from the TCF record" ); } } diff --git a/crates/trusted-server-core/src/ec/cookies.rs b/crates/trusted-server-core/src/ec/cookies.rs index ac0e0c05b..ff050579e 100644 --- a/crates/trusted-server-core/src/ec/cookies.rs +++ b/crates/trusted-server-core/src/ec/cookies.rs @@ -13,75 +13,36 @@ //! endpoint (`/_ts/api/v1/identify`) exposes the EC ID in its response //! body for legitimate JS use cases. -use std::borrow::Cow; - use edgezero_core::body::Body as EdgeBody; use http::{HeaderValue, Response, header}; -use crate::constants::COOKIE_TS_EC; +use crate::constants::{COOKIE_TS_EC, COOKIE_TS_EC_RESOLVED}; use crate::settings::Settings; /// Maximum age for the EC cookie (1 year in seconds). const COOKIE_MAX_AGE: i32 = 365 * 24 * 60 * 60; +/// Maximum length in bytes of an Edge Cookie identifier. +/// +/// A global bound enforced wherever an identifier enters the system (creation, +/// cookie read-back, cookie write), so no provider can emit a value the cookie +/// layer, logs, or the KV key space cannot carry. +pub(crate) const MAX_EC_ID_LEN: usize = 256; + fn is_allowed_ec_id_char(c: char) -> bool { - c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') + c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '~') } -// Outbound allowlist for cookie sanitization: permits [a-zA-Z0-9._-] as a -// defense-in-depth backstop when setting the Set-Cookie header. This is -// intentionally broader than the inbound format validator -// (`generation::is_valid_ec_id`), which enforces the exact -// `<64-hex>.<6-alphanumeric>` structure and is used to reject untrusted -// request values before they enter the system. +// Identifier allowlist: [A-Za-z0-9._~-], the cookie-safe alphabet every +// Edge Cookie identifier must fit regardless of which provider created it. +// This is intentionally broader than the built-in format validator +// (`generation::is_valid_ec_id`), which enforces the HMAC provider's +// `<64-hex>.<6-alphanumeric>` structure, either bare or under the `hmac~` +// envelope; an opaque vendor identifier only has to fit the alphabet and the +// length bound. #[must_use] pub(crate) fn ec_id_has_only_allowed_chars(ec_id: &str) -> bool { - ec_id.chars().all(is_allowed_ec_id_char) -} - -fn sanitize_ec_id_for_cookie(ec_id: &str) -> Cow<'_, str> { - if ec_id_has_only_allowed_chars(ec_id) { - return Cow::Borrowed(ec_id); - } - - let safe_id = ec_id - .chars() - .filter(|c| is_allowed_ec_id_char(*c)) - .collect::(); - - log::warn!( - "Stripped disallowed characters from EC ID before setting cookie (len {} -> {}); \ - callers should reject invalid request IDs before cookie creation", - ec_id.len(), - safe_id.len(), - ); - - Cow::Owned(safe_id) -} - -/// Returns `true` if every byte in `value` is a valid RFC 6265 `cookie-octet`. -/// An empty string is always rejected. -/// -/// RFC 6265 restricts cookie values to printable US-ASCII excluding whitespace, -/// double-quote, comma, semicolon, and backslash. Rejecting these characters -/// prevents header-injection attacks where a crafted value could append -/// spurious cookie attributes (e.g. `evil; Domain=.attacker.com`). -/// -/// Non-ASCII characters (multi-byte UTF-8) are always rejected because their -/// byte values exceed `0x7E`. -#[must_use] -fn is_safe_cookie_value(value: &str) -> bool { - // RFC 6265 §4.1.1 cookie-octet: - // 0x21 — '!' - // 0x23–0x2B — '#' through '+' (excludes 0x22 DQUOTE) - // 0x2D–0x3A — '-' through ':' (excludes 0x2C comma) - // 0x3C–0x5B — '<' through '[' (excludes 0x3B semicolon) - // 0x5D–0x7E — ']' through '~' (excludes 0x5C backslash, 0x7F DEL) - // All control characters (0x00–0x20) and non-ASCII (0x80+) are also excluded. - !value.is_empty() - && value - .bytes() - .all(|b| matches!(b, 0x21 | 0x23..=0x2B | 0x2D..=0x3A | 0x3C..=0x5B | 0x5D..=0x7E)) + !ec_id.is_empty() && ec_id.len() <= MAX_EC_ID_LEN && ec_id.chars().all(is_allowed_ec_id_char) } /// Formats a `Set-Cookie` header value for the EC cookie. @@ -98,61 +59,95 @@ fn format_set_cookie(domain: &str, value: &str, max_age: i32) -> String { /// /// Per spec §5.2, the EC cookie domain is computed from /// `settings.publisher.domain` (not `cookie_domain`) to ensure the EC -/// cookie is always scoped to the publisher's apex domain. The EC ID is -/// sanitized through a narrow outbound allowlist as a defense-in-depth -/// backstop against header injection. +/// cookie is always scoped to the publisher's apex domain. Callers validate +/// the identifier with [`ec_id_has_only_allowed_chars`] before this point; +/// an identifier is rejected outright rather than rewritten, so the cookie +/// value and the identity-graph key can never silently diverge. #[must_use] pub(crate) fn create_ec_cookie(settings: &Settings, ec_id: &str) -> String { - let safe_id = sanitize_ec_id_for_cookie(ec_id); - format_set_cookie( &settings.publisher.ec_cookie_domain(), - safe_id.as_ref(), + ec_id, COOKIE_MAX_AGE, ) } /// Sets the EC ID cookie on the given response. /// -/// Validates `ec_id` against RFC 6265 `cookie-octet` rules before -/// interpolation. If the value contains unsafe characters (e.g. semicolons), -/// the cookie is not set and a warning is logged. This prevents an attacker -/// from injecting spurious cookie attributes via a controlled ID value. +/// Validates `ec_id` against the identifier alphabet and length bound before +/// interpolation. An identifier that fails validation is rejected and the +/// cookie is not set, with an error logged; the value is never rewritten, so +/// a provider identifier survives byte for byte or not at all. This also +/// prevents an attacker from injecting spurious cookie attributes via a +/// controlled ID value. /// /// `cookie_domain` comes from operator configuration and is considered trusted. -/// -/// # Panics (debug only) -/// -/// 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 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", - ec_id.len() + if !ec_id_has_only_allowed_chars(ec_id) { + log::error!( + "Rejecting EC ID for Set-Cookie: value of {} bytes is empty, over {} bytes, or \ + contains characters outside the identifier alphabet", + ec_id.len(), + MAX_EC_ID_LEN, ); return; } - debug_assert!( - super::generation::is_valid_ec_id(ec_id), - "EC ID must be validated before cookie creation: got '{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. + // Unreachable in practice: the identifier allowlist above gates + // the value, and format_set_cookie emits only controlled bytes. + // Logged for defense-in-depth symmetry with the rejection above. log::warn!("Skipping EC Set-Cookie: invalid header value: {e}"); } } } +/// Sets an Edge Cookie created by a client-cycle provider on the response. +/// +/// Unlike [`set_ec_cookie`], the value is treated as opaque: it need not match +/// the canonical HMAC id shape, because a client-cycle provider's identifier +/// (for example a signed envelope, or the client-random demo's value) is not in +/// that format. The value is still validated against the same identifier +/// alphabet and length bound as every other identifier +/// ([`ec_id_has_only_allowed_chars`]); a value outside the bounds is rejected +/// (no cookie set) and logged, never rewritten, which prevents header +/// injection. The same `Secure`, `HttpOnly`, `SameSite=Lax`, `Path=/`, and +/// `Domain` attributes as [`set_ec_cookie`] apply. +pub(crate) fn set_provider_ec_cookie( + settings: &Settings, + response: &mut Response, + value: &str, +) { + if !ec_id_has_only_allowed_chars(value) { + log::error!( + "Rejecting provider Edge Cookie value of {} bytes: empty, over {} bytes, or outside the identifier alphabet", + value.len(), + MAX_EC_ID_LEN, + ); + return; + } + + let cookie = format_set_cookie( + &settings.publisher.ec_cookie_domain(), + value, + COOKIE_MAX_AGE, + ); + match HeaderValue::from_str(&cookie) { + Ok(val) => { + response.headers_mut().append(header::SET_COOKIE, val); + } + Err(e) => { + // Unreachable in practice: the identifier allowlist gates the value + // and format_set_cookie emits only controlled bytes. + log::warn!("Skipping provider 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 @@ -172,11 +167,101 @@ pub fn expire_ec_cookie(settings: &Settings, response: &mut Response) log::warn!("Skipping EC cookie expiry Set-Cookie: invalid header value: {e}"); } } + // Expire the resolved marker together with the Edge Cookie, so a page + // whose visitor later re-establishes the permission can resolve again. + expire_ec_resolved_marker(settings, response); +} + +/// Expires the resolved marker on its own, leaving the Edge Cookie alone. +/// +/// The marker records only that some client-cycle provider resolved in the +/// past. It is not namespaced by the provider code the way the cookie value, +/// the identity-graph key and withdrawal are, so on a switch from one +/// client-cycle provider to another the marker outlives the identity it was +/// set for. The new provider's page script would then see the marker, skip the +/// resolve it should perform, and the visitor would sit with no identity +/// rather than a restarted one. Expiring the marker whenever the incoming +/// value is not one the selected provider owns restarts the cycle without +/// depending on any vendor page script comparing a marker value correctly. +pub fn expire_ec_resolved_marker(settings: &Settings, response: &mut Response) { + let marker = format!( + "{COOKIE_TS_EC_RESOLVED}=; Domain={}; Path=/; Secure; SameSite=Lax; Max-Age=0", + settings.publisher.ec_cookie_domain(), + ); + match HeaderValue::from_str(&marker) { + Ok(val) => { + response.headers_mut().append(header::SET_COOKIE, val); + } + Err(e) => { + log::warn!("Skipping resolved-marker expiry Set-Cookie: invalid header value: {e}"); + } + } +} + +/// Sets the `non-HttpOnly` resolved-marker cookie alongside a client-cycle Edge +/// Cookie. +/// +/// The Edge Cookie itself is `HttpOnly`, so the page script cannot see it and +/// would otherwise post to the resolve endpoint on every page view. The marker +/// carries no identity (its value is `1`); it only signals that a resolve +/// succeeded. It shares the Edge Cookie's `Domain`, `Path`, `Secure`, +/// `SameSite`, and lifetime, and is expired together with it by +/// [`expire_ec_cookie`]. +pub(crate) fn set_resolved_marker_cookie(settings: &Settings, response: &mut Response) { + let cookie = format!( + "{COOKIE_TS_EC_RESOLVED}=1; Domain={}; Path=/; Secure; SameSite=Lax; Max-Age={COOKIE_MAX_AGE}", + settings.publisher.ec_cookie_domain(), + ); + match HeaderValue::from_str(&cookie) { + Ok(val) => { + response.headers_mut().append(header::SET_COOKIE, val); + } + Err(e) => { + // Unreachable in practice: the value is a constant and the domain + // comes from operator-trusted configuration. + log::warn!("Skipping resolved-marker Set-Cookie: invalid header value: {e}"); + } + } } #[cfg(test)] mod tests { use super::*; + + #[test] + fn the_ec_cookie_lifetime_is_one_year() { + // The legacy bare-identifier reader's retirement condition (see + // `provider_owns_id`) is written in terms of this lifetime and the + // identity-graph `ENTRY_TTL`, which `kv::tests::constants_have_expected_values` + // pins to the same figure. Changing either moves the earliest safe + // retirement, so neither may drift unnoticed. + assert_eq!( + COOKIE_MAX_AGE, 31_536_000, + "the EC cookie should live one year" + ); + } + + #[test] + fn identifier_bounds_reject_oversize_and_accept_tilde() { + assert!( + ec_id_has_only_allowed_chars("a.~-_Z9"), + "the cookie-safe alphabet includes the tilde" + ); + assert!( + !ec_id_has_only_allowed_chars(""), + "an empty identifier is rejected" + ); + let oversize = "a".repeat(MAX_EC_ID_LEN + 1); + assert!( + !ec_id_has_only_allowed_chars(&oversize), + "an identifier over the length cap is rejected" + ); + let at_cap = "a".repeat(MAX_EC_ID_LEN); + assert!( + ec_id_has_only_allowed_chars(&at_cap), + "an identifier at the length cap is accepted" + ); + } use crate::test_support::tests::create_test_settings; use http::header; @@ -226,17 +311,21 @@ mod tests { } #[test] - fn create_ec_cookie_sanitizes_disallowed_chars_in_id() { + fn set_ec_cookie_rejects_disallowed_chars_outright() { + // Rejection, never rewriting: an identifier outside the alphabet must + // not produce a cookie at all, so the cookie value and the identity + // graph key can never silently diverge. let settings = create_test_settings(); - let result = create_ec_cookie(&settings, "evil;injected\r\nfoo=bar\0baz"); - let value = result - .strip_prefix(&format!("{COOKIE_TS_EC}=")) - .and_then(|s| s.split_once(';').map(|(v, _)| v)) - .expect("should have cookie value portion"); - - assert_eq!( - value, "evilinjectedfoobarbaz", - "should strip disallowed characters and preserve safe chars" + let mut response = Response::new(EdgeBody::empty()); + set_ec_cookie( + &settings, + &mut response, + "evil;injected +foo=bar", + ); + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "an identifier outside the alphabet should set no cookie" ); } @@ -289,47 +378,6 @@ mod tests { ); } - #[test] - fn is_safe_cookie_value_rejects_empty_string() { - assert!(!is_safe_cookie_value(""), "should reject empty string"); - } - - #[test] - fn is_safe_cookie_value_accepts_valid_ec_id_characters() { - assert!( - is_safe_cookie_value("abcdef0123456789.ABCDEFabcdef"), - "should accept hex digits, dots, and alphanumeric characters" - ); - } - - #[test] - fn is_safe_cookie_value_rejects_non_ascii() { - assert!( - !is_safe_cookie_value("val\u{fc}e"), - "should reject non-ASCII UTF-8 characters" - ); - } - - #[test] - fn is_safe_cookie_value_rejects_illegal_characters() { - assert!(!is_safe_cookie_value("val;ue"), "should reject semicolon"); - assert!(!is_safe_cookie_value("val,ue"), "should reject comma"); - assert!( - !is_safe_cookie_value("val\"ue"), - "should reject double-quote" - ); - assert!(!is_safe_cookie_value("val\\ue"), "should reject backslash"); - assert!(!is_safe_cookie_value("val ue"), "should reject space"); - assert!( - !is_safe_cookie_value("val\x00ue"), - "should reject null byte" - ); - assert!( - !is_safe_cookie_value("val\x7fue"), - "should reject DEL character" - ); - } - #[test] fn expire_ec_cookie_sets_max_age_zero() { let settings = create_test_settings(); @@ -377,4 +425,64 @@ mod tests { "expiry cookie should retain the same security attributes as the live cookie" ); } + + #[test] + fn set_provider_ec_cookie_sets_opaque_value_with_security_attributes() { + let settings = create_test_settings(); + let mut response = empty_response(); + // A client-cycle value that is not the canonical HMAC id shape. + set_provider_ec_cookie(&settings, &mut response, "8473625190"); + + let cookie_str = response + .headers() + .get(header::SET_COOKIE) + .expect("should set the EC cookie") + .to_str() + .expect("should be valid UTF-8"); + + assert_eq!( + cookie_str, + format!( + "{}=8473625190; Domain=.{}; Path=/; Secure; SameSite=Lax; Max-Age={}; HttpOnly", + COOKIE_TS_EC, settings.publisher.domain, COOKIE_MAX_AGE, + ), + "an opaque provider value should be set verbatim with the standard security attributes" + ); + } + + #[test] + fn set_provider_ec_cookie_preserves_base64url_value() { + let settings = create_test_settings(); + let mut response = empty_response(); + // A base64url value (RFC 4648 section 5, unpadded) fits the identifier + // alphabet exactly and must be preserved verbatim. Standard base64 + // (`+`, `/`, `=`) is outside the alphabet, so a provider that carries + // binary data re-encodes it as base64url before creating the cookie value. + let value = "abcDEF123-_x"; + set_provider_ec_cookie(&settings, &mut response, value); + + let cookie_str = response + .headers() + .get(header::SET_COOKIE) + .expect("should set the EC cookie") + .to_str() + .expect("should be valid UTF-8"); + + assert!( + cookie_str.starts_with(&format!("{COOKIE_TS_EC}={value};")), + "a base64url provider value should be preserved verbatim, got {cookie_str}" + ); + } + + #[test] + fn set_provider_ec_cookie_rejects_unsafe_value() { + let settings = create_test_settings(); + let mut response = empty_response(); + set_provider_ec_cookie(&settings, &mut response, "evil; Domain=.attacker.com"); + + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "an unsafe value must not set a cookie, preventing header injection" + ); + } } diff --git a/crates/trusted-server-core/src/ec/device.rs b/crates/trusted-server-core/src/ec/device.rs index fbefa9586..a0651c1fa 100644 --- a/crates/trusted-server-core/src/ec/device.rs +++ b/crates/trusted-server-core/src/ec/device.rs @@ -1,9 +1,11 @@ //! Device signal derivation for bot detection and browser classification. //! -//! All functions in this module are pure computations — no KV I/O or Fastly -//! SDK calls. The Fastly adapter extracts raw strings from the request -//! (`get_tls_ja4()`, `get_client_h2_fingerprint()`, UA header) and passes -//! them here for classification. +//! The [`DeviceSignals`] derivation here is pure computation, with no KV I/O or +//! Fastly SDK calls. A [`DeviceProvider`] is wired by dependency injection. It +//! reads the [`RequestInfo`] for the User-Agent from the borrowed argument +//! passed to `detect` at call time, and on a host that supplies them the +//! [`HostSignals`](crate::evidence::HostSignals) for the TLS and HTTP/2 signals +//! injected into its constructor, then classifies the request from both. //! //! # Signals //! @@ -18,6 +20,8 @@ use sha2::{Digest as _, Sha256}; use super::kv_types::KvDevice; +use crate::evidence::RequestInfo; +use crate::settings::Settings; /// Device signals derived from a single request. /// @@ -33,18 +37,48 @@ pub struct DeviceSignals { /// Coarse OS family: `"mac"`, `"windows"`, `"ios"`, `"android"`, /// `"linux"`. pub platform_class: Option, - /// SHA256 prefix (12 hex chars) of the raw H2 SETTINGS string. + /// SHA256 prefix (12 hex chars) of the raw H2 SETTINGS signal. pub h2_fp_hash: Option, /// `true` = known browser, `false` = known bot, `None` = unknown. pub known_browser: Option, + /// Whether the request looks like a real browser, used to gate Edge Cookie + /// writes. Computed by the producing provider: the built-in provider uses a + /// User-Agent-only heuristic, while the Fastly provider strengthens it with + /// the TLS and HTTP/2 signals. + pub looks_like_browser: bool, } impl DeviceSignals { - /// Derives all device signals from raw request data. + /// Derives device signals from the User-Agent alone, with no + /// host-specific TLS or HTTP/2 evidence. + /// + /// This is the default path. It touches no Fastly-specific API, so device + /// classification stays host-neutral by default. `ja4_class` and + /// `h2_fp_hash` are left absent, and the browser/bot decision uses a + /// User-Agent-only heuristic (`looks_like_browser_from_ua`). + #[must_use] + pub fn derive_ua_only(ua: &str) -> Self { + let platform_class = parse_platform_class(ua); + let looks_like_browser = looks_like_browser_from_ua(ua, platform_class.as_deref()); + + Self { + is_mobile: parse_is_mobile(ua), + ja4_class: None, + platform_class, + h2_fp_hash: None, + known_browser: None, + looks_like_browser, + } + } + + /// Derives device signals from the User-Agent strengthened with the + /// host's TLS and HTTP/2 signals. /// /// `ua` is the `User-Agent` header value. `ja4` is the full JA4 hash /// from `req.get_tls_ja4()`. `h2_fp` is the raw H2 SETTINGS string - /// from `req.get_client_h2_fingerprint()`. + /// from `req.get_client_h2_fingerprint()`. These signals are + /// host-specific (Fastly), so only the opt-in Fastly device provider + /// uses this path, and the browser/bot gate then requires a TLS signal. #[must_use] pub fn derive(ua: &str, ja4: Option<&str>, h2_fp: Option<&str>) -> Self { let is_mobile = parse_is_mobile(ua); @@ -52,6 +86,12 @@ impl DeviceSignals { let platform_class = parse_platform_class(ua); let h2_fp_hash = h2_fp.map(compute_h2_fp_hash); let known_browser = evaluate_known_browser(ja4_class.as_deref(), h2_fp_hash.as_deref()); + // The gate strengthened by host signals. A real browser produces a valid + // TLS signal and a recognizable UA platform. Raw HTTP clients + // (curl, Python requests, Go net/http, headless scrapers) lack one or + // both. This is intentionally aimed at filtering obvious missing-signal + // traffic, not at resisting deliberate JA4 + UA spoofing. + let looks_like_browser = ja4_class.is_some() && platform_class.is_some(); Self { is_mobile, @@ -59,32 +99,10 @@ impl DeviceSignals { platform_class, h2_fp_hash, known_browser, + looks_like_browser, } } - /// Returns `true` when the request looks like a real browser. - /// - /// Checks for the presence of recognizable signals rather than matching - /// against a hardcoded signal allowlist. Real browsers always - /// produce a valid TLS probabilistic identifier (`ja4_class`) and a recognizable UA - /// platform string (`platform_class`). Raw HTTP clients (curl, Python - /// requests, Go net/http, headless scrapers) typically lack one or both. - /// - /// # Threat model - /// - /// This heuristic is intentionally aimed at filtering obvious - /// missing-signal traffic, not at resisting deliberate spoofing. A bot - /// that forges plausible JA4 and UA inputs may still pass; deeper - /// consistency checks can be added later if product requirements demand - /// stronger spoof resistance. - /// - /// `known_browser` is still computed and stored on [`KvDevice`] for - /// analytics but does not gate identity operations. - #[must_use] - pub fn looks_like_browser(&self) -> bool { - self.ja4_class.is_some() && self.platform_class.is_some() - } - /// Converts these signals into a [`KvDevice`] for KV storage. #[must_use] pub fn to_kv_device(&self) -> KvDevice { @@ -98,6 +116,109 @@ impl DeviceSignals { } } +/// A strategy for classifying a request into [`DeviceSignals`]. +/// +/// Implementations are selected by configuration. The built-in +/// [`BuiltinDeviceProvider`] is the default; a deployment can switch to another +/// provider without changing call sites. +/// +/// These signals serve identity gating and bot detection, not bid enrichment. +/// [`DeviceSignals`] deliberately carries only the coarse browser and bot +/// classification the Edge Cookie gate needs, not a full device-detection +/// result such as make, model, OS version, or screen size. A richer device +/// model for the ad request is a separate concern. +/// Uses `#[async_trait(?Send)]` for the same reason as +/// [`PlatformHttpClient`](crate::platform::PlatformHttpClient): the trait +/// object stays `Send + Sync` so it can be shared and run multi-threaded, +/// while the future it returns is pinned to one thread because the host SDKs +/// produce `!Send` futures on wasm32. +#[async_trait::async_trait(?Send)] +pub trait DeviceProvider: Send + Sync { + /// Returns the stable identifier for this provider, used in configuration + /// and logs. + fn id(&self) -> &'static str; + + /// Classifies the request into [`DeviceSignals`], reading the request data + /// it needs from the [`RequestInfo`] passed borrowed at call time (plus any + /// host signals injected into its constructor). + /// + /// Device signals gate identity operations and must always yield a value, + /// so this is infallible: a provider that cannot determine a signal returns + /// the unknown variant rather than failing the request. + /// Asynchronous because a device provider may reach a backend, a + /// key-value store or a secret to classify a request, and a provider that + /// cannot make those calls cannot be written at all. The built-in + /// User-Agent provider does no I/O and returns immediately. + async fn detect( + &self, + request_info: &dyn RequestInfo, + services: &crate::platform::RuntimeServices, + ) -> DeviceSignals; + + /// The permissions this provider's data use requires. + /// + /// The default is empty, so the built-in User-Agent-only provider requires + /// no permission. + fn required_permissions(&self) -> crate::permissions::PermissionSet { + crate::permissions::PermissionSet::none() + } +} + +/// The built-in device provider, the default. +/// +/// Derives [`DeviceSignals`] from the User-Agent alone via +/// [`DeviceSignals::derive_ua_only`], touching no host-specific API. It reads +/// only [`RequestInfo::user_agent`] and never a host signal, so device +/// classification stays host-neutral by default. +#[derive(Debug, Default)] +pub struct BuiltinDeviceProvider; + +impl BuiltinDeviceProvider { + /// Creates the built-in provider. + #[must_use] + pub fn new() -> Self { + Self + } +} + +#[async_trait::async_trait(?Send)] +impl DeviceProvider for BuiltinDeviceProvider { + fn id(&self) -> &'static str { + "builtin" + } + + async fn detect( + &self, + request_info: &dyn RequestInfo, + _services: &crate::platform::RuntimeServices, + ) -> DeviceSignals { + DeviceSignals::derive_ua_only(request_info.user_agent()) + } +} + +/// Selects the device provider named by the `[device] provider` selector. +/// +/// Returns the built-in User-Agent-only provider unless the `fastly` selector is +/// set, in which case it builds the host-specific provider through the +/// `build_fastly` factory the adapter supplies. The factory runs only when that +/// provider is selected, so device classification itself reads no host signals +/// by default (see [`BuiltinDeviceProvider`] for the host-neutral default). The +/// Fastly entry point still reads the TLS and HTTP/2 signals on every request to +/// build the host-signal service and client info. A +/// selected-but-unknown provider is rejected at startup by +/// [`DeviceConfig::validate_provider_selection`](crate::settings::DeviceConfig::validate_provider_selection), +/// so this falls back to the built-in provider for that case. +#[must_use] +pub fn build_device_provider( + settings: &Settings, + build_fastly: impl FnOnce() -> Box, +) -> Box { + match settings.device.provider_key() { + "fastly" => build_fastly(), + _ => Box::new(BuiltinDeviceProvider::new()), + } +} + /// Device is a desktop (confirmed via UA platform token). const MOBILE_DESKTOP: u8 = 0; /// Device is a mobile (confirmed via UA mobile token). @@ -146,6 +267,53 @@ fn parse_platform_class(ua: &str) -> Option { None } +/// Decides whether a request looks like a real browser from the User-Agent +/// alone, with no TLS or HTTP/2 evidence. +/// +/// A real browser sends the `Mozilla/` token every major engine still emits and +/// a recognizable platform string (so `platform_class` is present), and is not +/// an obvious bot or command-line client. Raw HTTP clients (curl, Python +/// requests, Go net/http) carry no platform token, so they fail the +/// `platform_class` check; declared crawlers are caught by [`looks_like_bot_ua`]. +/// +/// # Threat model +/// +/// This is the default, host-neutral gate. It filters obvious non-browser +/// traffic but does not resist a bot that forges a complete browser +/// User-Agent. The opt-in Fastly device provider strengthens the gate with the +/// TLS and HTTP/2 signals for deployments that need it. +#[must_use] +fn looks_like_browser_from_ua(ua: &str, platform_class: Option<&str>) -> bool { + platform_class.is_some() && ua.contains("Mozilla/") && !looks_like_bot_ua(ua) +} + +/// Returns `true` when the User-Agent declares a known bot, crawler, or +/// non-browser HTTP client. +/// +/// Matches common self-identifying markers case-insensitively. The `bot` marker +/// covers `Googlebot`, `bingbot`, and similar; the library markers cover HTTP +/// clients that set a recognizable platform token. +#[must_use] +fn looks_like_bot_ua(ua: &str) -> bool { + const BOT_MARKERS: &[&str] = &[ + "bot", + "crawl", + "spider", + "slurp", + "curl", + "wget", + "python-requests", + "go-http-client", + "okhttp", + "java/", + "headlesschrome", + "phantomjs", + "scrapy", + ]; + let lower = ua.to_ascii_lowercase(); + BOT_MARKERS.iter().any(|marker| lower.contains(marker)) +} + /// Extracts Section 1 from a full JA4 string. /// /// JA4 format: `section1_section2_section3` separated by underscores. @@ -164,7 +332,7 @@ fn extract_ja4_section1(full_ja4: &str) -> Option { } /// Computes a 12-hex-char prefix of the SHA256 hash of the raw H2 -/// SETTINGS string. +/// SETTINGS signal string. /// /// The raw string looks like `"1:65536;2:0;4:6291456;6:262144"`. #[must_use] @@ -191,7 +359,7 @@ const KNOWN_BROWSERS: &[(&str, &str, bool)] = &[ ("t13d1717h2", "1:65536;2:0;4:131072;5:16384", true), ]; -/// Returns H2 SETTINGS hashes for the known browser allowlist. +/// Returns H2 signal hashes for the known browser allowlist. /// /// Computed once on first call and cached via `OnceLock`. fn known_browser_h2_hashes() -> &'static Vec<(&'static str, String, bool)> { @@ -230,6 +398,7 @@ fn evaluate_known_browser(ja4_class: Option<&str>, h2_fp_hash: Option<&str>) -> #[cfg(test)] mod tests { use super::*; + use crate::evidence::OwnedRequestInfo; // Chrome Mac UA const CHROME_MAC_UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \ @@ -366,7 +535,7 @@ mod tests { assert_eq!( evaluate_known_browser(Some(ja4), Some(&h2_hash)), Some(true), - "Chrome signals should be recognized" + "Chrome signal should be recognized" ); } @@ -377,7 +546,7 @@ mod tests { assert_eq!( evaluate_known_browser(Some(ja4), Some(&h2_hash)), Some(true), - "Safari signals should be recognized" + "Safari signal should be recognized" ); } @@ -388,7 +557,7 @@ mod tests { assert_eq!( evaluate_known_browser(Some(ja4), Some(&h2_hash)), Some(true), - "Firefox signals should be recognized" + "Firefox signal should be recognized" ); } @@ -523,7 +692,7 @@ mod tests { Some("1:65536;2:0;4:6291456;6:262144"), ); assert!( - signals.looks_like_browser(), + signals.looks_like_browser, "Chrome/Mac should look like a browser" ); } @@ -537,8 +706,8 @@ mod tests { Some("99:99;88:88"), ); assert!( - signals.looks_like_browser(), - "unknown signal combination with valid JA4 + platform should pass" + signals.looks_like_browser, + "unknown signal with valid JA4 + platform should pass" ); assert_eq!(signals.known_browser, None, "should not match allowlist"); } @@ -547,17 +716,17 @@ mod tests { fn looks_like_browser_rejects_bot() { let signals = DeviceSignals::derive(BOT_UA, None, None); assert!( - !signals.looks_like_browser(), + !signals.looks_like_browser, "bot with no JA4 and no platform should be rejected" ); } #[test] fn looks_like_browser_rejects_missing_ja4() { - // Real UA but no JA4 value (e.g. HTTP/1.1 or missing SDK support) + // Real UA but no TLS signal (e.g. HTTP/1.1 or missing SDK support) let signals = DeviceSignals::derive(CHROME_MAC_UA, None, Some("1:65536")); assert!( - !signals.looks_like_browser(), + !signals.looks_like_browser, "missing JA4 should be rejected even with valid UA" ); } @@ -567,8 +736,148 @@ mod tests { // Has JA4 but unrecognizable UA let signals = DeviceSignals::derive(BOT_UA, Some("t13d1516h2_abc_def"), None); assert!( - !signals.looks_like_browser(), + !signals.looks_like_browser, "unrecognizable UA should be rejected even with JA4" ); } + + #[test] + fn derive_ua_only_accepts_real_browsers_without_fingerprints() { + for ua in [ + CHROME_MAC_UA, + SAFARI_IOS_UA, + FIREFOX_MAC_UA, + CHROME_ANDROID_UA, + CHROME_WINDOWS_UA, + ] { + let signals = DeviceSignals::derive_ua_only(ua); + assert!( + signals.looks_like_browser, + "a real browser UA should pass the UA-only gate: {ua}" + ); + assert!( + signals.ja4_class.is_none() && signals.h2_fp_hash.is_none(), + "the UA-only path must not record any TLS/H2 evidence" + ); + } + } + + #[test] + fn derive_ua_only_rejects_bots_and_http_clients() { + // Declared crawlers and CLI/library clients must not pass the gate. + for ua in [ + BOT_UA, + "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)", + "curl/8.4.0", + "python-requests/2.31.0", + "Go-http-client/2.0", + "", + ] { + assert!( + !DeviceSignals::derive_ua_only(ua).looks_like_browser, + "a non-browser client should fail the UA-only gate: {ua:?}" + ); + } + } + + #[test] + fn derive_ua_only_rejects_a_browser_ua_that_declares_a_bot() { + // Newer crawlers send a full browser UA with a platform token; the bot + // marker must still reject them. + let googlebot_mobile = "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) \ + AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36 \ + (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"; + assert!( + !DeviceSignals::derive_ua_only(googlebot_mobile).looks_like_browser, + "a browser-shaped UA declaring Googlebot should be rejected" + ); + } + + #[tokio::test] + async fn builtin_device_provider_is_ua_only() { + let provider = BuiltinDeviceProvider::new(); + assert_eq!(provider.id(), "builtin"); + + // The built-in provider classifies from the User-Agent in the request + // info passed to `detect` alone, recording no host signal. + let request_info = request_info_with_ua(CHROME_MAC_UA); + let signals = provider + .detect( + &request_info, + &crate::platform::test_support::noop_services(), + ) + .await; + assert_eq!( + signals, + DeviceSignals::derive_ua_only(CHROME_MAC_UA), + "the built-in provider should classify from the User-Agent only" + ); + assert!( + signals.ja4_class.is_none(), + "the built-in provider must not record a JA4 class" + ); + } + + /// Builds request info carrying the given User-Agent, for provider tests. + fn request_info_with_ua(user_agent: &str) -> OwnedRequestInfo { + let mut headers = http::HeaderMap::new(); + headers.insert( + http::header::USER_AGENT, + http::HeaderValue::from_str(user_agent) + .expect("should build a valid User-Agent header"), + ); + OwnedRequestInfo::new(String::new(), headers) + } + + /// A stand-in for the host-specific provider the adapter injects, so the + /// selection logic can be tested in core without the Fastly provider crate. + struct StubFastlyProvider; + + #[async_trait::async_trait(?Send)] + impl DeviceProvider for StubFastlyProvider { + fn id(&self) -> &'static str { + "fastly" + } + + async fn detect( + &self, + _request_info: &dyn RequestInfo, + _services: &crate::platform::RuntimeServices, + ) -> DeviceSignals { + DeviceSignals::derive_ua_only("") + } + } + + #[test] + fn builtin_device_provider_requires_no_permissions() { + assert!( + BuiltinDeviceProvider::new() + .required_permissions() + .is_empty(), + "the built-in User-Agent-only device provider requires no permissions" + ); + } + + #[test] + fn build_device_provider_defaults_to_builtin_and_selects_injected() { + // The default selector returns the built-in provider, ignoring the + // injected candidate. + let settings = crate::settings::Settings::default(); + let default = build_device_provider(&settings, || { + Box::new(StubFastlyProvider) as Box + }); + assert_eq!(default.id(), "builtin", "no selector should be UA-only"); + + // The `fastly` selector returns the provider the adapter's factory builds. + let mut fastly = crate::settings::Settings::default(); + fastly.device.provider = Some("fastly".to_owned()); + let selected = build_device_provider(&fastly, || { + Box::new(StubFastlyProvider) as Box + }); + assert_eq!( + selected.id(), + "fastly", + "the fastly selector should use the injected provider" + ); + } } diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index a553bb7a7..16f7e55fa 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -8,33 +8,29 @@ use std::collections::HashSet; use edgezero_core::body::Body as EdgeBody; use http::Response; -use super::consent::{ec_consent_granted, ec_consent_withdrawn}; +use crate::constants::EC_RESPONSE_HEADERS; use crate::settings::Settings; use super::EcContext; -use super::cookies::{expire_ec_cookie, set_ec_cookie}; -use super::generation::is_valid_ec_id; +use super::cookies::{expire_ec_cookie, expire_ec_resolved_marker, set_ec_cookie}; use super::kv::KvIdentityGraph; use super::log_id; use super::prebid_eids::ingest_eid_cookies; +use super::provider::apply_provider_response_headers; use super::registry::PartnerRegistry; -/// TS-managed response headers tied to EC identity output. -const EC_RESPONSE_HEADERS: &[&str] = &[ - "x-ts-ec", - "x-ts-eids", - "x-ts-ec-consent", - "x-ts-eids-truncated", -]; - /// Finalizes EC response behavior for all routes. /// -/// Applies withdrawal handling, last-seen updates, cookie reconciliation, -/// Prebid EID ingestion, and cookie writes for new EC generation. +/// Applies the resolved permission state, cookie reconciliation, Prebid EID +/// ingestion, and cookie writes for new EC generation. /// -/// On consent withdrawal, the browser response clears the EC cookie -/// immediately and the EC identity-graph KV tombstone is the authoritative -/// revocation marker. There is no separate consent KV store to clean up. +/// When the request carries an explicit withdrawal signal (a storage opt-out or +/// a TCF record refusing storage) and the client presented a cookie, the browser +/// response clears the EC cookie immediately and the EC identity-graph KV +/// tombstone is the authoritative revocation marker. A request that is merely +/// not permitted (pre-consent or fail-closed) strips EC response headers but +/// leaves an already-issued cookie intact. There is no separate consent KV +/// store to clean up. /// /// `eids_cookie` should be the raw value of the `ts-eids` cookie extracted /// from the request *before* routing consumes it. @@ -47,32 +43,47 @@ pub fn ec_finalize_response( sharedid_cookie: Option<&str>, response: &mut Response, ) { - let consent_allows_ec = ec_consent_granted(ec_context.consent()); - let consent_withdrawn = ec_consent_withdrawn(ec_context.consent()); - - if !consent_allows_ec { - // Always strip EC-specific response headers when consent is not - // currently usable for this request. This covers both explicit - // revocation and fail-closed cases such as missing geo or undecodable - // consent input. + // Apply any response headers the active provider asked for during + // generation (for example to request more client evidence). This is empty + // unless a provider produced headers, so it is safe on every path. Each + // one was checked against core's reserved response surface at capture + // time in `EcContext::generate_with_provider`, so nothing here can set a + // managed `ts-` cookie, an `x-ts-` header, or a framing or hop-by-hop + // header. They accumulate with whatever the origin returned rather than + // replacing it, for the reasons on + // `provider::apply_provider_response_headers`. + apply_provider_response_headers( + response.headers_mut(), + ec_context.response_headers().iter().cloned(), + ); + + let ec_permitted = ec_context.ec_allowed(); + + if !ec_permitted { + // Always strip EC-specific response headers when EC is not permitted for + // this request, covering both an explicit withdrawal and fail-closed + // cases such as missing geo or undecodable consent input. clear_ec_headers_on_response(response, Some(registry)); // Only expire the browser cookie and tombstone the identity-graph row - // when the request carries an explicit withdrawal signal. - if consent_withdrawn && ec_context.cookie_was_present() { + // when the request carries an explicit withdrawal signal. A pre-consent + // or fail-closed state (the permission is simply not set) strips headers + // but must not destroy an already-issued identifier, or a returning user + // would be permanently withdrawn before they ever get to consent. + if ec_context.storage_withdrawn() && ec_context.cookie_was_present() { expire_ec_cookie(settings, response); // Compute once for the authoritative identity-graph tombstones. - let ids_to_withdraw = withdrawal_ec_ids(ec_context); + let keys_to_withdraw = withdrawal_kv_keys(ec_context); // The identity-graph tombstone is the authoritative withdrawal marker // for subsequent EC behavior. if let Some(graph) = kv { - apply_withdrawal_tombstones(&ids_to_withdraw, |ec_id| { - if let Err(err) = graph.write_withdrawal_tombstone(ec_id) { + apply_withdrawal_tombstones(&keys_to_withdraw, |kv_key| { + if let Err(err) = graph.write_withdrawal_tombstone(kv_key) { log::error!( "Failed to write withdrawal tombstone for EC ID '{}': {err:?}", - log_id(ec_id), + log_id(kv_key), ); } }); @@ -82,10 +93,27 @@ pub fn ec_finalize_response( return; } - // Returning user: consent is granted and EC came from request. - if ec_context.ec_was_present() && !ec_context.ec_generated() && consent_allows_ec { - if let (Some(graph), Some(ec_id)) = (kv, ec_context.ec_value()) { - ingest_eid_cookies(eids_cookie, sharedid_cookie, ec_id, graph, registry); + // The request carried a `ts-ec` the selected provider does not own, which + // is what a switch between client-cycle providers looks like on the first + // request after the switch. The resolved marker is not namespaced by the + // provider code, so it would otherwise survive and tell the new provider's + // page script that a resolve had already happened, leaving the visitor with + // no identity instead of a restarted one. Expire the marker so the cycle + // starts again. The cookie itself is left alone, because the value is + // already ignored for read-back and a returning visitor may still be + // carrying an identifier another selected provider would own. + if ec_context.cookie_was_present() && !ec_context.ec_was_present() { + expire_ec_resolved_marker(settings, response); + } + + // Returning user: EC is permitted and came from the request. + if ec_context.ec_was_present() && !ec_context.ec_generated() && ec_permitted { + // Key EID ingestion by the provider's canonical form of the identifier, + // the key the identity-graph row is stored under, so an ingested EID + // lands on the live row rather than creating a second one keyed by the + // value the browser carries. + if let (Some(graph), Some(kv_key)) = (kv, ec_context.ec_kv_key()) { + ingest_eid_cookies(eids_cookie, sharedid_cookie, &kv_key, graph, registry); } // Ordinary returning-user page views no longer refresh the browser @@ -97,12 +125,15 @@ pub fn ec_finalize_response( // there is no KV graph: that would mint a browser cookie with no backing // identity-graph row, producing a phantom ID on later requests. if ec_context.ec_generated() { - let (Some(graph), Some(ec_id)) = (kv, ec_context.ec_value()) else { - log::info!("Skipping generated EC response write because KV graph is unavailable"); + let (Some(graph), Some(kv_key)) = (kv, ec_context.ec_kv_key()) else { + log::info!( + "Skipping generated EC response write because the KV graph or the \ + identity-graph key is unavailable" + ); return; }; - ingest_eid_cookies(eids_cookie, sharedid_cookie, ec_id, graph, registry); + ingest_eid_cookies(eids_cookie, sharedid_cookie, &kv_key, graph, registry); set_ec_cookie_on_response(settings, ec_context, response); } } @@ -152,30 +183,35 @@ pub fn clear_ec_on_response(settings: &Settings, response: &mut Response HashSet { - let mut hashes = HashSet::new(); - - if let Some(cookie_ec_id) = ec_context.existing_cookie_ec_id() - && is_valid_ec_id(cookie_ec_id) - { - hashes.insert(cookie_ec_id.to_owned()); +/// The identity-graph keys a withdrawal must tombstone. +/// +/// Both the `ts-ec` cookie the request carried and the active identifier are +/// turned into keys by the provider that owns them, so the tombstone lands on +/// the row the live identifier is stored under rather than on the raw cookie +/// value. An identifier no provider this deployment reads owns produces no key +/// and is dropped, which is the same filtering the previous shape check did. +/// The two collapse to one key when they are the same identity written two +/// ways. +fn withdrawal_kv_keys(ec_context: &EcContext) -> HashSet { + let mut keys = HashSet::new(); + + if let Some(cookie_kv_key) = ec_context.cookie_ec_kv_key() { + keys.insert(cookie_kv_key); } - if let Some(active_ec_id) = ec_context.ec_value() - && is_valid_ec_id(active_ec_id) - { - hashes.insert(active_ec_id.to_owned()); + if let Some(active_kv_key) = ec_context.ec_kv_key() { + keys.insert(active_kv_key); } - hashes + keys } -fn apply_withdrawal_tombstones(ec_ids: &HashSet, mut write_tombstone: F) +fn apply_withdrawal_tombstones(kv_keys: &HashSet, mut write_tombstone: F) where F: FnMut(&str), { - for ec_id in ec_ids { - write_tombstone(ec_id); + for kv_key in kv_keys { + write_tombstone(kv_key); } } @@ -219,6 +255,7 @@ mod tests { ec_was_present: bool, ec_generated: bool, jurisdiction: Jurisdiction, + ec_allowed: bool, ) -> EcContext { let consent = ConsentContext { jurisdiction, @@ -232,6 +269,7 @@ mod tests { ec_was_present, ec_generated, consent, + ec_allowed, ) } @@ -241,6 +279,7 @@ mod tests { ec_was_present: bool, ec_generated: bool, consent: ConsentContext, + ec_allowed: bool, ) -> EcContext { EcContext::new_for_test_with_cookie( ec_value.map(str::to_owned), @@ -248,9 +287,54 @@ mod tests { ec_was_present, ec_generated, consent, + ec_allowed, ) } + /// The identifier [`CanonicalizingProvider`] creates, as the browser carries + /// it in the `ts-ec` cookie. + const CANONICAL_COOKIE_VALUE: &str = "t0ca~MiXeD.CaseId"; + + /// The identity-graph key generation writes that identifier's row under. + /// Pinned to the creation path by + /// `generate_keys_the_identity_graph_by_the_normalized_identifier` in the + /// `ec` module tests. + const CANONICAL_KV_KEY: &str = "t0ca~mixed.caseid"; + + fn canonicalizing_context( + ec_was_present: bool, + ec_generated: bool, + consent: ConsentContext, + ec_allowed: bool, + ) -> EcContext { + make_context_with_consent( + Some(CANONICAL_COOKIE_VALUE), + Some(CANONICAL_COOKIE_VALUE), + ec_was_present, + ec_generated, + consent, + ec_allowed, + ) + .with_provider_for_test(std::sync::Arc::new( + crate::ec::tests::CanonicalizingProvider, + )) + } + + fn graph_with_live_canonical_row() -> KvIdentityGraph { + let graph = KvIdentityGraph::in_memory("finalize-canonical-store"); + graph + .create( + CANONICAL_KV_KEY, + &crate::ec::kv_types::KvEntry::minimal( + "ssp.example.com", + "partner-uid-123", + 1_741_824_000, + ), + ) + .expect("should write the row generation keys by the canonical form"); + graph + } + fn sample_ec_id(suffix: &str) -> String { format!("{}.{suffix}", "a".repeat(64)) } @@ -273,11 +357,18 @@ mod tests { } #[test] - fn withdrawal_ec_ids_returns_cookie_ec_only_when_active_missing() { + fn withdrawal_kv_keys_returns_cookie_ec_only_when_active_missing() { let cookie_ec = sample_ec_id("cook1e"); - let ec_context = make_context(None, Some(&cookie_ec), true, false, Jurisdiction::Unknown); + let ec_context = make_context( + None, + Some(&cookie_ec), + true, + false, + Jurisdiction::Unknown, + false, + ); - let ids = withdrawal_ec_ids(&ec_context); + let ids = withdrawal_kv_keys(&ec_context); assert_eq!(ids.len(), 1, "should include exactly one EC ID"); assert!( @@ -287,7 +378,7 @@ mod tests { } #[test] - fn withdrawal_ec_ids_deduplicates_matching_cookie_and_active_ec() { + fn withdrawal_kv_keys_deduplicates_matching_cookie_and_active_ec() { let ec_id = sample_ec_id("same01"); let ec_context = make_context( Some(&ec_id), @@ -295,16 +386,17 @@ mod tests { true, false, Jurisdiction::Unknown, + false, ); - let ids = withdrawal_ec_ids(&ec_context); + let ids = withdrawal_kv_keys(&ec_context); assert_eq!(ids.len(), 1, "should deduplicate identical EC IDs"); assert!(ids.contains(&ec_id), "should retain the shared EC ID"); } #[test] - fn withdrawal_ec_ids_includes_both_cookie_and_active_when_different() { + fn withdrawal_kv_keys_includes_both_cookie_and_active_when_different() { let active_ec = sample_ec_id("activ1"); let cookie_ec = sample_ec_id("cook1e"); let ec_context = make_context( @@ -313,9 +405,10 @@ mod tests { true, false, Jurisdiction::Unknown, + false, ); - let ids = withdrawal_ec_ids(&ec_context); + let ids = withdrawal_kv_keys(&ec_context); assert_eq!(ids.len(), 2, "should include both distinct EC IDs"); assert!(ids.contains(&active_ec), "should include active EC ID"); @@ -323,7 +416,7 @@ mod tests { } #[test] - fn withdrawal_ec_ids_filters_invalid_values() { + fn withdrawal_kv_keys_filters_invalid_values() { let valid_ec = sample_ec_id("valid1"); let ec_context = make_context( Some(&valid_ec), @@ -331,9 +424,10 @@ mod tests { true, false, Jurisdiction::Unknown, + false, ); - let ids = withdrawal_ec_ids(&ec_context); + let ids = withdrawal_kv_keys(&ec_context); assert_eq!(ids.len(), 1, "should ignore malformed EC values"); assert!(ids.contains(&valid_ec), "should keep the valid EC ID"); @@ -395,14 +489,17 @@ mod tests { fn finalize_withdrawal_clears_cookie_and_headers() { let settings = create_test_settings(); let ec_id = sample_ec_id("aBc123"); + // A TCF record refusing storage is the withdrawal trigger. The test + // context resolves the storage baseline at the requires-signal floor, + // where refusing the signal storage depends on is destructive. let consent = ConsentContext { - jurisdiction: Jurisdiction::UsState("CA".to_owned()), - gpc: true, + jurisdiction: Jurisdiction::Gdpr, + tcf: Some(refusing_tcf()), source: ConsentSource::Cookie, ..Default::default() }; let ec_context = - make_context_with_consent(Some(&ec_id), Some(&ec_id), true, false, consent); + make_context_with_consent(Some(&ec_id), Some(&ec_id), true, false, consent, false); let mut response = empty_response(); set_header(&mut response, "x-ts-ec", "stale"); set_header(&mut response, "x-ts-eids", "[]"); @@ -448,6 +545,65 @@ mod tests { ); } + /// A decoded TCF record refusing every purpose, storage included. + fn refusing_tcf() -> crate::consent::TcfConsent { + crate::consent::TcfConsent { + version: 2, + cmp_id: 0, + cmp_version: 0, + consent_screen: 0, + consent_language: "EN".to_owned(), + vendor_list_version: 0, + tcf_policy_version: 2, + created_ds: 0, + last_updated_ds: 0, + purpose_consents: vec![false; 24], + purpose_legitimate_interests: vec![false; 24], + vendor_consents: Vec::new(), + vendor_legitimate_interests: Vec::new(), + special_feature_opt_ins: vec![false; 12], + } + } + + #[test] + fn finalize_gpc_suppresses_headers_but_keeps_the_cookie() { + // A US-style opt-out suppresses use (headers cleared, nothing egressed) + // but is never destructive: the browser cookie is not expired, so a + // visitor who later withdraws the opt-out keeps their identity. + let settings = create_test_settings(); + let ec_id = sample_ec_id("aBc123"); + let consent = ConsentContext { + jurisdiction: Jurisdiction::UsState("CA".to_owned()), + gpc: true, + source: ConsentSource::Cookie, + ..Default::default() + }; + let ec_context = + make_context_with_consent(Some(&ec_id), Some(&ec_id), true, false, consent, false); + let mut response = empty_response(); + set_header(&mut response, "x-ts-ec", "stale"); + + let test_registry = PartnerRegistry::empty(); + ec_finalize_response( + &settings, + &ec_context, + None, + &test_registry, + None, + None, + &mut response, + ); + + assert!( + get_header(&response, "x-ts-ec").is_none(), + "the opt-out should clear the EC header" + ); + assert!( + get_header(&response, "set-cookie").is_none(), + "the opt-out should not expire the browser cookie" + ); + } + #[test] fn finalize_returning_user_with_cookie_mismatch_sets_no_header_or_cookie() { let settings = create_test_settings(); @@ -459,6 +615,7 @@ mod tests { true, false, Jurisdiction::NonRegulated, + true, ); let mut response = empty_response(); @@ -493,6 +650,7 @@ mod tests { true, false, Jurisdiction::NonRegulated, + true, ); let mut response = empty_response(); @@ -527,6 +685,7 @@ mod tests { false, true, Jurisdiction::NonRegulated, + true, ); let mut response = empty_response(); @@ -554,7 +713,7 @@ mod tests { #[test] fn finalize_denied_without_cookie_is_noop() { let settings = create_test_settings(); - let ec_context = make_context(None, None, false, false, Jurisdiction::Unknown); + let ec_context = make_context(None, None, false, false, Jurisdiction::Unknown, false); let mut response = empty_response(); let test_registry = PartnerRegistry::empty(); @@ -579,7 +738,12 @@ mod tests { } #[test] - fn finalize_unknown_jurisdiction_strips_headers_without_expiring_cookie() { + fn finalize_not_permitted_without_withdrawal_keeps_cookie() { + // When EC is not permitted (here a fail-closed unknown jurisdiction with + // no geo) but the request carries no explicit withdrawal signal, the + // response strips EC headers yet must leave an already-issued cookie + // intact. A pre-consent or transient fail-closed request must not + // permanently withdraw a returning user before they get to consent. let settings = create_test_settings(); let ec_id = sample_ec_id("unk001"); let ec_context = make_context( @@ -588,6 +752,7 @@ mod tests { true, false, Jurisdiction::Unknown, + false, ); let mut response = empty_response(); set_header(&mut response, "x-ts-ec", &ec_id); @@ -606,15 +771,502 @@ mod tests { assert!( get_header(&response, "x-ts-ec").is_none(), - "should strip EC header when consent cannot be verified" + "should strip EC header when EC is not permitted" ); assert!( get_header(&response, "x-ts-eids").is_none(), - "should strip EID header when consent cannot be verified" + "should strip EID header when EC is not permitted" + ); + assert!( + get_header(&response, "set-cookie").is_none(), + "a not-permitted request without a withdrawal signal should keep the cookie" ); + } + + #[test] + fn set_ec_cookie_on_response_writes_the_ts_ec_cookie() { + // The positive case: when an EC value is present, the finalize path + // writes the ts-ec cookie to the browser, carrying the EC id. + let settings = create_test_settings(); + let ec_id = sample_ec_id("setck1"); + let ec_context = make_context( + Some(&ec_id), + None, + false, + true, + Jurisdiction::NonRegulated, + true, + ); + let mut response = empty_response(); + + set_ec_cookie_on_response(&settings, &ec_context, &mut response); + + let set_cookie = + get_header_str(&response, "set-cookie").expect("an EC value should write a Set-Cookie"); + assert!( + set_cookie.contains("ts-ec=") && set_cookie.contains(&ec_id), + "should write the ts-ec cookie carrying the EC id, got: {set_cookie}" + ); + } + + #[test] + fn closed_permission_gate_writes_no_ec_cookie() { + // The gate: with the permission gate closed (ec_allowed = false), no + // ts-ec cookie is written, even when an EC value and a generated flag are + // present. The permission model is what suppresses the cookie. + let settings = create_test_settings(); + let ec_id = sample_ec_id("gated1"); + let ec_context = make_context( + Some(&ec_id), + None, + false, + true, + Jurisdiction::NonRegulated, + false, + ); + let mut response = empty_response(); + + // Pass a KV graph so the missing-graph guard cannot be the reason the + // cookie is suppressed; the closed gate must be doing the work. + let kv = KvIdentityGraph::failing("test_store"); + let test_registry = PartnerRegistry::empty(); + ec_finalize_response( + &settings, + &ec_context, + Some(&kv), + &test_registry, + None, + None, + &mut response, + ); + assert!( get_header(&response, "set-cookie").is_none(), - "should not expire the cookie without an explicit withdrawal signal" + "a closed permission gate must not write a ts-ec cookie" + ); + } + + #[test] + fn withdrawal_tombstones_the_canonical_row_not_the_cookie_value() { + // The tombstone is the authoritative revocation marker, so it has to + // land on the key the live row uses. Written under the raw cookie + // value it creates a second row nothing reads, and the revocation + // never takes effect for a provider whose canonical form differs. + // + // Destructive withdrawal is narrow, so the trigger here is a TCF record + // refusing storage, the same one + // `finalize_withdrawal_clears_cookie_and_headers` uses. An opt-out such + // as GPC suppresses use without destroying an issued identifier, so it + // would write no tombstone for this test to place. + let settings = create_test_settings(); + let graph = graph_with_live_canonical_row(); + let consent = ConsentContext { + jurisdiction: Jurisdiction::Gdpr, + tcf: Some(refusing_tcf()), + source: ConsentSource::Cookie, + ..Default::default() + }; + let ec_context = canonicalizing_context(true, false, consent, false); + let mut response = empty_response(); + + ec_finalize_response( + &settings, + &ec_context, + Some(&graph), + &PartnerRegistry::empty(), + None, + None, + &mut response, + ); + + let (live_row, _) = graph + .get(CANONICAL_KV_KEY) + .expect("should read the canonical row") + .expect("the canonical row should still exist"); + assert!( + !live_row.consent.ok, + "withdrawal should tombstone the row the live identifier is keyed by" + ); + assert!( + graph + .get(CANONICAL_COOKIE_VALUE) + .expect("should read the graph") + .is_none(), + "withdrawal should not write a tombstone under the raw cookie value" + ); + } + + #[test] + fn eid_ingestion_keys_by_the_providers_canonical_form() { + // An ingested EID must join the row the identifier already has. Keyed + // by the raw cookie value the upsert finds no row and the partner ID + // is dropped. + let settings = create_test_settings(); + let graph = graph_with_live_canonical_row(); + let partners = vec![make_partner("sharedid.org")]; + let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); + let consent = ConsentContext { + jurisdiction: Jurisdiction::NonRegulated, + source: ConsentSource::Cookie, + ..Default::default() + }; + let ec_context = canonicalizing_context(true, false, consent, true); + let mut response = empty_response(); + + ec_finalize_response( + &settings, + &ec_context, + Some(&graph), + ®istry, + None, + Some("shared-cookie-id"), + &mut response, + ); + + let (row, _) = graph + .get(CANONICAL_KV_KEY) + .expect("should read the canonical row") + .expect("the canonical row should still exist"); + assert_eq!( + row.ids.get("sharedid.org").map(|id| id.uid.as_str()), + Some("shared-cookie-id"), + "the ingested EID should land on the row keyed by the canonical form" + ); + assert!( + graph + .get(CANONICAL_COOKIE_VALUE) + .expect("should read the graph") + .is_none(), + "EID ingestion should not create a row under the raw cookie value" + ); + } + + /// A provider that sets one cookie of its own and one `Vary` entry, the + /// two response effects a provider realistically asks for, so a test can + /// watch both land on a response the origin already wrote headers to. + #[derive(Debug)] + struct EvidenceHeaderProvider; + + #[async_trait::async_trait(?Send)] + impl crate::ec::provider::EdgeCookieProvider for EvidenceHeaderProvider { + fn id(&self) -> &'static str { + "evidence-header" + } + + fn code(&self) -> crate::ec::provider::ProviderCode { + crate::provider_code!("t0eh") + } + + async fn generate( + &self, + _request_info: &dyn crate::evidence::RequestInfo, + _input: &crate::ec::provider::IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, + ) -> Result< + crate::ec::provider::GeneratedEdgeCookie, + error_stack::Report, + > { + Ok(crate::ec::provider::GeneratedEdgeCookie { + id: Some("evidence-id".to_owned()), + response_headers: vec![ + ( + http::header::SET_COOKIE, + HeaderValue::from_static("vendor-ev=abc; Path=/"), + ), + (http::header::VARY, HeaderValue::from_static("sec-ch-ua")), + ], + }) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + value.to_owned() + } + } + + #[tokio::test] + async fn provider_response_headers_reach_the_response_without_dropping_the_origins() { + // The response finalization runs on is the finished one, so it already + // carries the publisher origin's own headers. A provider effect must + // add to those, never replace them: replacing `Set-Cookie` would drop + // the publisher's session and sign-in cookies, and replacing `Vary` + // would break the caching the origin asked for. + let settings = create_test_settings(); + let graph = KvIdentityGraph::in_memory("finalize-provider-headers-store"); + let consent = ConsentContext { + jurisdiction: Jurisdiction::NonRegulated, + source: ConsentSource::Cookie, + ..Default::default() + }; + let mut ec_context = make_context_with_consent(None, None, false, false, consent, true) + .with_provider_for_test(std::sync::Arc::new(EvidenceHeaderProvider)); + ec_context + .generate_if_needed( + &settings, + Some(&graph), + &crate::platform::test_support::noop_services(), + ) + .await + .expect("should create through the provider"); + + // What the publisher's origin returned, before EC finalization runs. + let mut response = empty_response(); + response.headers_mut().append( + http::header::SET_COOKIE, + HeaderValue::from_static("publisher_session=origin-value; Path=/; HttpOnly"), + ); + response.headers_mut().append( + http::header::VARY, + HeaderValue::from_static("accept-encoding"), + ); + + ec_finalize_response( + &settings, + &ec_context, + Some(&graph), + &PartnerRegistry::empty(), + None, + None, + &mut response, + ); + + let cookies: Vec<&str> = response + .headers() + .get_all(http::header::SET_COOKIE) + .iter() + .map(|value| value.to_str().expect("should render set-cookie as utf-8")) + .collect(); + assert!( + cookies + .iter() + .any(|cookie| cookie.starts_with("publisher_session=origin-value")), + "the origin's own cookie must survive a provider effect, got {cookies:?}" + ); + assert!( + cookies + .iter() + .any(|cookie| cookie.starts_with("vendor-ev=abc")), + "the provider's cookie must reach the response, got {cookies:?}" + ); + assert!( + cookies.iter().any(|cookie| cookie.starts_with("ts-ec=")), + "core's own managed cookie must still be written, got {cookies:?}" + ); + + let vary: Vec<&str> = response + .headers() + .get_all(http::header::VARY) + .iter() + .map(|value| value.to_str().expect("should render vary as utf-8")) + .collect(); + assert!( + vary.contains(&"accept-encoding"), + "the origin's Vary must survive a provider effect, got {vary:?}" + ); + assert!( + vary.contains(&"sec-ch-ua"), + "the provider's Vary must reach the response, got {vary:?}" + ); + } + + /// A provider standing in for the one a deployment switched *to*, with a + /// different registered code from the provider that created the live row. + #[derive(Debug)] + struct SwitchedProvider; + + #[async_trait::async_trait(?Send)] + impl crate::ec::provider::EdgeCookieProvider for SwitchedProvider { + fn id(&self) -> &'static str { + "switched" + } + + fn code(&self) -> crate::ec::provider::ProviderCode { + crate::provider_code!("t0sw") + } + + async fn generate( + &self, + _request_info: &dyn crate::evidence::RequestInfo, + _input: &crate::ec::provider::IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, + ) -> Result< + crate::ec::provider::GeneratedEdgeCookie, + error_stack::Report, + > { + Ok(crate::ec::provider::GeneratedEdgeCookie::default()) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + value.to_ascii_lowercase() + } + } + + #[test] + fn switching_provider_leaves_the_previous_providers_row_beyond_withdrawal() { + // Pins what a provider switch really does, which the switching + // section of the pluggable-providers spec now states plainly. The + // retired provider's identifier is owned by nobody this deployment + // reads, so a later withdrawal expires the browser cookie but cannot + // tombstone the row, and the identifier is never adopted either. If + // the deferred `legacy_providers` reader list ever lands, this test + // is meant to fail, so that the spec sentence a deployer acts on is + // revisited in the same change. + let settings = create_test_settings(); + let graph = graph_with_live_canonical_row(); + // A TCF record consenting to nothing, under GDPR, so the request + // carries an explicit refusal of storage. That is the narrow, + // destructive kind of withdrawal, the one that tombstones rather than + // merely suppressing, which is the behavior under test. + let consent = ConsentContext { + jurisdiction: Jurisdiction::Gdpr, + tcf: Some(crate::consent::TcfConsent { + version: 2, + cmp_id: 0, + cmp_version: 0, + consent_screen: 0, + consent_language: "EN".to_owned(), + vendor_list_version: 0, + tcf_policy_version: 2, + created_ds: 0, + last_updated_ds: 0, + purpose_consents: vec![false; 24], + purpose_legitimate_interests: vec![false; 24], + vendor_consents: Vec::new(), + vendor_legitimate_interests: Vec::new(), + special_feature_opt_ins: vec![false; 12], + }), + source: ConsentSource::Cookie, + ..Default::default() + }; + // The browser still carries the identifier the previous provider + // created, but the deployment now runs a provider with a different + // code, so read-back treats the cookie as absent and the active + // identifier is empty. + let ec_context = make_context_with_consent( + None, + Some(CANONICAL_COOKIE_VALUE), + false, + false, + consent, + false, + ) + .with_provider_for_test(std::sync::Arc::new(SwitchedProvider)); + let mut response = empty_response(); + + ec_finalize_response( + &settings, + &ec_context, + Some(&graph), + &PartnerRegistry::empty(), + None, + None, + &mut response, + ); + + assert!( + ec_context.ec_value().is_none(), + "the retired provider's identifier must never be adopted by the new one" + ); + + let (row, _) = graph + .get(CANONICAL_KV_KEY) + .expect("should read the previous provider's row") + .expect("the previous provider's row should still exist"); + assert!( + row.consent.ok, + "withdrawal cannot reach a retired provider's row without the provider that owns the code" + ); + + let cookies: Vec<&str> = response + .headers() + .get_all(http::header::SET_COOKIE) + .iter() + .map(|value| value.to_str().expect("should render set-cookie as utf-8")) + .collect(); + assert!( + cookies + .iter() + .any(|cookie| cookie.starts_with("ts-ec=") && cookie.contains("Max-Age=0")), + "withdrawal should still expire the browser cookie after a switch, got {cookies:?}" + ); + } + #[test] + fn marker_is_expired_when_the_selected_provider_does_not_own_the_cookie() { + // A switch between client-cycle providers looks like this on the first + // request after the switch. The raw cookie is still presented, but the + // selected provider does not recognize it, so no EC is in play. + let settings = create_test_settings(); + let ec_context = make_context( + None, + Some("other~an-ec"), + false, + false, + Jurisdiction::Unknown, + true, + ); + let mut response = empty_response(); + + ec_finalize_response( + &settings, + &ec_context, + None, + &PartnerRegistry::empty(), + None, + None, + &mut response, + ); + + let expired = response + .headers() + .get_all(http::header::SET_COOKIE) + .iter() + .filter_map(|v| v.to_str().ok()) + .any(|v| v.starts_with("ts-ecr=") && v.contains("Max-Age=0")); + assert!( + expired, + "the resolved marker should be expired so the new provider's page script resolves again" + ); + } + + #[test] + fn marker_is_left_alone_when_the_provider_owns_the_cookie() { + let settings = create_test_settings(); + let ec_context = make_context( + Some("an-ec"), + Some("an-ec"), + true, + false, + Jurisdiction::Unknown, + true, + ); + let mut response = empty_response(); + + ec_finalize_response( + &settings, + &ec_context, + None, + &PartnerRegistry::empty(), + None, + None, + &mut response, + ); + + let expired = response + .headers() + .get_all(http::header::SET_COOKIE) + .iter() + .filter_map(|v| v.to_str().ok()) + .any(|v| v.starts_with("ts-ecr=") && v.contains("Max-Age=0")); + assert!( + !expired, + "a recognized identifier should leave the resolved marker in place" ); } } diff --git a/crates/trusted-server-core/src/ec/generation.rs b/crates/trusted-server-core/src/ec/generation.rs index 2924b7692..90530687c 100644 --- a/crates/trusted-server-core/src/ec/generation.rs +++ b/crates/trusted-server-core/src/ec/generation.rs @@ -10,8 +10,8 @@ use hmac::{Hmac, Mac}; use rand::Rng; use sha2::Sha256; +use crate::ec::provider::{HMAC_PROVIDER_CODE, PROVIDER_CODE_SEPARATOR, split_provider_code}; use crate::error::TrustedServerError; -use crate::settings::Settings; type HmacSha256 = Hmac; @@ -81,19 +81,39 @@ fn generate_random_suffix(length: usize) -> String { /// /// - [`TrustedServerError::EdgeCookie`] if HMAC generation fails pub fn generate_ec_id( - settings: &Settings, + passphrase: &str, client_ip: &str, ) -> Result> { - let mut mac = HmacSha256::new_from_slice(settings.ec.passphrase.expose().as_bytes()) - .change_context(TrustedServerError::EdgeCookie { + generate_hmac_ec_id(passphrase, &[client_ip]) +} + +/// Creates an Edge Cookie identifier as HMAC-SHA256 over the given parts plus a +/// random suffix, in the `{64hex}.{6alnum}` format. +/// +/// The parts are joined with a unit separator (`\u{1f}`), which cannot appear in +/// a client IP, User-Agent, or TLS and HTTP/2 signal, so distinct part lists +/// cannot collide. A provider that derives identity from multiple request +/// signals (for example a Fastly provider over JA4, H2, IP, and UA) passes them +/// as separate parts. Each part must be pre-normalized by the caller. +/// +/// # Errors +/// +/// - [`TrustedServerError::EdgeCookie`] if HMAC generation fails +pub fn generate_hmac_ec_id( + passphrase: &str, + parts: &[&str], +) -> Result> { + let mut mac = HmacSha256::new_from_slice(passphrase.as_bytes()).change_context( + TrustedServerError::EdgeCookie { message: "Failed to create HMAC instance".to_string(), - })?; - mac.update(client_ip.as_bytes()); + }, + )?; + // A unit separator cannot occur in any part, so distinct lists never collide. + mac.update(parts.join("\u{1f}").as_bytes()); let hmac_hash = hex::encode(mac.finalize().into_bytes()); - // Append random 6-character alphanumeric suffix for additional uniqueness. - let random_suffix = generate_random_suffix(6); - let ec_id = format!("{hmac_hash}.{random_suffix}"); + // Append a random 6-character alphanumeric suffix for additional uniqueness. + let ec_id = format!("{hmac_hash}.{}", generate_random_suffix(6)); log::trace!("Generated fresh EC ID: {}", super::log_id(&ec_id)); @@ -120,12 +140,33 @@ pub fn ec_hash(ec_id: &str) -> &str { /// so internal EC IDs are already lowercase. This normalization is a /// defense-in-depth measure for EC IDs submitted by external partners /// (via batch sync) that may use uppercase hex. +/// +/// An identifier this function creates carries the built-in provider's code +/// envelope (`hmac~` before the value, see +/// [`PROVIDER_CODE_SEPARATOR`](super::provider::PROVIDER_CODE_SEPARATOR)), +/// and partners echo that form back, so the envelope is kept and only the +/// value inside it is lowercased. That keeps the key identical to the one +/// written at creation. An identifier under any other provider's code is not +/// HMAC-shaped and is returned unchanged, because only that provider knows +/// how to normalize it. #[must_use] pub fn normalize_ec_id_for_kv(ec_id: &str) -> String { - let mut parts = ec_id.splitn(2, '.'); + let (code, bare) = match split_provider_code(ec_id) { + (Some(code), bare) if code == HMAC_PROVIDER_CODE.as_str() => (Some(code), bare), + (Some(_), _) => return ec_id.to_owned(), + (None, bare) => (None, bare), + }; + let mut parts = bare.splitn(2, '.'); let hash = parts.next().unwrap_or_default(); let suffix = parts.next().unwrap_or_default(); - format!("{}.{}", hash.to_ascii_lowercase(), suffix) + match code { + Some(code) => format!( + "{code}{PROVIDER_CODE_SEPARATOR}{}.{}", + hash.to_ascii_lowercase(), + suffix + ), + None => format!("{}.{}", hash.to_ascii_lowercase(), suffix), + } } /// Checks whether a string is a valid 64-character hex EC hash prefix. @@ -139,17 +180,35 @@ pub fn is_valid_ec_hash(value: &str) -> bool { value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit()) } -/// Checks whether a string matches the expected EC ID format. +/// Checks whether a string is a built-in HMAC identifier, bare or enveloped. /// -/// The format is `{64hex}.{6alnum}` where the first part is a 64-character -/// **lowercase** hex string and the second part is a 6-character alphanumeric -/// string. Only lowercase hex is accepted; callers must normalize before -/// validation to prevent duplicate KV keys from case-variant EC IDs. The HMAC -/// prefix is lowercase because it comes from `hex::encode`; the random suffix -/// allows mixed-case alphanumeric characters by construction. +/// The bare format is `{64hex}.{6alnum}` where the first part is a +/// 64-character **lowercase** hex string and the second part is a 6-character +/// alphanumeric string. Only lowercase hex is accepted; callers must +/// normalize before validation to prevent duplicate KV keys from case-variant +/// EC IDs. The HMAC prefix is lowercase because it comes from `hex::encode`; +/// the random suffix allows mixed-case alphanumeric characters by +/// construction. +/// +/// An identifier this provider creates carries the provider-code envelope, +/// `hmac~` before the bare value, so both the enveloped and the legacy bare +/// form are accepted here. An identifier under any other provider's code is +/// not an HMAC identifier and is rejected. +/// +/// This is the built-in provider's grammar, not the deployment's. The +/// partner-facing paths (pull sync, batch sync, the admin lookup) dispatch by +/// provider code through +/// [`AcceptedProviders`](super::provider::AcceptedProviders), which reaches +/// this only for an identifier the built-in provider owns, or as the fallback +/// for a stateless deployment that has selected no provider at all. #[must_use] pub fn is_valid_ec_id(value: &str) -> bool { - let mut parts = value.split('.'); + let bare = match split_provider_code(value) { + (Some(code), bare) if code == HMAC_PROVIDER_CODE.as_str() => bare, + (Some(_), _) => return false, + (None, bare) => bare, + }; + let mut parts = bare.split('.'); let Some(hmac_part) = parts.next() else { return false; }; @@ -175,7 +234,39 @@ mod tests { use super::*; use std::net::{Ipv4Addr, Ipv6Addr}; - use crate::test_support::tests::create_test_settings; + const TEST_PASSPHRASE: &str = "test-secret-key-32-bytes-minimum"; + + #[test] + fn generate_hmac_ec_id_is_stable_per_parts_and_collision_resistant() { + // The 64-char hex prefix is HMAC over the parts and is stable for the + // same parts; the random suffix varies, so compare prefixes only. + let prefix = |parts: &[&str]| { + generate_hmac_ec_id(TEST_PASSPHRASE, parts) + .expect("should generate") + .split('.') + .next() + .expect("should have a prefix") + .to_owned() + }; + + assert_eq!( + prefix(&["a", "b"]), + prefix(&["a", "b"]), + "the same parts should yield the same stable prefix" + ); + assert_ne!( + prefix(&["a", "b"]), + prefix(&["a", "c"]), + "different parts should yield a different prefix" + ); + // The unit separator prevents a join collision: ["a", "b"] must not hash + // the same as ["ab"]. + assert_ne!( + prefix(&["a", "b"]), + prefix(&["ab"]), + "the separator should prevent ['a','b'] colliding with ['ab']" + ); + } #[test] fn normalize_ipv4_unchanged() { @@ -215,8 +306,7 @@ mod tests { #[test] fn generate_produces_valid_format() { - let settings = create_test_settings(); - let ec_id = generate_ec_id(&settings, "192.168.1.1").expect("should generate EC ID"); + let ec_id = generate_ec_id(TEST_PASSPHRASE, "192.168.1.1").expect("should generate EC ID"); assert!( is_valid_ec_id(&ec_id), "should match EC ID format: {{64hex}}.{{6alnum}}, got: {ec_id}" @@ -225,10 +315,10 @@ mod tests { #[test] fn generate_same_ip_produces_consistent_hash_prefix() { - let settings = create_test_settings(); - let first = generate_ec_id(&settings, "192.168.1.1").expect("should generate first EC ID"); + let first = + generate_ec_id(TEST_PASSPHRASE, "192.168.1.1").expect("should generate first EC ID"); let second = - generate_ec_id(&settings, "192.168.1.1").expect("should generate second EC ID"); + generate_ec_id(TEST_PASSPHRASE, "192.168.1.1").expect("should generate second EC ID"); assert_eq!( ec_hash(&first), @@ -321,4 +411,37 @@ mod tests { "should reject extra segments" ); } + + #[test] + fn is_valid_ec_id_accepts_the_hmac_envelope() { + let coded = format!("hmac~{}.ABC123", "a".repeat(64)); + assert!( + is_valid_ec_id(&coded), + "should accept a created identifier carrying the hmac code" + ); + } + + #[test] + fn is_valid_ec_id_rejects_other_provider_codes() { + let coded = format!("t0op~{}.ABC123", "a".repeat(64)); + assert!( + !is_valid_ec_id(&coded), + "should reject an identifier carrying another provider's code" + ); + } + + #[test] + fn normalize_ec_id_for_kv_keeps_the_hmac_envelope() { + let coded = format!("hmac~{}.ABC123", "A".repeat(64)); + assert_eq!( + normalize_ec_id_for_kv(&coded), + format!("hmac~{}.ABC123", "a".repeat(64)), + "should lowercase the hash and keep the code prefix" + ); + assert_eq!( + normalize_ec_id_for_kv("t0op~MixedCase"), + "t0op~MixedCase", + "should leave another provider's identifier unchanged" + ); + } } diff --git a/crates/trusted-server-core/src/ec/identify.rs b/crates/trusted-server-core/src/ec/identify.rs index 6ca251905..ca34354a1 100644 --- a/crates/trusted-server-core/src/ec/identify.rs +++ b/crates/trusted-server-core/src/ec/identify.rs @@ -10,7 +10,6 @@ use http::{Request, Response, StatusCode}; use url::Url; use super::auth::authenticate_bearer; -use super::consent::ec_consent_granted; use crate::error::TrustedServerError; use crate::openrtb::{Eid, Uid}; use crate::settings::Settings; @@ -62,7 +61,11 @@ pub fn handle_identify( ); }; - if !ec_consent_granted(ec_context.consent()) { + // Identify returns the partner's UID for this visitor, which is sharing + // the identity beyond the edge, so it needs the same permission pair as + // bidstream EIDs (storage plus personalised-ad selection), not only the + // provider's storage permission. + if !ec_context.ec_sharing_allowed() { return json_response_with_origin( StatusCode::FORBIDDEN, &serde_json::json!({ "consent": "denied" }), @@ -87,40 +90,53 @@ pub fn handle_identify( let mut uid: Option = None; let mut cluster_size: Option = None; - match kv.get(ec_id) { - Ok(Some((entry, generation))) => { - if !entry.consent.ok { - // Tombstone entries preserve the withdrawal signal for 24 hours. - // Do not extract IDs or evaluate cluster size because that would - // write back with the live-entry TTL. - log::trace!("Identify found tombstone for '{}'", log_id(ec_id)); - } else { - // Extract only this partner's UID. - if let Some(partner_uid) = entry.ids.get(&partner.source_domain) - && !partner_uid.uid.is_empty() - { - uid = Some(partner_uid.uid.clone()); - } - - // Evaluate cluster size lazily for identify responses. Existing - // stored cluster_size values are reused without a prefix-list call. - match kv.evaluate_cluster(ec_id, &entry, generation) { - Ok(size) => { - cluster_size = size; + // Read the identity-graph row under the provider's canonical form of the + // identifier, the same key generation wrote, rather than under the value the + // browser carries. The two are the same string for the built-in HMAC + // provider and differ for any provider whose canonical form is not the + // cookie value. `None` means no provider this deployment reads owns the + // identifier, so there is no row to look for and the response is not + // degraded. + if let Some(kv_key) = ec_context.ec_kv_key() { + match kv.get(&kv_key) { + Ok(Some((entry, generation))) => { + if !entry.consent.ok { + // Tombstone entries preserve the withdrawal signal for 24 + // hours. Do not extract IDs or evaluate cluster size because + // that would write back with the live-entry TTL. + log::trace!("Identify found tombstone for '{}'", log_id(&kv_key)); + } else { + // Extract only this partner's UID. + if let Some(partner_uid) = entry.ids.get(&partner.source_domain) + && !partner_uid.uid.is_empty() + { + uid = Some(partner_uid.uid.clone()); } - Err(err) => { - log::warn!("Cluster evaluation failed for '{}': {err:?}", log_id(ec_id)); + + // Evaluate cluster size lazily for identify responses. + // Existing stored cluster_size values are reused without a + // prefix-list call. + match kv.evaluate_cluster(&kv_key, &entry, generation) { + Ok(size) => { + cluster_size = size; + } + Err(err) => { + log::warn!( + "Cluster evaluation failed for '{}': {err:?}", + log_id(&kv_key) + ); + } } } } - } - Ok(None) => {} - Err(err) => { - log::warn!( - "Identify KV read failed for EC ID '{}': {err:?}", - log_id(ec_id) - ); - degraded = true; + Ok(None) => {} + Err(err) => { + log::warn!( + "Identify KV read failed for EC ID '{}': {err:?}", + log_id(&kv_key) + ); + degraded = true; + } } } @@ -332,7 +348,6 @@ fn apply_cors_headers(response: &mut Response, origin: &str) { #[cfg(test)] mod tests { use super::*; - use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::{ConsentContext, ConsentSource}; use crate::ec::registry::PartnerRegistry; use crate::redacted::Redacted; @@ -352,13 +367,23 @@ mod tests { ); } - fn make_ec_context(jurisdiction: Jurisdiction, ec_value: Option<&str>) -> EcContext { + /// The identifier [`CanonicalizingProvider`] creates, as the browser + /// carries it in the `ts-ec` cookie. + const CANONICAL_COOKIE_VALUE: &str = "t0ca~MiXeD.CaseId"; + + /// The identity-graph key generation writes that identifier's row under. + /// Pinned to the creation path by + /// `generate_keys_the_identity_graph_by_the_normalized_identifier` in the + /// `ec` module tests, which asserts both the key it writes and the key + /// [`EcContext::ec_kv_key`] derives. + const CANONICAL_KV_KEY: &str = "t0ca~mixed.caseid"; + + fn make_ec_context(ec_allowed: bool, ec_value: Option<&str>) -> EcContext { let consent = ConsentContext { - jurisdiction, source: ConsentSource::Cookie, ..ConsentContext::default() }; - EcContext::new_for_test(ec_value.map(str::to_owned), consent) + EcContext::new_for_test_gated(ec_value.map(str::to_owned), consent, ec_allowed) } fn make_test_partner(source_domain: &str, api_token: &str) -> EcPartner { @@ -472,7 +497,7 @@ mod tests { .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 ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct unauthorized response"); @@ -514,7 +539,7 @@ mod tests { .header("authorization", "Bearer wrong-token") .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct unauthorized response"); @@ -539,7 +564,7 @@ mod tests { .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 ec_context = make_ec_context(false, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct denied response"); @@ -573,7 +598,7 @@ mod tests { .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 ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct no-content response"); @@ -599,7 +624,7 @@ mod tests { .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 ec_context = make_ec_context(true, Some(&ec_id)); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct degraded identify response"); @@ -652,7 +677,7 @@ mod tests { .header("origin", "https://evil.example") .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = make_ec_context(Jurisdiction::NonRegulated, None); + let ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct forbidden response"); @@ -678,7 +703,7 @@ mod tests { .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 ec_context = make_ec_context(true, None); let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) .expect("should construct no-content response with CORS headers"); @@ -756,4 +781,57 @@ mod tests { "should vary on identity request inputs for preflight" ); } + + #[test] + fn handle_identify_reads_the_row_under_the_providers_canonical_key() { + // A provider whose canonical form is not the cookie value keys its row + // under the canonical form at generation. Identify has to look there, + // or every such deployment reads a miss for every request and reports + // no partner UID at all. + let settings = create_test_settings(); + let kv = KvIdentityGraph::in_memory("identify-canonical-store"); + kv.create( + CANONICAL_KV_KEY, + &crate::ec::kv_types::KvEntry::minimal( + "ssp.example.com", + "partner-uid-123", + 1_741_824_000, + ), + ) + .expect("should write the row generation keys by the canonical form"); + let partners = vec![make_test_partner("ssp.example.com", VALID_API_TOKEN)]; + let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); + 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(true, Some(CANONICAL_COOKIE_VALUE)) + .with_provider_for_test(std::sync::Arc::new( + crate::ec::tests::CanonicalizingProvider, + )); + + let response = handle_identify(&settings, &kv, ®istry, &req, &ec_context) + .expect("should build identify response"); + + assert_eq!(response.status(), StatusCode::OK, "should return 200"); + let body = serde_json::from_slice::( + &response.into_body().into_bytes().unwrap_or_default(), + ) + .expect("should decode identify response JSON"); + assert_eq!( + body["ec"], CANONICAL_COOKIE_VALUE, + "should echo the identifier the browser carries, not the graph key" + ); + assert_eq!( + body["uid"], "partner-uid-123", + "should find the row generation keyed by the provider's canonical form" + ); + assert_eq!( + body["degraded"], + serde_json::Value::Bool(false), + "a hit under the canonical key is not a degraded read" + ); + } } diff --git a/crates/trusted-server-core/src/ec/kv.rs b/crates/trusted-server-core/src/ec/kv.rs index 3572581ce..14f6f3487 100644 --- a/crates/trusted-server-core/src/ec/kv.rs +++ b/crates/trusted-server-core/src/ec/kv.rs @@ -712,6 +712,19 @@ impl KvIdentityGraph { } // Compute cluster size via prefix list. + // + // `ec_hash` takes everything before the first `.`, so a coded + // identifier yields `hmac~` and a legacy bare one yields + // ``. Prefix matching is anchored at the start of the key, so + // the two never see each other: while pre-epic bare cookies are still + // being read back, two rows for the same client IP that straddle the + // envelope each count only their own half and `cluster_size` + // under-reports. That is accepted, not a defect to work around here. + // The count is reported in identify responses and gates nothing, and + // bridging it would mean a second prefix scan on every request for the + // whole migration window. See section 3 of the pluggable-providers + // design. Anyone making this count gate a decision has to fix the + // bridge first. let hash_prefix = ec_hash(ec_id); let cluster_size = self.count_hash_prefix_keys(hash_prefix)?; diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 840ce90d3..f5424ee2b 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -15,7 +15,7 @@ //! //! - auth (private) — shared Bearer-token authentication helpers //! - [`generation`] — HMAC-based ID generation, IP normalization, format helpers -//! - [`consent`] — EC-specific consent gating wrapper +//! - [`consent`]: EC-specific permission gating, with consent as one input //! - [`cookies`] — `Set-Cookie` header creation and expiration helpers //! - [`kv`] — KV Store identity graph operations (CAS, tombstones, debounce) //! - [`kv_backend`] — Platform-neutral KV primitives implemented by adapters @@ -45,9 +45,11 @@ pub mod kv_backend; pub mod kv_types; pub mod partner; pub mod prebid_eids; +pub mod provider; pub mod pull_sync; pub mod rate_limiter; pub mod registry; +pub mod resolve; /// Truncates an EC ID for safe inclusion in log messages. /// @@ -60,6 +62,8 @@ pub fn log_id(ec_id: &str) -> String { format!("{prefix}\u{2026}") } +use std::sync::Arc; + use cookie::CookieJar; use edgezero_core::body::Body as EdgeBody; use error_stack::Report; @@ -70,10 +74,13 @@ 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::evidence::{BorrowedRequestInfo, HostSignals}; use crate::geo::GeoInfo; +use crate::permissions::{Acquisition, Permission, PermissionState}; use crate::platform::RuntimeServices; use crate::settings::Settings; use device::DeviceSignals; +use provider::{EdgeCookieProvider, GeneratedEdgeCookie, IdentityInput}; use self::kv::KvIdentityGraph; use self::kv_types::KvEntry; @@ -115,24 +122,6 @@ fn request_ec_id_if_allowed(value: &str, source: &str) -> Option { None } -/// Gets an existing EC ID from the request. -/// -/// Attempts to retrieve an existing EC ID from the `ts-ec` cookie. -/// -/// Returns `None` if the cookie does not contain a valid EC ID. -/// -/// # Errors -/// -/// - [`TrustedServerError::InvalidHeaderValue`] if cookie parsing fails -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 { - log::trace!("Existing EC ID found: {}", log_id(id)); - } - Ok(ec_id) -} - /// Captures the EC state for a single request lifecycle. /// /// Created via [`read_from_request`](Self::read_from_request) during @@ -152,6 +141,19 @@ pub struct EcContext { ec_generated: bool, /// The consent context for this request. consent: ConsentContext, + /// Whether the configured Edge Cookie provider's required permissions are + /// set for this request. Resolved once at construction through the + /// permission model and read via [`ec_allowed`](Self::ec_allowed). + ec_allowed: bool, + /// The permissions resolved for this request: the country/region baseline + /// augmented by the session's signals. Assembled once at construction and + /// read via [`permissions`](Self::permissions). + permissions: PermissionState, + /// The jurisdiction's acquisition rule for Edge Cookie storage, resolved + /// once at construction and used by + /// [`storage_withdrawn`](Self::storage_withdrawn) to scope destructive + /// withdrawal. Defaults to the requires-signal floor. + storage_acquisition: Acquisition, /// The normalized client IP, captured early before the request body /// is consumed. `None` when the platform cannot determine client IP. client_ip: Option, @@ -161,6 +163,39 @@ pub struct EcContext { /// Set via [`EcContext::set_device_signals`] before /// [`EcContext::generate_if_needed`] is called. device_signals: Option, + /// The host-signal service for this request, when the host supplies one + /// (the Fastly adapter registers the TLS and HTTP/2 signals). `None` on a + /// host that exposes none. Injected into a provider that needs it when the + /// provider is built. + host_signals: Option>, + /// The adapter-injected Edge Cookie provider, when one is wired for this + /// request. Captured once from [`RuntimeServices`] at construction and read + /// on every path that builds a provider (the organic path through + /// `request_provider`, the resolve path through `build_provider`), so a + /// vendor or host provider resolves without core naming it. `None` for + /// built-in-only deployments. + ec_provider: Option>, + /// The selected Edge Cookie provider (built-in or injected), built once at + /// construction. Core asks it whether an identifier is well formed + /// ([`accepts_id`](crate::ec::provider::EdgeCookieProvider::accepts_id)) so + /// an opaque vendor identifier round-trips through read-back and withdrawal + /// instead of being dropped by the built-in shape check. `None` when no + /// provider is configured. + selected_provider: Option>, + /// A snapshot of the request evidence a provider reads at generation time: + /// the request headers (so a provider can read cookies and client hints), and + /// the URL path and query string (so it can read request parameters). + /// Captured once at construction, and only when a provider is configured and + /// the request carries no usable identifier, so a no-provider deployment and + /// a returning visitor both clone nothing. A provider reads these through + /// [`RequestInfo`](crate::evidence::RequestInfo) at generate time. + request_headers: http::HeaderMap, + request_path: String, + request_query: String, + /// Response headers a provider asked to set, captured during + /// [`EcContext::generate_if_needed`] and applied to the response by EC + /// finalization. Empty for providers that set no headers. + response_headers: Vec<(http::HeaderName, http::HeaderValue)>, } impl EcContext { @@ -198,15 +233,120 @@ impl EcContext { services: &RuntimeServices, geo_info: Option<&GeoInfo>, ) -> Result> { + Self::read_from_request_with_geo_status( + settings, + req, + services, + consent::GeoStatus::from(geo_info), + ) + } + + /// Reads the EC context, resolving the location through the configured geo + /// provider first. + /// + /// This is the constructor adapters use: it runs the geo lookup itself so + /// a failed lookup is distinguished from "no location resolved". No + /// location falls back to the permission policy's top node, while a + /// failure resolves every permission to the requires-signal floor (see + /// [`consent::GeoStatus`]) and is logged at error level so an outage is + /// visible. + /// + /// # Errors + /// + /// Returns [`TrustedServerError`] when the selected Edge Cookie provider + /// cannot be built, the same as + /// [`read_from_request_with_geo`](Self::read_from_request_with_geo). + pub async fn read_from_request_resolving_geo( + settings: &Settings, + req: &Request, + services: &RuntimeServices, + ) -> Result> { + let lookup = services + .geo() + .lookup(services.client_info().client_ip, services) + .await; + let geo_info = match &lookup { + Ok(info) => info.clone(), + Err(error) => { + log::error!( + "geo lookup failed; resolving permissions at the requires-signal floor: {error:?}" + ); + None + } + }; + let status = match (&lookup, &geo_info) { + (Err(_), _) => consent::GeoStatus::Failed, + (Ok(_), Some(info)) => consent::GeoStatus::Located(info), + (Ok(_), None) => consent::GeoStatus::NoLocation, + }; + Self::read_from_request_with_geo_status(settings, req, services, status) + } + + fn read_from_request_with_geo_status( + settings: &Settings, + req: &Request, + services: &RuntimeServices, + geo_status: consent::GeoStatus<'_>, + ) -> Result> { + let geo_info = geo_status.info(); let parsed = parse_ec_from_request(req)?; - let ec_value = parsed.cookie_ec.clone().filter(|v| is_valid_ec_id(v)); + // Take the selected provider once. It is used here to decide whether + // the incoming cookie value is a usable identifier, to read the + // provider's required permissions, and again by generation, which + // reuses this one rather than asking for another. `request_provider` + // hands back the instance the composition root resolved when there is + // one, and otherwise resolves the selection from this request's own + // services, the host signals among them, so a provider built from + // request evidence reads this request's evidence. A provider that needs + // a service the host did not supply fails to resolve, which stops the + // request. + let selected_provider: Option> = + provider::request_provider(&settings.ec, services)?; + + // The same two services are kept on the context so the resolve endpoint, + // which runs later in the request with no `RuntimeServices` of its own, + // can reach the provider again. The provider arrives through the one + // seam a composition root threads it into, so the resolve endpoint sees + // the same instance this request resolved. + let host_signals = services.host_signals(); + let ec_provider = services.resolved_ec_provider(); + + // Read back an existing identifier only when the selected provider + // accepts its shape, so an opaque vendor identifier (for example a signed + // envelope) round-trips instead of being silently dropped by the built-in + // shape check. With no provider configured, Trusted Server is stateless: + // an existing identifier is treated as absent so it is never used or + // egressed, while the raw cookie value stays available to withdrawal + // handling below. + let ec_value = parsed.cookie_ec.clone().filter(|v| { + selected_provider + .as_ref() + .is_some_and(|selected| provider::provider_owns_id(selected.as_ref(), v)) + }); let ec_was_present = ec_value.is_some(); if let Some(ref id) = ec_value { log::trace!("Existing EC ID found: {}", log_id(id)); } + // Snapshot the request evidence a provider reads at generation time (the + // headers, so it can read cookies and client hints, and the URL path and + // query, so it can read request parameters). Capture only when a provider + // is configured and no identifier already exists, so a no-provider + // deployment and a returning visitor clone nothing. Generation runs after + // the request body may be consumed, so the snapshot is owned. + let (request_headers, request_path, request_query) = + if selected_provider.is_some() && ec_value.is_none() { + ( + req.headers().clone(), + req.uri().path().to_owned(), + req.uri().query().unwrap_or_default().to_owned(), + ) + } else { + (http::HeaderMap::new(), String::new(), String::new()) + }; + // Capture the client IP from platform services (normalized). let client_ip = services .client_info() @@ -214,20 +354,39 @@ impl EcContext { .map(generation::normalize_ip); // Build consent context from request-local cookies, headers, and geo. + // Jurisdiction detection follows the permission model's fallback: with + // no location resolved the policy's declared jurisdiction stands in, so + // a deployment that declared one is not treated as unknown, while a + // failed lookup stays unknown so the consent gates fail closed + // alongside the requires-signal floor. let consent = consent_mod::build_consent_context(&ConsentPipelineInput { jar: parsed.jar.as_ref(), req, config: &settings.consent, geo: geo_info, + default_jurisdiction: consent::default_jurisdiction(geo_status), ec_id: None, kv_store: None, }); + // Assemble the permission state once, here, through the permission + // model, building the country/region baseline augmented by the session's + // signals. Downstream consumers read the stored result via + // [`EcContext::permissions`] and [`EcContext::ec_allowed`] rather than + // re-deriving it. + let permissions = consent::assemble_permissions(&consent, geo_status); + let storage_acquisition = consent::storage_acquisition(geo_status); + // With no provider selected nothing may create or use an identifier, so + // the gate is closed rather than open by default. + let ec_allowed = selected_provider + .as_ref() + .is_some_and(|selected| permissions.all_set(selected.required_permissions())); + log::info!( - "EC context: present={}, cookie_present={}, consent_allowed={}, jurisdiction={}", + "EC context: present={}, cookie_present={}, ec_allowed={}, jurisdiction={}", ec_was_present, parsed.cookie_ec.is_some(), - consent::ec_consent_granted(&consent), + ec_allowed, consent.jurisdiction, ); @@ -237,9 +396,19 @@ impl EcContext { ec_was_present, ec_generated: false, consent, + ec_allowed, + permissions, + storage_acquisition, client_ip, geo_info: geo_info.cloned(), device_signals: None, + host_signals, + ec_provider, + selected_provider, + request_headers, + request_path, + request_query, + response_headers: Vec::new(), }) } @@ -254,33 +423,143 @@ impl EcContext { /// /// # Errors /// - /// Returns an error if the client IP is unavailable and generation is - /// needed, or if HMAC generation fails. - pub fn generate_if_needed( + /// Returns an error if the selected provider fails to derive an identifier, + /// which includes a provider that needs the client IP being run on a host + /// that cannot supply one. + pub async fn generate_if_needed( &mut self, settings: &Settings, kv: Option<&KvIdentityGraph>, + services: &RuntimeServices, ) -> Result<(), Report> { if self.ec_value.is_some() { return Ok(()); } - if !consent::ec_consent_granted(&self.consent) { + // A deployment with no provider selected is stateless: nothing to + // generate, and not an error. Reuse the provider built at read time + // rather than building it again. + let Some(ec_provider) = self.selected_provider.clone() else { + log::trace!("EC generation skipped: no Edge Cookie provider configured"); + return Ok(()); + }; + + if !self.ec_allowed { log::info!( - "EC generation skipped: consent not granted (jurisdiction={})", + "EC generation skipped: required permissions not set (jurisdiction={})", self.consent.jurisdiction, ); return Ok(()); } - let client_ip = self.client_ip.as_deref().ok_or_else(|| { - Report::new(TrustedServerError::EdgeCookie { - message: "Client IP required for EC generation but unavailable".to_owned(), - }) - })?; + // Whether the client IP is needed is the selected provider's decision, + // not core's. A provider that derives identity from headers, cookies, + // query parameters, or the client reads no IP and must still run on a + // host that cannot supply one. The IP is passed as the documented + // unavailable value, the empty string (see + // [`RequestInfo::client_ip`](crate::evidence::RequestInfo::client_ip)), + // and a provider that needs it refuses there, which fails the request + // rather than serving without identity. + self.generate_with_provider(ec_provider.as_ref(), settings, kv, services) + .await + } - let ec_id = generation::generate_ec_id(settings, client_ip)?; - log::info!("Generated new EC ID: {}", log_id(&ec_id)); + /// Derives and commits an EC identifier using a specific provider. + /// + /// Split out of [`generate_if_needed`](Self::generate_if_needed) so the + /// provider is supplied explicitly, resolved once at read time and threaded + /// here rather than rebuilt. The request evidence captured at read time + /// (client IP, headers, and the URL path and query) is passed borrowed + /// through [`RequestInfo`](crate::evidence::RequestInfo), so a provider can + /// read cookies and request parameters at generate time, and the built-in + /// HMAC provider reads only the client IP. The skip guards (existing EC, + /// permission gate) stay in [`generate_if_needed`](Self::generate_if_needed). + /// + /// # Errors + /// + /// Returns [`TrustedServerError::EdgeCookie`] when the provider fails to + /// derive an identifier (which for [`HmacProvider`] includes an + /// unavailable client IP), the provider + /// asks for a response header inside core's reserved surface (see + /// [`reserved_response_effect`](crate::ec::provider::reserved_response_effect)), + /// or persisting a generated identifier to the KV identity graph fails. + async fn generate_with_provider( + &mut self, + ec_provider: &dyn EdgeCookieProvider, + settings: &Settings, + kv: Option<&KvIdentityGraph>, + services: &RuntimeServices, + ) -> Result<(), Report> { + let input = IdentityInput { + permissions: Some(&self.permissions), + consent: Some(&self.consent), + }; + // Pass the request evidence captured at read time, borrowed: the client + // IP, the request headers (so a provider reads cookies and client hints), + // and the URL path and query (so it reads request parameters). A built-in + // provider reads only the client IP, and a vendor provider reads what it + // needs through [`RequestInfo`]. + let request_info = BorrowedRequestInfo::new( + self.client_ip.as_deref().unwrap_or_default(), + Some(&self.request_headers), + ) + .with_request_target(&self.request_path, &self.request_query); + let generated: GeneratedEdgeCookie = ec_provider + .generate(&request_info, &input, services) + .await?; + // Check every response header the provider asked for against core's + // reserved surface before any of them are kept. A provider may set its + // own cookies and headers, but not a managed `ts-` cookie, a header in + // the `x-ts-` namespace, or a framing or hop-by-hop header. Rejection + // fails the request, matching the identifier-bounds rejection below: + // without it a provider could write `ts-ec` itself and bypass the + // identifier validation and identity-graph row this function enforces. + // Checked before the identifier is read, because a provider can return + // headers with no identifier at all. + for (name, value) in &generated.response_headers { + if let Some(effect) = provider::reserved_response_effect(name, value) { + return Err(Report::new(TrustedServerError::EdgeCookie { + message: format!( + "Provider `{}` returned a response header `{name}` that {effect}", + ec_provider.id(), + ), + })); + } + } + // Capture any response headers the provider asked for, even when it + // produced no identifier (for example while it still needs more client + // evidence). EC finalization applies them to the response. + self.response_headers = generated.response_headers; + let generated_id = generated + .id + .map(|value| crate::ec::provider::apply_provider_code(ec_provider, &value)); + let Some(ec_id) = generated_id else { + log::info!( + "EC generation produced no identifier (provider={}); proceeding without an EC", + ec_provider.id(), + ); + return Ok(()); + }; + // Enforce the global identifier bounds at creation. The cookie-safe + // alphabet and the length cap apply to every provider, so no + // implementation can emit a value the cookie layer or the identity + // graph cannot carry. Rejection is loud and total; the identifier is + // never rewritten. + if !ec_id_has_only_allowed_chars(&ec_id) { + return Err(Report::new(TrustedServerError::EdgeCookie { + message: format!( + "Provider `{}` produced an identifier that is empty, over {} bytes, or \ + outside the cookie-safe alphabet", + ec_provider.id(), + cookies::MAX_EC_ID_LEN, + ), + })); + } + log::info!( + "Generated new EC ID (provider={}): {}", + ec_provider.id(), + log_id(&ec_id), + ); self.ec_value = Some(ec_id); self.ec_generated = true; @@ -297,7 +576,13 @@ impl EcContext { .as_ref() .map(DeviceSignals::to_kv_device); - if let Err(err) = graph.create_or_revive(ec_value, &entry) { + // Key the identity graph by the provider's canonical form of the + // identifier, so equivalent representations of one identity share + // one row. The built-in normalization lowercases only the HMAC + // hash segment; an opaque vendor provider overrides it to the + // identity function. + let kv_key = crate::ec::provider::provider_kv_key(ec_provider, ec_value); + if let Err(err) = graph.create_or_revive(&kv_key, &entry) { log::error!( "Failed to create or revive EC entry for id '{}' after generation: {err:?}", log_id(ec_value), @@ -319,6 +604,84 @@ impl EcContext { self.ec_value.as_deref() } + /// The providers whose identifiers this request's paths accept. + /// + /// Today that is the selected provider alone (see + /// [`AcceptedProviders`](provider::AcceptedProviders) for the + /// `legacy_providers` seam). + #[must_use] + pub(crate) fn accepted_providers(&self) -> provider::AcceptedProviders<'_> { + provider::AcceptedProviders::active(self.selected_provider.as_deref()) + } + + /// Returns whether `value` is a well-formed identifier for the selected + /// provider. + /// + /// Lets core validate a cookie or active identifier (for example before + /// withdrawing it) through the provider that issued it, rather than assuming + /// the built-in shape. The global cookie bounds are checked first, then the + /// provider-specific part is dispatched by the identifier's code. Falls back + /// to the built-in shape when no provider is configured. + #[must_use] + pub(crate) fn accepts_id(&self, value: &str) -> bool { + self.accepted_providers().accepts(value) + } + + /// The identity-graph key for `value` under the providers this deployment + /// reads. + /// + /// The canonical route from an identifier to a row key. The organic + /// generate, identify and finalize paths turn an identifier into a row key + /// through this (or through [`ec_kv_key`](Self::ec_kv_key), which wraps it), + /// so a provider whose canonical form differs from the cookie value still + /// finds the row it created. The owning provider is picked by the + /// identifier's `{code}~` prefix and supplies the canonical form of its own + /// value part, matching what + /// [`generate_if_needed`](Self::generate_if_needed) wrote at creation. + /// + /// Known gap: pull sync (`ec::pull_sync`) and the admin lookup + /// (`ec::admin`) still key rows by the raw active identifier rather than + /// this canonical form, so for a provider whose canonical form differs from + /// the cookie value they can read or write under the wrong key. The three + /// organic paths were routed through the canonical form (commit + /// `343ac3e`); these two were left keying raw and are tracked as a known + /// issue for a later change. + /// + /// `None` when no provider this deployment reads owns `value`, in which + /// case there is no row to read or write. + #[must_use] + pub(crate) fn kv_key_for(&self, value: &str) -> Option { + self.accepted_providers().canonical_kv_key(value) + } + + /// The identity-graph key for this request's active identifier. + #[must_use] + pub(crate) fn ec_kv_key(&self) -> Option { + self.ec_value().and_then(|value| self.kv_key_for(value)) + } + + /// The identity-graph key for the `ts-ec` cookie the request carried. + /// + /// Withdrawal tombstones the cookie's row as well as the active one, + /// because a stateless deployment leaves [`ec_kv_key`](Self::ec_kv_key) + /// empty while a live row still exists, and the cookie is the only way + /// back to it. + /// + /// This does not reach across a provider switch. An identifier created + /// under a retired provider's `{code}~` prefix is owned by no provider + /// this deployment reads, so [`kv_key_for`](Self::kv_key_for) yields + /// `None` and its row is never tombstoned. Core cannot derive that key, + /// because the canonical form is the owning provider's own normalization. + /// The browser cookie is still expired, since that path keys off the raw + /// cookie rather than off ownership. See the provider-switching section + /// of the pluggable providers design spec for what an operator has to do + /// about it. + #[must_use] + pub(crate) fn cookie_ec_kv_key(&self) -> Option { + self.existing_cookie_ec_id() + .and_then(|value| self.kv_key_for(value)) + } + /// Returns whether the `ts-ec` cookie was present on the incoming request. #[must_use] pub fn cookie_was_present(&self) -> bool { @@ -348,7 +711,9 @@ impl EcContext { /// /// Allows handlers to apply query-param fallback consent for the current /// request only when pre-routing consent extraction produced an empty - /// context. + /// context. Mutations do not re-derive [`ec_allowed`](Self::ec_allowed) or + /// [`permissions`](Self::permissions), which are resolved once at + /// construction. pub fn consent_mut(&mut self) -> &mut ConsentContext { &mut self.consent } @@ -365,6 +730,14 @@ impl EcContext { self.device_signals = Some(signals); } + /// Returns the response headers a provider asked to set during + /// [`generate_if_needed`](Self::generate_if_needed). Empty unless a provider + /// produced any. + #[must_use] + pub fn response_headers(&self) -> &[(http::HeaderName, http::HeaderValue)] { + &self.response_headers + } + /// Returns the device signals, if set. #[must_use] pub fn device_signals(&self) -> Option<&DeviceSignals> { @@ -377,16 +750,75 @@ impl EcContext { self.client_ip.as_deref() } + /// Returns the host-computed client signals captured for this request, + /// when the host supplies them. + /// + /// The resolve path rebuilds the provider with the same injected services + /// as the organic path, so it reads the host signals captured here rather + /// than re-deriving them. + pub(crate) fn host_signals(&self) -> Option> { + self.host_signals.clone() + } + + /// Returns the adapter-injected Edge Cookie provider captured for this + /// request, or `None` for a built-in-only deployment. + pub(crate) fn ec_provider(&self) -> Option> { + self.ec_provider.clone() + } + /// Returns the pre-routing geo data, if available. #[must_use] pub fn geo_info(&self) -> Option<&GeoInfo> { self.geo_info.as_ref() } - /// Returns whether EC creation is permitted by consent for this request. + /// Returns whether the configured Edge Cookie provider's required + /// permissions are set for this request. + /// + /// Resolved once at construction through the permission model (see + /// [`consent::assemble_permissions`]). #[must_use] pub fn ec_allowed(&self) -> bool { - consent::ec_consent_granted(&self.consent) + self.ec_allowed + } + + /// Whether the request carries an explicit signal withdrawing Edge Cookie + /// storage, scoped to the jurisdiction's storage baseline. + /// + /// See [`consent::ec_storage_withdrawn`]: only a TCF record refusing + /// storage withdraws, and only where the storage baseline is not + /// `granted`. Suppression (the permission merely not set) is reported by + /// [`ec_allowed`](Self::ec_allowed) being `false` instead. + #[must_use] + pub fn storage_withdrawn(&self) -> bool { + consent::ec_storage_withdrawn(&self.consent, self.storage_acquisition) + } + + /// Whether the Edge Cookie identifier may be shared beyond the edge for + /// this request: into the bidstream as `user.id`, in a partner identify + /// response, or in a partner sync call. + /// + /// Sharing rides on the same two permissions as bidstream EIDs (see + /// [`crate::consent::gate_eids_by_permissions`]): storage (the identifier + /// exists and is readable) and personalised-ad selection (it is shared to + /// select ads). [`ec_allowed`](Self::ec_allowed) covers only the + /// provider's own requirements, so a storage-only grant keeps first-party + /// use while withholding partner sharing. + #[must_use] + pub fn ec_sharing_allowed(&self) -> bool { + self.ec_allowed() + && self.permissions.is_set(Permission::StoreOnDevice) + && self.permissions.is_set(Permission::SelectPersonalisedAds) + } + + /// Returns the permissions resolved for this request. + /// + /// Assembled once at construction, the country/region baseline augmented by + /// the session's signals. The core gates provider execution on these, and a + /// consumer may read them for its own logic. + #[must_use] + pub fn permissions(&self) -> &PermissionState { + &self.permissions } /// Returns the existing EC cookie value for revocation handling. @@ -399,35 +831,78 @@ impl EcContext { self.cookie_ec_value.as_deref() } - /// Returns `true` when the request carried a cookie EC and the selected - /// active EC differs from that cookie value. - #[must_use] - pub fn cookie_differs_from_active_ec(&self) -> bool { - matches!( - (self.cookie_ec_value.as_deref(), self.ec_value.as_deref()), - (Some(cookie), Some(active)) if cookie != active - ) - } - /// Returns the stable EC hash prefix from the active EC value. #[must_use] pub fn ec_hash(&self) -> Option<&str> { self.ec_value.as_deref().map(generation::ec_hash) } - /// Creates a test-only `EcContext` with explicit field values. + /// Attaches a selected provider to a test-only [`EcContext`]. + /// + /// The production constructor builds the provider from settings and + /// injected services. A test that only needs the provider's identifier + /// semantics (which identifiers it owns, and their canonical key form) + /// takes this shortcut instead. + #[cfg(test)] + #[must_use] + pub fn with_provider_for_test( + mut self, + provider: Arc, + ) -> Self { + self.selected_provider = Some(provider); + self + } + + /// Creates a test-only `EcContext` with the permission gate open. + /// + /// Use [`new_for_test_gated`](Self::new_for_test_gated) when a test needs + /// the gate closed. #[cfg(test)] #[must_use] pub fn new_for_test(ec_value: Option, consent: ConsentContext) -> Self { + Self::new_for_test_gated(ec_value, consent, true) + } + + /// Creates a test-only `EcContext` with an explicit permission gate. + /// + /// `ec_allowed` stands in for the permission decision the production path + /// resolves at construction, so a test can exercise the gate-open and + /// gate-closed branches directly. + #[cfg(test)] + #[must_use] + pub fn new_for_test_gated( + ec_value: Option, + consent: ConsentContext, + ec_allowed: bool, + ) -> Self { + let permissions = if ec_allowed { + PermissionState::new( + [Permission::StoreOnDevice, Permission::SelectPersonalisedAds] + .into_iter() + .collect(), + ) + } else { + PermissionState::default() + }; Self { ec_was_present: ec_value.is_some(), cookie_ec_value: ec_value.clone(), ec_value, ec_generated: false, consent, + ec_allowed, + permissions, + storage_acquisition: Acquisition::default(), client_ip: None, geo_info: None, device_signals: None, + host_signals: None, + ec_provider: None, + selected_provider: None, + request_headers: http::HeaderMap::new(), + request_path: String::new(), + request_query: String::new(), + response_headers: Vec::new(), } } @@ -445,9 +920,19 @@ impl EcContext { ec_value, ec_generated: false, consent, + ec_allowed: true, + storage_acquisition: Acquisition::default(), + permissions: PermissionState::default(), client_ip, geo_info: None, device_signals: None, + host_signals: None, + ec_provider: None, + selected_provider: None, + request_headers: http::HeaderMap::new(), + request_path: String::new(), + request_query: String::new(), + response_headers: Vec::new(), } } @@ -461,6 +946,7 @@ impl EcContext { ec_was_present: bool, ec_generated: bool, consent: ConsentContext, + ec_allowed: bool, ) -> Self { Self { ec_value, @@ -468,9 +954,19 @@ impl EcContext { ec_was_present, ec_generated, consent, + ec_allowed, + permissions: PermissionState::default(), + storage_acquisition: Acquisition::default(), client_ip: None, geo_info: None, device_signals: None, + host_signals: None, + ec_provider: None, + selected_provider: None, + request_headers: http::HeaderMap::new(), + request_path: String::new(), + request_query: String::new(), + response_headers: Vec::new(), } } } @@ -494,6 +990,8 @@ pub(crate) fn current_timestamp() -> u64 { #[cfg(test)] mod tests { use super::*; + use crate::ec::provider::{EcProviderSelection, ProviderCode}; + use crate::evidence::{OwnedRequestInfo, RequestInfo}; use crate::platform::test_support::noop_services; use crate::test_support::tests::create_test_settings; @@ -512,6 +1010,1071 @@ mod tests { format!("{}.{suffix}", prefix_char.repeat(64)) } + /// A provider that records the `Cookie` header from the request info passed + /// to `generate`, so a test can prove request cookies reach a provider (a + /// client that stores values in cookies relies on this). + #[derive(Debug)] + struct CookieCapturingProvider { + seen_cookie: std::sync::Mutex>, + } + + #[async_trait::async_trait(?Send)] + impl EdgeCookieProvider for CookieCapturingProvider { + fn id(&self) -> &'static str { + "cookie-capturing" + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0cc") + } + + async fn generate( + &self, + request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, + ) -> Result> { + let cookie = request_info.header("cookie").map(ToOwned::to_owned); + *self.seen_cookie.lock().expect("should lock seen cookie") = cookie; + Ok(GeneratedEdgeCookie::default()) + } + } + + #[tokio::test] + async fn a_provider_reads_request_cookies_from_the_request_info() { + // RequestInfo contract: a provider given request info that carries + // headers can read request cookies through it (a client that stores + // values in cookies relies on this). The organic generate path passes a + // snapshot of the request headers through generate_with_provider, so a + // provider reads request cookies through it there too; this test + // supplies its own headers directly. + let mut headers = http::HeaderMap::new(); + headers.insert( + "cookie", + "client-id=abc123; ts-ec=xyz" + .parse() + .expect("should build a valid cookie header"), + ); + let request_info = OwnedRequestInfo::new("203.0.113.7".to_owned(), headers); + let provider = CookieCapturingProvider { + seen_cookie: std::sync::Mutex::new(None), + }; + + provider + .generate( + &request_info, + &IdentityInput::default(), + &crate::platform::test_support::noop_services(), + ) + .await + .expect("generation should succeed"); + + assert_eq!( + provider + .seen_cookie + .lock() + .expect("should lock seen cookie") + .as_deref(), + Some("client-id=abc123; ts-ec=xyz"), + "the provider should read the request cookies from the request info" + ); + } + + /// A provider whose identifiers are opaque and deliberately not the + /// built-in HMAC shape (no dot, mixed case), modeling a vendor identifier + /// such as a signed envelope. It accepts any of its own non-empty + /// identifiers. + #[derive(Debug)] + struct OpaqueIdProvider; + + #[async_trait::async_trait(?Send)] + impl EdgeCookieProvider for OpaqueIdProvider { + fn id(&self) -> &'static str { + "opaque" + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0op") + } + + async fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + } + + /// A geo that resolves to the non-regulated jurisdiction (US, no region), + /// so the permission gate is open and generation runs. + /// + /// A test that drives a built-in provider needs this. The test default + /// country is FR, whose baseline requires a signal before + /// `StoreOnDevice` is set, so the built-in providers are gated off + /// without one. A test double declaring no required permission runs + /// either way and can read the request without a location. + fn non_regulated_geo() -> GeoInfo { + GeoInfo { + city: String::new(), + country: "US".to_owned(), + continent: "NorthAmerica".to_owned(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: None, + asn: None, + } + } + + #[test] + fn read_from_request_reuses_the_provider_the_composition_root_resolved() { + // Reading EC state runs on every request, and it used to resolve + // `[ec] provider` itself even though the composition root had just + // resolved the same settings, so the provider was built twice per + // request. An adapter now threads the resolved provider through + // `RuntimeServices`, and the context has to take that instance. + let mut settings = create_test_settings(); + settings.ec.provider = Some(EcProviderSelection::from("opaque")); + + let ec_config = settings.ec.clone(); + let resolved = crate::ec::provider::build_reusable_provider( + &ec_config, + None, + Some(Arc::new(OpaqueIdProvider)), + ) + .expect("the composition root should resolve the selection") + .expect("the selection should yield a provider"); + + let services = crate::platform::test_support::noop_services_with_resolved_ec_provider( + Arc::clone(&resolved), + ); + let req = create_test_request(&[]); + let ec = EcContext::read_from_request(&settings, &req, &services) + .expect("should read EC context"); + + let used = ec + .selected_provider + .as_ref() + .expect("the context should hold the selected provider"); + assert!( + Arc::ptr_eq(used, &resolved), + "reading EC state should reuse the provider resolved at startup rather \ + than building a second one for this request" + ); + } + + #[test] + fn read_from_request_round_trips_an_opaque_provider_identifier() { + use crate::platform::test_support::noop_services_with_ec_provider; + + // A vendor identifier that is deliberately not the built-in HMAC shape + // (no dot, mixed case), the exact value the built-in check would drop. + const OPAQUE_ID: &str = "AbC123opaqueEnvelopeValueXYZ"; + const CODED_ID: &str = "t0op~AbC123opaqueEnvelopeValueXYZ"; + + let mut settings = create_test_settings(); + settings.ec.provider = Some(EcProviderSelection::from("opaque")); + let cookie = format!("ts-ec={CODED_ID}"); + let req = create_test_request(&[("cookie", &cookie)]); + + // With the opaque provider injected, its `accepts_id` governs read-back, + // so the identifier survives verbatim. + let services = noop_services_with_ec_provider(Arc::new(OpaqueIdProvider)); + let ec = EcContext::read_from_request(&settings, &req, &services) + .expect("should read EC context"); + assert_eq!( + ec.ec_value(), + Some(CODED_ID), + "an opaque provider identifier should round-trip through read-back verbatim" + ); + let _ = OPAQUE_ID; + + // Control: with the provider selected but not injected by the adapter, + // the request fails loudly instead of silently running stateless with + // the identifier dropped. + let err = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect_err("a selected but uninjected provider should fail the request"); + assert!( + err.to_string().contains("opaque"), + "the error should name the selected provider, got: {err}" + ); + + // Control: with no provider selected at all, the identifier is treated + // as absent, so a stateless deployment never uses or egresses it. + let mut stateless = create_test_settings(); + stateless.ec.provider = None; + stateless.ec.providers.hmac = None; + let ec_without = EcContext::read_from_request(&stateless, &req, &noop_services()) + .expect("should read EC context"); + assert_eq!( + ec_without.ec_value(), + None, + "with no provider selected, an existing identifier is treated as absent" + ); + assert!( + !ec_without.ec_allowed(), + "with no provider selected, the gate stays closed" + ); + } + + /// A provider that records the request query parameter `id` and the `Cookie` + /// header it is given at generate time, proving request evidence (parameters + /// and cookies) reaches a provider through the organic generate path. + #[derive(Debug, Default)] + struct EvidenceCapturingProvider { + seen: std::sync::Mutex>, + seen_client_ip: std::sync::Mutex>, + } + + #[async_trait::async_trait(?Send)] + impl EdgeCookieProvider for EvidenceCapturingProvider { + fn id(&self) -> &'static str { + "evidence" + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0ev") + } + + async fn generate( + &self, + request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, + ) -> Result> { + let query_id = request_info.query_param("id").unwrap_or_default(); + let cookie = request_info.header("cookie").unwrap_or_default().to_owned(); + *self.seen.lock().expect("should lock seen evidence") = Some((query_id, cookie)); + *self + .seen_client_ip + .lock() + .expect("should lock the seen client IP") = + Some(request_info.client_ip().to_owned()); + Ok(GeneratedEdgeCookie { + id: Some("evidence-ec".to_owned()), + response_headers: Vec::new(), + }) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + } + + #[tokio::test] + async fn generate_passes_request_parameters_and_cookies_to_the_provider() { + use crate::platform::test_support::noop_services_with_ec_provider; + + let provider = Arc::new(EvidenceCapturingProvider::default()); + let mut settings = create_test_settings(); + settings.ec.provider = Some(EcProviderSelection::from("evidence")); + + // A request carrying a query parameter and a (non-EC) cookie, with no + // existing `ts-ec` cookie so the generate path runs. + let req = Request::builder() + .method("GET") + .uri("http://example.com/page?id=abc123&debug=1") + .header("cookie", "client-id=xyz789") + .body(EdgeBody::empty()) + .expect("should build request"); + + let services = noop_services_with_ec_provider(provider.clone()); + let geo = non_regulated_geo(); + let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("should read EC context"); + ec.generate_if_needed(&settings, None, &services) + .await + .expect("should run generation"); + + let seen = provider + .seen + .lock() + .expect("should lock seen evidence") + .clone(); + assert_eq!( + seen, + Some(("abc123".to_owned(), "client-id=xyz789".to_owned())), + "the provider should read the request query parameter and cookies at generate time" + ); + assert_eq!( + ec.ec_value(), + Some("t0ev~evidence-ec"), + "the identifier the provider created should be committed under its code" + ); + } + + /// A provider that creates an opaque, mixed-case, non-HMAC identifier at + /// the edge, so a test can prove such an identifier persists to the KV identity + /// graph under its own value as the key. + #[derive(Debug)] + struct ServerOpaqueProvider; + + #[async_trait::async_trait(?Send)] + impl EdgeCookieProvider for ServerOpaqueProvider { + fn id(&self) -> &'static str { + "server-opaque" + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0so") + } + + async fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, + ) -> Result> { + Ok(GeneratedEdgeCookie { + id: Some("Opaque_EC_Value_MixedCase_123".to_owned()), + response_headers: Vec::new(), + }) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + value.to_owned() + } + } + + #[tokio::test] + async fn generate_persists_an_opaque_identifier_to_kv_under_its_own_key() { + use crate::platform::test_support::noop_services_with_ec_provider; + + const OPAQUE: &str = "t0so~Opaque_EC_Value_MixedCase_123"; + + let mut settings = create_test_settings(); + settings.ec.provider = Some(EcProviderSelection::from("server-opaque")); + let services = noop_services_with_ec_provider(Arc::new(ServerOpaqueProvider)); + let graph = KvIdentityGraph::in_memory("test-ec-store"); + + // No existing cookie, so the edge creates one and persists it. + let req = create_test_request(&[]); + let mut ec = EcContext::read_from_request(&settings, &req, &services) + .expect("should read EC context"); + ec.generate_if_needed(&settings, Some(&graph), &services) + .await + .expect("should generate and persist"); + + assert_eq!( + ec.ec_value(), + Some(OPAQUE), + "the opaque identifier should be created" + ); + + // The entry is stored under the full identifier verbatim. + assert!( + graph.get(OPAQUE).expect("kv get should succeed").is_some(), + "the entry should exist under the opaque identifier key" + ); + + // A lowercased key must miss, proving the key preserves case rather than + // being lowercased like the built-in HMAC form (the clash this guards). + assert!( + graph + .get(&OPAQUE.to_lowercase()) + .expect("kv get should succeed") + .is_none(), + "the KV key must be case-sensitive and verbatim, not lowercased" + ); + } + + /// A geo provider that resolves the country from the config store it is + /// handed, the geo counterpart of [`ConfigReadingProvider`]. + #[derive(Debug)] + struct ConfigReadingGeo; + + #[async_trait::async_trait(?Send)] + impl crate::platform::PlatformGeo for ConfigReadingGeo { + async fn lookup( + &self, + _client_ip: Option, + services: &crate::platform::RuntimeServices, + ) -> Result, Report> { + let country = services + .config_store() + .get(&crate::platform::StoreName::from("vendor_store"), "country")?; + Ok(Some(GeoInfo { + country, + ..non_regulated_geo() + })) + } + } + + #[tokio::test] + async fn a_geo_provider_reads_a_platform_service_through_the_services_it_is_given() { + use crate::platform::test_support::FixedConfigStore; + + // The geo half of the seam acceptance test. Geo runs ahead of the + // permission model and feeds it the country, so a vendor geo provider + // that resolves location from a backend or a store needs the platform + // services exactly as an Edge Cookie provider does. `DE` can only + // appear here if the provider read it through the services this call + // supplied. + let settings = create_test_settings(); + let req = create_test_request(&[]); + let services = RuntimeServices::builder() + .config_store(Arc::new(FixedConfigStore { + store: "vendor_store", + key: "country", + value: "DE", + })) + .secret_store(Arc::new(crate::platform::test_support::NoopSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(Arc::new(crate::platform::test_support::NoopBackend)) + .http_client(Arc::new(crate::platform::test_support::NoopHttpClient)) + .geo(Arc::new(ConfigReadingGeo)) + .client_info(crate::platform::ClientInfo::default()) + .build(); + + let ec = EcContext::read_from_request_resolving_geo(&settings, &req, &services) + .await + .expect("should read EC context"); + + assert_eq!( + ec.geo_info().map(|info| info.country.as_str()), + Some("DE"), + "the resolved country must come from the value the geo provider read through the services it was handed" + ); + } + + /// A provider that derives its identifier from a value it reads out of the + /// config store at generate time, which is the whole point of handing providers + /// the platform services. Nothing about the value is known when the + /// provider is constructed, so an identifier carrying it can only come from + /// a real read through the services passed to `generate`. + #[derive(Debug)] + struct ConfigReadingProvider; + + #[async_trait::async_trait(?Send)] + impl EdgeCookieProvider for ConfigReadingProvider { + fn id(&self) -> &'static str { + "config-reading" + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0cr") + } + + async fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + services: &crate::platform::RuntimeServices, + ) -> Result> { + let tenant = services + .config_store() + .get(&crate::platform::StoreName::from("vendor_store"), "tenant") + .map_err(|error| { + error.change_context(TrustedServerError::EdgeCookie { + message: "config-reading provider could not read its tenant".to_owned(), + }) + })?; + Ok(GeneratedEdgeCookie { + id: Some(format!("tenant-{tenant}")), + response_headers: Vec::new(), + }) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + } + + #[tokio::test] + async fn a_provider_reads_a_platform_service_through_the_services_it_is_given() { + use crate::platform::test_support::{ + FixedConfigStore, services_with_ec_provider_and_config_store, + }; + + // The acceptance test for the asynchronous provider seam. Before + // providers were handed the platform services, a provider could not + // reach a config store, a key-value store, a secret or a backend at + // all, which made every real vendor provider impossible to write. This + // proves the services that arrive at `generate` are the caller's real + // ones: the identifier can only carry `acme` if the provider actually + // read it out of the store on this request, because nothing gives the + // provider that value at construction time. + let provider = Arc::new(ConfigReadingProvider); + let mut settings = create_test_settings(); + settings.ec.provider = Some(EcProviderSelection::from("config-reading")); + let req = create_test_request(&[]); + let geo = non_regulated_geo(); + + let services = services_with_ec_provider_and_config_store( + provider.clone(), + Arc::new(FixedConfigStore { + store: "vendor_store", + key: "tenant", + value: "acme", + }), + ); + let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("should read EC context"); + + ec.generate_if_needed(&settings, None, &services) + .await + .expect("the provider should create an identifier from the config value it read"); + + assert_eq!( + ec.ec_value(), + Some("t0cr~tenant-acme"), + "the identifier must carry the value the provider read through the services it was handed, which proves the seam delivers them" + ); + } + + #[tokio::test] + async fn a_provider_that_reads_no_client_ip_mints_when_the_host_has_none() { + use crate::platform::test_support::noop_services_with_ec_provider_without_client_ip; + + // The requirement for a client IP belongs to the provider that uses + // one, not to core. A provider deriving identity from the request + // query and cookies runs on a host that cannot determine a client IP, + // and still creates an identifier. This also pins what the provider is + // handed in that case, which is the documented unavailable value rather + // than something else, because a provider cannot decide how to behave + // without knowing what absence looks like. + let provider = Arc::new(EvidenceCapturingProvider::default()); + let mut settings = create_test_settings(); + settings.ec.provider = Some(EcProviderSelection::from("evidence")); + let req = Request::builder() + .method("GET") + .uri("http://example.com/page?id=abc123") + .header("cookie", "client-id=xyz789") + .body(EdgeBody::empty()) + .expect("should build request"); + + let services = noop_services_with_ec_provider_without_client_ip(provider.clone()); + let mut ec = EcContext::read_from_request(&settings, &req, &services) + .expect("should read EC context"); + assert_eq!( + ec.client_ip(), + None, + "the host should supply no client IP in this test" + ); + + ec.generate_if_needed(&settings, None, &services) + .await + .expect("a provider that reads no client IP should still create an identifier"); + assert_eq!( + ec.ec_value(), + Some("t0ev~evidence-ec"), + "the identifier should be committed with no client IP available" + ); + assert_eq!( + provider + .seen_client_ip + .lock() + .expect("should lock the seen client IP") + .clone(), + Some(String::new()), + "a host that cannot determine a client IP should hand the provider the documented unavailable value, which is the empty string" + ); + } + + #[tokio::test] + async fn the_hmac_provider_refuses_when_the_host_has_no_client_ip() { + // The other half: the built-in provider's only input is the client IP, + // so with none it fails rather than hashing the empty string into an + // identifier every visitor on that host would share. Identity cannot be + // established, so generate_if_needed returns the error, which the + // publisher and integration proxies log before serving the response + // without an Edge Cookie. + let settings = create_test_settings(); + let req = create_test_request(&[]); + let geo = non_regulated_geo(); + let mut ec = + EcContext::read_from_request_with_geo(&settings, &req, &noop_services(), Some(&geo)) + .expect("should read EC context"); + assert_eq!( + ec.client_ip(), + None, + "the host should supply no client IP in this test" + ); + + let err = ec + .generate_if_needed(&settings, None, &noop_services()) + .await + .expect_err("the HMAC provider should refuse without a client IP"); + assert!( + err.to_string().contains("client IP"), + "the error should name the missing client IP, got: {err}" + ); + assert_eq!( + ec.ec_value(), + None, + "no identifier should be committed when the provider refuses" + ); + } + + /// A provider that creates an identifier outside the cookie-safe alphabet, + /// to prove core rejects it at creation rather than rewriting it. + #[derive(Debug)] + struct IllegalIdProvider; + + #[async_trait::async_trait(?Send)] + impl EdgeCookieProvider for IllegalIdProvider { + fn id(&self) -> &'static str { + "illegal" + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0il") + } + + async fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, + ) -> Result> { + Ok(GeneratedEdgeCookie { + id: Some("bad;value with spaces".to_owned()), + response_headers: Vec::new(), + }) + } + + fn accepts_id(&self, _value: &str) -> bool { + true + } + } + + #[tokio::test] + async fn generate_rejects_an_identifier_outside_the_cookie_safe_alphabet() { + use crate::platform::test_support::noop_services_with_ec_provider; + + let mut settings = create_test_settings(); + settings.ec.provider = Some(EcProviderSelection::from("illegal")); + let services = noop_services_with_ec_provider(Arc::new(IllegalIdProvider)); + let req = create_test_request(&[]); + let mut ec = EcContext::read_from_request(&settings, &req, &services) + .expect("should read EC context"); + + let err = ec + .generate_if_needed(&settings, None, &services) + .await + .expect_err("an identifier outside the alphabet should be rejected at creation"); + assert!( + err.to_string().contains("illegal"), + "the error should name the provider, got: {err}" + ); + assert_eq!( + ec.ec_value(), + None, + "no identifier should be committed after a creation rejection" + ); + } + + /// A provider that returns a caller-chosen response header and no + /// identifier, so a test can drive one provider response effect at a time + /// through the organic generate path. + #[derive(Debug)] + struct HeaderSettingProvider { + name: &'static str, + value: &'static str, + mint: bool, + } + + #[async_trait::async_trait(?Send)] + impl EdgeCookieProvider for HeaderSettingProvider { + fn id(&self) -> &'static str { + "header-setting" + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0hs") + } + + async fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, + ) -> Result> { + Ok(GeneratedEdgeCookie { + id: self.mint.then(|| "provider-value".to_owned()), + response_headers: vec![( + http::HeaderName::from_bytes(self.name.as_bytes()) + .expect("should parse header name"), + http::HeaderValue::from_static(self.value), + )], + }) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + value.to_owned() + } + } + + async fn generate_with_header_setting_provider( + provider: HeaderSettingProvider, + graph: Option<&KvIdentityGraph>, + ) -> (Settings, Result>) { + use crate::platform::test_support::noop_services_with_ec_provider; + + let mut settings = create_test_settings(); + settings.ec.provider = Some(EcProviderSelection::from("header-setting")); + let services = noop_services_with_ec_provider(Arc::new(provider)); + let req = create_test_request(&[]); + let mut ec = EcContext::read_from_request(&settings, &req, &services) + .expect("should read EC context"); + let outcome = ec + .generate_if_needed(&settings, graph, &services) + .await + .map(|()| ec); + (settings, outcome) + } + + #[tokio::test] + async fn generate_rejects_a_provider_effect_inside_the_reserved_response_surface() { + // A provider that sets the managed `ts-ec` cookie would bypass core's + // identifier validation and its identity-graph row entirely, so the + // request fails rather than the effect being quietly dropped. The + // provider creates no identifier here, which is exactly the case the + // cookie write would otherwise slip through. + let (_settings, outcome) = generate_with_header_setting_provider( + HeaderSettingProvider { + name: "set-cookie", + value: "ts-ec=forged-value; Path=/", + mint: false, + }, + None, + ) + .await; + + let err = outcome.expect_err("a managed cookie effect should fail the request"); + assert!( + err.to_string().contains("header-setting"), + "the error should name the provider, got: {err}" + ); + + // The same for the reserved header namespace and for message framing. + for (name, value) in [("x-ts-ec", "forged"), ("transfer-encoding", "chunked")] { + let (_settings, outcome) = generate_with_header_setting_provider( + HeaderSettingProvider { + name, + value, + mint: false, + }, + None, + ) + .await; + assert!( + outcome.is_err(), + "`{name}` is reserved and should fail the request" + ); + } + } + + #[tokio::test] + async fn generate_applies_a_provider_owned_cookie_to_the_response() { + // The other half of the rule: a provider's own cookie is not core's, so + // it survives generation and reaches the browser response unchanged, + // alongside the managed `ts-ec` cookie core writes itself. + let graph = KvIdentityGraph::in_memory("test-ec-store"); + let (settings, outcome) = generate_with_header_setting_provider( + HeaderSettingProvider { + name: "set-cookie", + value: "acme-evidence=abc123; Path=/; Secure", + mint: true, + }, + Some(&graph), + ) + .await; + let ec = outcome.expect("a provider-owned cookie should not fail the request"); + assert_eq!( + ec.ec_value(), + Some("t0hs~provider-value"), + "the identifier should still be committed" + ); + + let mut response = http::Response::builder() + .status(200) + .body(EdgeBody::empty()) + .expect("should build test response"); + finalize::ec_finalize_response( + &settings, + &ec, + Some(&graph), + ®istry::PartnerRegistry::empty(), + None, + None, + &mut response, + ); + + let cookies: Vec<&str> = response + .headers() + .get_all(http::header::SET_COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .collect(); + assert!( + cookies + .iter() + .any(|cookie| cookie.starts_with("acme-evidence=abc123")), + "the provider's own cookie should reach the response, got: {cookies:?}" + ); + assert!( + cookies.iter().any(|cookie| cookie.starts_with("ts-ec=")), + "core's own managed cookie should still be written, got: {cookies:?}" + ); + } + + /// A provider whose identifier normalizes to a distinct canonical form, to + /// prove the identity graph is keyed by the canonical form. + /// + /// Shared with the identify and finalization tests, which need a provider + /// whose canonical key is not the value the browser carries. + #[derive(Debug)] + pub(crate) struct CanonicalizingProvider; + + #[async_trait::async_trait(?Send)] + impl EdgeCookieProvider for CanonicalizingProvider { + fn id(&self) -> &'static str { + "canonical" + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0ca") + } + + async fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, + ) -> Result> { + Ok(GeneratedEdgeCookie { + id: Some("MiXeD.CaseId".to_owned()), + response_headers: Vec::new(), + }) + } + + fn accepts_id(&self, _value: &str) -> bool { + true + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + value.to_ascii_lowercase() + } + } + + #[tokio::test] + async fn generate_keys_the_identity_graph_by_the_normalized_identifier() { + use crate::platform::test_support::noop_services_with_ec_provider; + + let mut settings = create_test_settings(); + settings.ec.provider = Some(EcProviderSelection::from("canonical")); + let services = noop_services_with_ec_provider(Arc::new(CanonicalizingProvider)); + let graph = KvIdentityGraph::in_memory("test-ec-store"); + let req = create_test_request(&[]); + let mut ec = EcContext::read_from_request(&settings, &req, &services) + .expect("should read EC context"); + ec.generate_if_needed(&settings, Some(&graph), &services) + .await + .expect("should generate and persist"); + + assert_eq!( + ec.ec_value(), + Some("t0ca~MiXeD.CaseId"), + "the cookie value keeps the provider's exact identifier under its code" + ); + assert!( + graph + .get("t0ca~mixed.caseid") + .expect("should read the graph") + .is_some(), + "the graph row should be keyed by the code plus the canonical form" + ); + // Pin the read-side derivation to the key generation actually wrote. + // Identify, the withdrawal tombstones, and EID ingestion all read the + // row through `ec_kv_key`, so the two must never drift apart. + assert_eq!( + ec.ec_kv_key().as_deref(), + Some("t0ca~mixed.caseid"), + "the read-side key should be the key generation wrote" + ); + } + + #[tokio::test] + async fn hmac_mints_a_coded_identifier_and_dual_reads_the_legacy_bare_form() { + let settings = create_test_settings(); + // Place the request in a US opt-out state, whose baseline grants the + // storage permission with no signal, so the creation runs. + let geo = us_opt_out_geo(); + let req = create_test_request(&[]); + let services = crate::platform::test_support::noop_services_with_client_ip( + std::net::IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 7)), + ); + let mut ec = EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("should read EC context"); + ec.generate_if_needed(&settings, None, &services) + .await + .expect("should generate"); + let created = ec.ec_value().expect("should create an identifier"); + assert!( + created.starts_with("hmac~"), + "a fresh HMAC identifier should carry the hmac code, got {created}" + ); + + // A deployed pre-envelope cookie (bare form) still reads back, so the + // migration does not orphan existing identities. + let legacy = format!("{}.ABC123", "a".repeat(64)); + let cookie = format!("ts-ec={legacy}"); + let req = create_test_request(&[("cookie", &cookie)]); + let ec = + EcContext::read_from_request_with_geo(&settings, &req, &noop_services(), Some(&geo)) + .expect("should read EC context"); + assert_eq!( + ec.ec_value(), + Some(legacy.as_str()), + "the legacy bare form should dual-read under the hmac provider" + ); + } + + #[test] + fn a_foreign_provider_code_is_treated_as_absent() { + // An identifier carrying another provider's code must never be adopted + // by the selected provider, so switching providers cannot silently mix + // identity populations. + let settings = create_test_settings(); + let foreign = format!("zz00~{}.ABC123", "a".repeat(64)); + let cookie = format!("ts-ec={foreign}"); + let req = create_test_request(&[("cookie", &cookie)]); + let ec = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect("should read EC context"); + assert_eq!( + ec.ec_value(), + None, + "an identifier with a foreign provider code is not this provider's" + ); + } + + /// A geo provider whose lookup fails, the state the permission model's + /// fail-closed rule exists for. + /// + /// No geo provider shipped in this workspace can fail: the Fastly SDK's + /// `geo_lookup` returns an `Option`, the Cloudflare provider reads request + /// headers, and the Axum and Spin providers resolve nothing at all. The + /// `Result` on [`PlatformGeo::lookup`] is there for a provider that does + /// its own fallible lookup, so this stands in for one and proves the floor + /// is reached through the seam rather than only from a hand-built status. + #[derive(Debug)] + struct FailingGeo; + + #[async_trait::async_trait(?Send)] + impl crate::platform::PlatformGeo for FailingGeo { + async fn lookup( + &self, + _client_ip: Option, + _services: &crate::platform::RuntimeServices, + ) -> Result, Report> { + Err(Report::new(crate::platform::PlatformError::Geo)) + } + } + + /// A location in a US opt-out state, whose group grants every modeled + /// purpose without a signal. Used where a test needs a granted baseline. + fn us_opt_out_geo() -> GeoInfo { + GeoInfo { + city: String::new(), + country: "US".to_owned(), + continent: String::new(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: Some("CA".to_owned()), + asn: None, + } + } + + #[tokio::test] + async fn a_geo_provider_failure_resolves_permissions_at_the_requires_signal_floor() { + use crate::permissions::Permission; + use crate::platform::test_support::build_services_with_geo; + + // A located request in a granted-baseline state, so the assertion can + // only pass by the failure reaching the floor rather than a tree node. + let settings = create_test_settings(); + let req = create_test_request(&[]); + let geo = us_opt_out_geo(); + + let granted = + EcContext::read_from_request_with_geo(&settings, &req, &noop_services(), Some(&geo)) + .expect("should read EC context for a located request"); + assert!( + granted.permissions().is_set(Permission::StoreOnDevice), + "the located baseline must grant storage, or this test proves nothing" + ); + + let services = build_services_with_geo(std::sync::Arc::new(FailingGeo)); + let failed = EcContext::read_from_request_resolving_geo(&settings, &req, &services) + .await + .expect("a failed lookup should resolve permissions, not fail the request"); + assert!( + !failed.permissions().is_set(Permission::StoreOnDevice), + "a geo provider failure must resolve at the requires-signal floor" + ); + assert_eq!( + failed.consent().jurisdiction, + crate::consent::jurisdiction::Jurisdiction::Unknown, + "a failed lookup must not adopt the policy's declared jurisdiction" + ); + } + + #[test] + fn the_resolved_place_decides_the_consent_jurisdiction() { + use crate::consent::jurisdiction::Jurisdiction; + + let settings = create_test_settings(); + let req = create_test_request(&[]); + + // No location at all: the policy's top node answers. + let unplaced = EcContext::read_from_request(&settings, &req, &noop_services()) + .expect("should read EC context"); + assert_eq!( + unplaced.consent().jurisdiction, + Jurisdiction::Gdpr, + "with no place the top node's jurisdiction should apply" + ); + + // A listed US state names itself as the state. + let geo = us_opt_out_geo(); + let located = + EcContext::read_from_request_with_geo(&settings, &req, &noop_services(), Some(&geo)) + .expect("should read EC context"); + assert_eq!( + located.consent().jurisdiction, + Jurisdiction::UsState("CA".to_owned()), + "a listed US state should resolve its own state jurisdiction" + ); + } + + #[test] + fn sharing_requires_the_personalised_ads_permission_not_just_storage() { + let mut ec = + EcContext::new_for_test(Some(valid_ec_id("a", "ABC123")), ConsentContext::default()); + ec.permissions = PermissionState::new([Permission::StoreOnDevice].into_iter().collect()); + assert!(ec.ec_allowed(), "the provider gate is open"); + assert!( + !ec.ec_sharing_allowed(), + "storage alone must not allow sharing beyond the edge" + ); + } + #[test] fn read_from_request_ignores_header_ec() { let settings = create_test_settings(); @@ -615,8 +2178,8 @@ mod tests { ); } - #[test] - fn generate_if_needed_skips_when_ec_exists() { + #[tokio::test] + async fn generate_if_needed_skips_when_ec_exists() { let settings = create_test_settings(); let ec_id = valid_ec_id("d", "Exist1"); let cookie = format!("ts-ec={ec_id}"); @@ -624,7 +2187,8 @@ mod tests { let mut ec = EcContext::read_from_request(&settings, &req, &noop_services()) .expect("should read EC context"); - ec.generate_if_needed(&settings, None) + ec.generate_if_needed(&settings, None, &noop_services()) + .await .expect("should not error when EC already exists"); assert_eq!( diff --git a/crates/trusted-server-core/src/ec/provider.rs b/crates/trusted-server-core/src/ec/provider.rs new file mode 100644 index 000000000..b5a2d0e47 --- /dev/null +++ b/crates/trusted-server-core/src/ec/provider.rs @@ -0,0 +1,2525 @@ +//! Edge Cookie identity providers. +//! +//! An [`EdgeCookieProvider`] derives an Edge Cookie identifier. The provider is +//! selected by configuration, with no default, and [`build_provider`] is the +//! composition root that builds the selected one. A built-in provider is +//! constructed from its `[ec.providers.]` block, and a vendor provider is +//! taken from the adapter that injected it. A built-in provider that also needs +//! a host service, as the host-signal provider needs the [`HostSignals`] +//! service, is built only on a host that supplies that service. +//! Construction reads configuration and long-lived services, so a selection +//! this deployment cannot satisfy fails at startup rather than leaving it +//! running without an identity. The host-signal provider is the exception: it +//! is built per request from that request's TLS and HTTP/2 signals (see +//! [`is_request_scoped`](EdgeCookieProvider::is_request_scoped)). +//! +//! Request evidence reaches a provider at call time rather than at +//! construction. [`EdgeCookieProvider::generate`] borrows a [`RequestInfo`], +//! which carries the normalized client IP, the User-Agent and the request +//! headers, for the life of the call, alongside an [`IdentityInput`] holding +//! the request's gating context. A provider reads what it needs and retains +//! nothing. Core snapshots the headers, path and query it lends to the provider +//! at generate time, and the provider itself keeps none of it. +//! +//! [`HmacProvider`] is the built-in server-side implementation. It derives the +//! identifier from the client IP using HMAC over the configured passphrase, the +//! behavior Trusted Server has always shipped. + +use std::sync::Arc; + +use error_stack::Report; +use serde::{Deserialize, Serialize}; + +use crate::consent::ConsentContext; +use crate::error::TrustedServerError; +use crate::evidence::{HostSignals, RequestInfo}; +use crate::permissions::{Permission, PermissionSet, PermissionState}; +use crate::redacted::Redacted; +use crate::settings::Ec; + +use super::cookies::ec_id_has_only_allowed_chars; +use super::generation; + +/// The Edge Cookie identity provider a deployment has selected. +/// +/// Deserialized from the `[ec] provider` string, and serialized back to the +/// same string, so the configuration surface is unchanged. Provider names are +/// open-ended (a vendor crate names its own), so every name other than the +/// explicit `"none"` becomes [`Named`](Self::Named) rather than a parse +/// failure, and whether the deployment can actually supply that provider is +/// decided by [`build_provider`]. +/// +/// No individual provider has a variant of its own, the one still built into +/// core included. Every provider is selected the same way, by name, so no +/// caller can be written around one provider being different, and moving the +/// built-in provider out into its own module changes nothing here. +/// +/// This is the one place the selector is spelled. Everything that needs to ask +/// which provider is selected matches on this rather than comparing string +/// literals. +#[derive(Debug, Clone, Eq, Hash, PartialEq, Deserialize, Serialize)] +#[serde(from = "String", into = "String")] +pub enum EcProviderSelection { + /// Explicit statelessness, spelled `"none"`. The same meaning as omitting + /// the selector: no Edge Cookie is created and no provider block may be + /// configured. + None, + + /// A provider selected by name, configured by the matching + /// `[ec.providers.]` block. [`build_provider`] resolves the name to + /// an implementation, whether that implementation is built into core or + /// injected by the adapter. + Named(String), +} + +impl EcProviderSelection { + /// The configuration spelling of explicit statelessness. + pub const NONE_KEY: &'static str = "none"; + + /// The configuration key this selection is written as. + #[must_use] + pub fn key(&self) -> &str { + match self { + Self::None => Self::NONE_KEY, + Self::Named(key) => key, + } + } +} + +impl From<&str> for EcProviderSelection { + fn from(key: &str) -> Self { + match key { + EcProviderSelection::NONE_KEY => Self::None, + other => Self::Named(other.to_owned()), + } + } +} + +impl From for EcProviderSelection { + fn from(key: String) -> Self { + match key.as_str() { + EcProviderSelection::NONE_KEY => Self::None, + _ => Self::Named(key), + } + } +} + +impl From for String { + fn from(selection: EcProviderSelection) -> Self { + match selection { + EcProviderSelection::None => EcProviderSelection::NONE_KEY.to_owned(), + EcProviderSelection::Named(key) => key, + } + } +} + +/// The configuration name of the HMAC provider still built into core. +/// +/// The name lives in the same open-ended namespace every vendor provider name +/// comes from, and nothing branches on it outside the resolution in +/// [`build_provider`]. It is also [`HmacProvider::id`]'s return value and +/// [`HMAC_PROVIDER_CODE`]'s text. It goes with that resolution arm when the +/// built-in provider becomes a module of its own. +pub const HMAC_PROVIDER_KEY: &str = "hmac"; + +/// The configuration name of the host-signal provider still built into core. +/// +/// An ordinary name in the same open-ended namespace as [`HMAC_PROVIDER_KEY`], +/// spelled exactly the way a vendor crate spells its own, and nothing branches +/// on it outside the resolution in [`build_provider`]. It is also +/// [`HostSignalProvider::id`]'s return value, and it goes with that resolution +/// arm when the host-signal provider becomes a module of its own. +pub const HOST_SIGNALS_PROVIDER_KEY: &str = "host-signals"; + +/// The configuration name of the client-fixed demonstration provider. +/// +/// An ordinary name in the same open-ended namespace as [`HMAC_PROVIDER_KEY`]. +/// The resolution in [`build_provider`] matches it, as does its startup +/// counterpart `check_named_provider_configuration`, and the integration +/// registry adds the client-cycle page-script module when the name is +/// selected. It is also `ClientFixedProvider`'s `id`, and it goes with that +/// resolution arm +/// when the demonstration provider becomes a module of its own. The provider +/// type is not linked here because it is compiled in only under the +/// `client-fixed-demo` cargo feature, while this name is always spelled. +/// +/// The name is spelled here whether or not the provider is compiled in, +/// because a build without it still has to recognize the name to reject the +/// selection at startup rather than at the first request. +pub const CLIENT_FIXED_PROVIDER_KEY: &str = "client-fixed"; + +/// The provider names core supplies itself. +/// +/// A name in this list is already taken, so an adapter that injects a provider +/// under one of them has two suppliers claiming a single name and +/// [`build_provider`] refuses the pair rather than picking one. The list holds +/// one entry per resolution arm in [`resolve_named_provider`], so it grows and +/// shrinks with them, and it empties when the providers still built into core +/// become modules like every other provider, at which point no name is +/// reserved and every provider is injected. +/// +/// [`CLIENT_FIXED_PROVIDER_KEY`] is listed whether or not the demonstration +/// provider is compiled in, for the same reason the name itself is always +/// spelled, which is that a build without it still owns the name. +const BUILTIN_PROVIDER_KEYS: &[&str] = &[ + HMAC_PROVIDER_KEY, + HOST_SIGNALS_PROVIDER_KEY, + CLIENT_FIXED_PROVIDER_KEY, +]; + +/// The registry code of the built-in HMAC provider. +/// +/// The same text as [`HMAC_PROVIDER_KEY`], but a different role: this is the +/// `{code}~` namespace stamped on every identifier the built-in provider +/// creates, and it is what [`generation`] matches when it decides whether an +/// enveloped identifier is one of its own. +pub const HMAC_PROVIDER_CODE: ProviderCode = crate::provider_code!(HMAC_PROVIDER_KEY); + +/// The request-scoped gating context passed to [`EdgeCookieProvider::generate`]. +/// +/// Request data reaches a provider through the `request_info` parameter of +/// [`EdgeCookieProvider::generate`], not through this struct and not through +/// anything injected into the provider's constructor. This struct carries only +/// the per-request gating context a provider may read for behavior beyond +/// gating. On the organic request path the gate has confirmed the provider's +/// required permissions are set before `generate` is called. A direct +/// `edge_cookie::generate_ec_id` call, test-only today, reaches `generate` +/// without that gate. +#[derive(Default)] +pub struct IdentityInput<'a> { + /// The permissions resolved for this request, when the calling path carries + /// them. A provider reads this only for behavior beyond gating. The main + /// organic path supplies them; the publisher path passes `None`. + pub permissions: Option<&'a PermissionState>, + + /// The request's consent context, when available, for provider-specific + /// logic. The core gates on permissions, not consent, so a provider reads + /// this only to forward or record consent. [`HmacProvider`] ignores it. + pub consent: Option<&'a ConsentContext>, +} + +/// Inputs available to [`EdgeCookieProvider::resolve_from_client`]. +/// +/// Carries the value a client produced and posted to the Edge Cookie resolve +/// endpoint, alongside the same gating context as [`IdentityInput`]. The posted +/// value reaches the provider as [`payload`](Self::payload), not through +/// anything injected into the provider. Unlike trusted edge-derived +/// data, [`payload`](Self::payload) arrives from the browser, so an +/// implementation must verify it before deriving an identifier from it. +pub struct ClientResolveInput<'a> { + /// The raw body the client posted to the resolve endpoint. For a vendor + /// provider this is its own JSON envelope; for the built-in + /// `ClientFixedProvider` demo it is the fixed known word the page script + /// posts. + pub payload: &'a [u8], + + /// The permissions resolved for the resolve request. The endpoint has + /// already confirmed the provider's required permissions are set, so a + /// provider reads this only for behavior beyond gating. + pub permissions: Option<&'a PermissionState>, + + /// The resolve request's consent context, for provider-specific logic. The + /// core gates on permissions, not consent. + pub consent: Option<&'a ConsentContext>, +} + +/// The outcome of [`EdgeCookieProvider::generate`]. +/// +/// Carries the derived identifier, if any, and any response headers the provider +/// needs set on the outbound response. +#[derive(Debug, Default)] +pub struct GeneratedEdgeCookie { + /// The derived Edge Cookie identifier, or `None` when the provider produced + /// none for this request. + pub id: Option, + + /// Response headers the provider needs set on the outbound response, for + /// example to request additional client evidence on later requests. Empty + /// for providers that set no headers, such as [`HmacProvider`]. + /// + /// Core checks every header here against its own reserved response surface + /// (see [`reserved_response_effect`]) before it is applied, so a provider + /// may set its own cookies and headers but cannot reach into the surface + /// core manages. + pub response_headers: Vec<(http::HeaderName, http::HeaderValue)>, +} + +/// The cookie-name namespace Trusted Server manages. +/// +/// Every cookie core writes or reads as part of its own behavior is named +/// `ts-` (`ts-ec` in [`COOKIE_TS_EC`](crate::constants::COOKIE_TS_EC), +/// `ts-eids` in [`COOKIE_TS_EIDS`](crate::constants::COOKIE_TS_EIDS), and +/// `ts-tester` in [`COOKIE_TS_TESTER`](crate::constants::COOKIE_TS_TESTER)), so +/// core defends the whole prefix rather than a list that a new managed cookie +/// would silently outgrow. `sharedId` is deliberately not reserved: core only +/// reads it, and it belongs to the page's own identity stack. +const MANAGED_COOKIE_NAME_PREFIX: &[u8] = b"ts-"; + +/// The response-header namespace Trusted Server reserves for itself. +/// +/// Covers the fixed EC output headers and the per-partner +/// `x-ts-` headers, which is why the prefix is reserved rather +/// than the four names in +/// [`INTERNAL_HEADERS`](crate::constants::INTERNAL_HEADERS). +const RESERVED_RESPONSE_HEADER_PREFIX: &str = "x-ts-"; + +/// Response headers that frame an HTTP message, are hop-by-hop, or govern +/// caching. +/// +/// The hop-by-hop set is RFC 7230 §6.1, plus `content-length`, which frames the +/// body the adapter is about to write, and `cache-control`, which governs +/// whether the response may be cached. A provider that set any of these would +/// be rewriting the response envelope rather than adding evidence to it, and a +/// provider setting `cache-control` could make an identity-bearing response +/// publicly cacheable, so it is reserved with the rest. +const FRAMING_OR_HOP_BY_HOP_HEADERS: &[&str] = &[ + "cache-control", + "connection", + "content-length", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]; + +/// Why one provider response header falls inside core's reserved surface. +#[derive(Debug, Copy, Clone, Eq, PartialEq, derive_more::Display)] +pub enum ReservedResponseEffect { + /// A `Set-Cookie` naming a cookie in the `ts-` namespace core manages. + #[display("sets a cookie in the `ts-` namespace Trusted Server manages")] + ManagedCookie, + + /// A header in the `x-ts-` namespace core emits and strips. + #[display("sets a header in the reserved `x-ts-` namespace")] + ReservedHeader, + + /// A framing, hop-by-hop, or caching header core manages. + #[display("sets a framing, hop-by-hop, or caching header core manages")] + FramingHeader, +} + +/// The cookie name in a `Set-Cookie` value, as raw bytes. +/// +/// Reads the bytes rather than a `&str` so a value that is not valid UTF-8 +/// cannot smuggle a managed cookie name past the check. +fn set_cookie_name(value: &[u8]) -> &[u8] { + let pair_end = value.iter().position(|b| *b == b';').unwrap_or(value.len()); + let pair = &value[..pair_end]; + let name_end = pair.iter().position(|b| *b == b'=').unwrap_or(pair.len()); + pair[..name_end].trim_ascii() +} + +/// Classifies one provider response header against core's reserved surface. +/// +/// Returns `Some` when the header would reach into what core manages, and +/// `None` for everything else, including a provider's own cookie. Providers +/// legitimately need to set cookies of their own (an evidence cookie for a +/// later request, for example), so the rule reserves core's namespace rather +/// than banning `Set-Cookie` outright. +/// +/// A rejected effect fails the request rather than being dropped, because a +/// provider reaching into the reserved surface has broken its contract in the +/// same way as one creating an identifier outside the cookie-safe alphabet, and +/// that already fails the request. Serving the response instead would let a +/// provider set `ts-ec` directly, bypassing core's identifier validation and +/// its requirement that a created identifier have an identity-graph row. +#[must_use] +pub fn reserved_response_effect( + name: &http::HeaderName, + value: &http::HeaderValue, +) -> Option { + let lower = name.as_str(); + if lower == http::header::SET_COOKIE.as_str() { + let cookie_name = set_cookie_name(value.as_bytes()); + if cookie_name.len() >= MANAGED_COOKIE_NAME_PREFIX.len() + && cookie_name[..MANAGED_COOKIE_NAME_PREFIX.len()] + .eq_ignore_ascii_case(MANAGED_COOKIE_NAME_PREFIX) + { + return Some(ReservedResponseEffect::ManagedCookie); + } + return None; + } + if lower.starts_with(RESERVED_RESPONSE_HEADER_PREFIX) { + return Some(ReservedResponseEffect::ReservedHeader); + } + if FRAMING_OR_HOP_BY_HOP_HEADERS.contains(&lower) { + return Some(ReservedResponseEffect::FramingHeader); + } + None +} + +/// Applies a provider's response headers to a response that already carries +/// the publisher origin's own. +/// +/// Every header here accumulates with what the origin returned rather than +/// replacing it, because a provider on this seam only ever adds evidence about +/// the request. It is never correcting the origin's output, so core has no +/// grounds to discard a value it did not write. Working through the headers a +/// provider can actually set: +/// +/// - `Set-Cookie` can never be folded into one field line, so replacing it +/// drops every cookie the origin set, a publisher's session and sign-in +/// cookies included. This is the case the whole rule turns on, because +/// `response_headers` is a list of pairs precisely so a provider can set more +/// than one cookie of its own, and replacing collapses those too. +/// - The list-valued headers a provider realistically sets, `Vary` first among +/// them, mean the union of their field lines. Replacing the origin's +/// `Vary: Accept-Encoding` with the provider's own would break the cache +/// correctness the origin asked for. +/// - The single-valued headers where replacing would be the right answer are +/// exactly the ones a provider must not author at all, and +/// [`reserved_response_effect`] already fails the request for them: core's +/// `x-ts-` namespace, the `ts-` managed cookies, and the framing and +/// hop-by-hop set. +/// +/// So nothing a provider is permitted to set here needs to replace, and +/// accumulating is the direction that cannot silently destroy someone else's +/// header. Appending where one value was wanted leaves a duplicate a reviewer +/// can see; replacing where two were wanted leaves nothing at all. +pub(crate) fn apply_provider_response_headers(headers: &mut http::HeaderMap, provider_headers: I) +where + I: IntoIterator, +{ + for (name, value) in provider_headers { + headers.append(name, value); + } +} + +/// The registered short code that namespaces one Edge Cookie provider's +/// identifiers. +/// +/// Exactly four characters from `[a-z0-9]`, allocated append-only in the +/// provider-code registry and never reused. The code appears as the +/// `{code}~` prefix of every identifier the provider creates, so identifiers +/// from different providers can never collide in the cookie, the identity +/// graph, or a withdrawal, and each identifier records which provider +/// created it. +#[derive(Debug, Copy, Clone, Eq, Hash, PartialEq, derive_more::Display)] +pub struct ProviderCode(&'static str); + +impl ProviderCode { + /// Creates a provider code when `code` matches the registry format. + /// + /// Returns `None` when `code` is not exactly four characters of `[a-z0-9]`, + /// so a caller that assembles a code from anything other than a literal is + /// handed an answer it has to deal with rather than a panic. Nothing in + /// this function can panic, whatever it is called with and wherever it is + /// called from. + /// + /// Use [`provider_code!`](crate::provider_code) for a literal. That macro + /// runs this check while the crate is compiled, so a malformed code is a + /// build failure and the resulting value needs no unwrapping. + /// + /// # Examples + /// + /// ``` + /// use trusted_server_core::ec::provider::ProviderCode; + /// + /// assert_eq!(ProviderCode::new("t0ac").map(ProviderCode::as_str), Some("t0ac")); + /// assert_eq!(ProviderCode::new("nope!"), None); + /// ``` + #[must_use] + pub const fn new(code: &'static str) -> Option { + let bytes = code.as_bytes(); + if bytes.len() != 4 { + return None; + } + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + if !b.is_ascii_lowercase() && !b.is_ascii_digit() { + return None; + } + i += 1; + } + Some(Self(code)) + } + + /// The code as a string slice. + #[must_use] + pub const fn as_str(self) -> &'static str { + self.0 + } +} + +/// Builds a [`ProviderCode`] from a constant, checked while the crate is +/// compiled. +/// +/// The check runs inside a `const` block, so a code that is not exactly four +/// characters of `[a-z0-9]` fails the build instead of panicking at run time, +/// and the value the macro produces needs no unwrapping. Every provider code in +/// this workspace is written through this macro, which is what makes +/// [`ProviderCode::new`]'s fallible form safe to hand to anyone else. +/// +/// # Examples +/// +/// ``` +/// use trusted_server_core::provider_code; +/// +/// assert_eq!(provider_code!("t0ac").as_str(), "t0ac"); +/// ``` +#[macro_export] +macro_rules! provider_code { + ($code:expr) => { + const { + match $crate::ec::provider::ProviderCode::new($code) { + Some(code) => code, + None => panic!("provider code must be exactly four characters of [a-z0-9]"), + } + } + }; +} + +/// The separator between a provider code and the provider's identifier value. +/// +/// The tilde is inside the cookie-safe identifier alphabet and outside the +/// built-in HMAC identifier's own characters, so a legacy bare identifier can +/// never be misread as a coded one. +pub const PROVIDER_CODE_SEPARATOR: char = '~'; + +/// Splits a full identifier into its provider-code prefix and value. +/// +/// Returns `(Some(code), value)` when the identifier starts with a well-formed +/// `{code}~` prefix, and `(None, full)` for a legacy bare identifier. The code +/// here is the raw string, not a validated [`ProviderCode`]: an unknown code +/// simply fails the ownership check against the selected provider. +#[must_use] +pub fn split_provider_code(full: &str) -> (Option<&str>, &str) { + if let Some((code, value)) = full.split_once(PROVIDER_CODE_SEPARATOR) + && code.len() == 4 + && code + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) + { + return (Some(code), value); + } + (None, full) +} + +/// Whether the selected provider owns `full` as one of its identifiers. +/// +/// A coded identifier belongs to the provider whose registered code it +/// carries, with the value part accepted by that provider's +/// [`accepts_id`](EdgeCookieProvider::accepts_id). A legacy bare identifier +/// (no code prefix) belongs only to the built-in HMAC provider, which +/// dual-reads its pre-envelope form so deployed cookies keep working across +/// the migration. +/// +/// # Retiring the legacy bare reader +/// +/// The reader stays until a bare identifier can no longer arrive. A returning +/// visitor's bare cookie is never rewritten into the coded form, and its +/// `COOKIE_MAX_AGE` lifetime in [`cookies`](super::cookies) (one year, not +/// operator-configurable) runs from the moment it was written. The +/// identity-graph row is not fixed the same way: an ordinary page view that +/// ingests `ts-eids` or `sharedId` cookies runs `ingest_eid_cookies` in +/// `ec_finalize_response` (see [`finalize`](super::finalize)), which rewrites +/// the bare-keyed row with a fresh `ENTRY_TTL` in [`kv`](super::kv) (also one +/// year), so the row's clock restarts on each such view. The earliest safe +/// retirement is therefore one year after the last write that could still +/// leave a bare-keyed row, which is the later of the last release that could +/// still create a bare identifier stopping everywhere and the last page view +/// that refreshed such a row, plus however long a deployment's own rollout +/// takes to reach every point of presence. +/// +/// The other half of that condition, evidence that bare identifiers really +/// have stopped arriving, cannot be checked today. Nothing counts or logs a +/// bare-form read-back, so there is no observed legacy-reader traffic to look +/// at, and the elapsed time alone cannot tell anyone whether a deployment +/// somewhere is still serving them. Scheduling the removal needs that signal +/// to exist first. Until it does the reader stays, and keeping it costs one +/// string comparison per read-back. +#[must_use] +pub fn provider_owns_id(provider: &dyn EdgeCookieProvider, full: &str) -> bool { + match split_provider_code(full) { + (Some(code), value) => code == provider.code().as_str() && provider.accepts_id(value), + (None, value) => provider.id() == HMAC_PROVIDER_KEY && provider.accepts_id(value), + } +} + +/// The full created identifier for `value` under `provider`'s code. +#[must_use] +pub fn apply_provider_code(provider: &dyn EdgeCookieProvider, value: &str) -> String { + format!("{}{PROVIDER_CODE_SEPARATOR}{value}", provider.code()) +} + +/// The KV-key form of a full identifier under `provider`. +/// +/// The code prefix is preserved verbatim and the provider normalizes only its +/// own value part, so distinct providers' rows can never share a key and a +/// provider never sees another provider's syntax. +#[must_use] +pub fn provider_kv_key(provider: &dyn EdgeCookieProvider, full: &str) -> String { + match split_provider_code(full) { + (Some(code), value) => format!( + "{code}{PROVIDER_CODE_SEPARATOR}{}", + provider.normalize_id_for_kv(value) + ), + (None, value) => provider.normalize_id_for_kv(value), + } +} + +/// The providers whose identifiers a partner or diagnostic path accepts. +/// +/// Pull sync, batch sync, and the admin lookup each take an identifier from +/// outside the organic request path and have to decide whether Trusted Server +/// issued it. The answer is in two parts. The **global cookie bounds** (the +/// length cap and the cookie-safe alphabet, see `ec_id_has_only_allowed_chars`) +/// apply to every identifier whichever provider created it. The rest is +/// **dispatched by the `{code}~` prefix** to the provider that owns that code, +/// which canonicalizes its own value part and decides whether the canonical +/// form is one of its own. A code no provider in the set owns is rejected, so a +/// second provider's identifiers can never be adopted or written under this +/// deployment's keys. +/// +/// Batch sync keys its rows through [`canonical_kv_key`](Self::canonical_kv_key) +/// here. Pull sync and the admin lookup still read and write rows by the raw +/// active identifier rather than the canonical form, so for a provider whose +/// canonical form differs from the cookie value they can key the wrong row. +/// That gap is recorded on `EcContext::kv_key_for` and tracked as a known +/// issue for a later change. +/// +/// The set holds the deployment's active provider. The design's +/// `legacy_providers` reader list, the providers that never create but must still +/// recognize identifiers a previous provider issued, is not implemented on this +/// branch, so [`active`](Self::active) fills `readers` with the one active +/// provider. That is the seam: when the configured legacy readers land they are +/// built alongside the active provider and pushed into the same list, and +/// neither [`accepts`](Self::accepts) nor +/// [`canonical_kv_key`](Self::canonical_kv_key) changes. +pub struct AcceptedProviders<'a> { + readers: Vec<&'a dyn EdgeCookieProvider>, +} + +impl<'a> AcceptedProviders<'a> { + /// The set holding only the deployment's active provider. + /// + /// `None` means no provider is selected, so the deployment is stateless. + #[must_use] + pub fn active(provider: Option<&'a dyn EdgeCookieProvider>) -> Self { + Self { + readers: provider.into_iter().collect(), + } + } + + /// The provider in the set that owns `full`'s code. + /// + /// Dispatch is on the code alone, before any provider looks at a value, so + /// an identifier a partner echoed back in a different case still reaches + /// its own provider to be canonicalized rather than being rejected first. + /// A legacy bare identifier predates the envelope and belongs to the + /// built-in HMAC provider alone. + fn owner(&self, full: &str) -> Option<&'a dyn EdgeCookieProvider> { + let (code, _) = split_provider_code(full); + self.readers.iter().copied().find(|provider| match code { + Some(code) => provider.code().as_str() == code, + None => provider.id() == HMAC_PROVIDER_KEY, + }) + } + + /// Whether `full` is an identifier this deployment accepts. + #[must_use] + pub fn accepts(&self, full: &str) -> bool { + self.canonical_kv_key(full).is_some() + } + + /// The identity-graph key for `full`, or `None` when nothing in the set + /// accepts it. + /// + /// The owning provider supplies the canonical form of its own value part + /// and the code prefix is preserved verbatim, so two providers' rows can + /// never share a key. + #[must_use] + pub fn canonical_kv_key(&self, full: &str) -> Option { + if !ec_id_has_only_allowed_chars(full) { + return None; + } + match self.owner(full) { + Some(owner) => { + let key = provider_kv_key(owner, full); + provider_owns_id(owner, &key).then_some(key) + } + // No provider is selected, so there is no code to dispatch on and + // the built-in HMAC grammar is the fallback, the same fallback + // `EcContext::accepts_id` has always used for a stateless + // deployment. + None if self.readers.is_empty() => { + let key = generation::normalize_ec_id_for_kv(full); + generation::is_valid_ec_id(&key).then_some(key) + } + // A code that belongs to some other deployment's provider. + None => None, + } + } +} + +/// A strategy for deriving an Edge Cookie identifier. +/// +/// Implementations are selected by configuration and come in two types, which +/// reach the same outcome (a `ts-ec` cookie) by different routes: +/// +/// - **Server-side** (for example [`HmacProvider`]): derives the identifier at +/// the edge in [`generate`](Self::generate), and the page response sets the +/// cookie. Nothing client-side is involved. +/// - **Client-side** (for example `ClientFixedProvider`): defers in +/// [`generate`](Self::generate) (returns `id: None`), runs its own JavaScript +/// in the browser, and creates the identifier from the value the page posts +/// back in +/// [`resolve_from_client`](Self::resolve_from_client), whose response sets the +/// cookie. +/// +/// A provider that cannot derive an identifier at the edge returns a +/// [`GeneratedEdgeCookie`] whose [`id`](GeneratedEdgeCookie::id) is `None`, so +/// the request proceeds without an Edge Cookie rather than failing. +/// Uses `#[async_trait(?Send)]` for the same reason as +/// [`PlatformHttpClient`](crate::platform::PlatformHttpClient): the trait +/// object stays `Send + Sync` so it can be shared and run multi-threaded, +/// while the future it returns is pinned to one thread because the host SDKs +/// produce `!Send` futures on wasm32. +#[async_trait::async_trait(?Send)] +pub trait EdgeCookieProvider: Send + Sync + core::fmt::Debug { + /// Returns the stable identifier for this provider, used in configuration + /// and logs. + fn id(&self) -> &'static str; + + /// The provider's registered code, the `{code}~` namespace of every + /// identifier it creates. + /// + /// Mandatory, with no default: a provider must allocate a unique code in + /// the provider-code registry before it can exist, so no two providers + /// can ever create colliding identifiers. Core applies the code at + /// creation and checks it at read-back, and the provider itself only ever + /// sees its own value part. + fn code(&self) -> ProviderCode; + + /// Whether this provider was built from evidence about one request. + /// + /// Almost every provider is built from configuration and services that are + /// the same for every request, so one instance can be resolved once and + /// handed to all of them. [`HostSignalProvider`] is the exception, because + /// it is built from the TLS and HTTP/2 signals of a single request and + /// answers `true` here. A composition root reads this through + /// [`build_reusable_provider`] to decide whether keeping the instance is + /// safe, and keeping a request-scoped one would serve every later request + /// from the first request's evidence. + /// + /// The default is `false`, which is right for a provider whose constructor + /// takes only configuration and long-lived services. + fn is_request_scoped(&self) -> bool { + false + } + + /// Derives an Edge Cookie identifier from the request evidence in + /// `request_info` and the gating context in `input`. + /// + /// A server-side provider creates here. A client-side provider defers here + /// (returns `id: None`) and creates later in + /// [`resolve_from_client`](Self::resolve_from_client) from the value the page + /// posts back. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::EdgeCookie`] when derivation fails. + /// Asynchronous, and handed the platform services, because a provider may + /// reach a backend, a key-value store or a secret to derive an identifier, + /// and a provider that cannot make those calls cannot be written at all. + /// A provider that derives from data already in hand still declares an + /// async method and returns immediately. + async fn generate( + &self, + request_info: &dyn RequestInfo, + input: &IdentityInput<'_>, + services: &crate::platform::RuntimeServices, + ) -> Result>; + + /// Returns whether `value` is a well-formed identifier this provider issues. + /// + /// Core calls this to decide whether an incoming `ts-ec` cookie value is a + /// usable Edge Cookie identifier before reading it back, keying the KV + /// identity graph, or withdrawing it. Core strips the provider's `{code}~` + /// prefix first, so this receives only the provider's own value part. + /// This keeps the identifier opaque to + /// core: a provider whose identifiers are not the built-in shape (for + /// example an opaque signed envelope) accepts its own format here, so its + /// identifier round-trips instead of being silently dropped on read-back. + /// + /// The default accepts the built-in HMAC identifier shape + /// (`<64 hex>.<6 alphanumeric>`), which is correct for [`HmacProvider`], the + /// one provider core builds in. + fn accepts_id(&self, value: &str) -> bool { + generation::is_valid_ec_id(value) + } + + /// Returns the KV-key form of `value` for this provider's identifiers. + /// + /// Core keys the identity graph by the returned string, so a provider whose + /// identifiers are case-sensitive or carry no separable segments returns the + /// value unchanged to avoid collapsing distinct identifiers into one key. + /// + /// The default lowercases the leading HMAC hash segment and preserves the + /// suffix, matching the built-in identifier shape. + fn normalize_id_for_kv(&self, value: &str) -> String { + generation::normalize_ec_id_for_kv(value) + } + + /// The permissions this provider's data use requires. + /// + /// Trusted Server executes the provider only when every permission returned + /// here is set. The default is empty, so a vendor-neutral provider requires + /// no permission. A provider that stores identity on the device, or shares it + /// onward, declares the matching permission so the request's country and + /// signal rules can gate it. + fn required_permissions(&self) -> PermissionSet { + PermissionSet::none() + } + + /// Derives an Edge Cookie identifier from a value the client produced and + /// posted to the resolve endpoint (`POST /_ts/api/v1/ec/resolve`). + /// + /// This is the client-side counterpart to [`generate`](Self::generate). A + /// provider that cannot derive an identifier at the edge defers from + /// `generate` (returning `id: None`, optionally with response headers that + /// trigger client-side work), and the page posts its result back here. The + /// payload arrives from the browser, so an implementation MUST verify it + /// (for example checking a signature) before trusting it. The default + /// returns no identifier, so a provider that creates entirely server-side + /// (such as [`HmacProvider`]) need not implement it. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::EdgeCookie`] when processing the payload + /// fails. A payload that is merely unverified or absent yields `id: None` + /// rather than an error, so the request proceeds without an Edge Cookie. + /// Asynchronous, and handed the platform services, for the same reason as + /// [`generate`](Self::generate). Verifying a payload the browser posted is + /// the case that most needs them, because checking a signature or a nonce + /// generally means reading a secret or calling the vendor's backend. + async fn resolve_from_client( + &self, + _input: &ClientResolveInput<'_>, + _services: &crate::platform::RuntimeServices, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } +} + +/// The built-in HMAC Edge Cookie provider. +/// +/// Derives the identifier from the client IP (read from the [`RequestInfo`] +/// passed at call time) and the configured passphrase via +/// [`generation::generate_ec_id`]. +/// +/// The client IP is this provider's only input, so it is this provider that +/// requires one. On a host that cannot supply one, [`RequestInfo::client_ip`] +/// is the empty string and [`generate`](Self::generate) fails rather than +/// hashing the empty string into an identifier every visitor on that host +/// would share. The failure is returned to the caller. The publisher proxy and +/// integration proxy log it and serve the response without an Edge Cookie. A +/// provider that reads other evidence makes its own decision and is unaffected. +#[derive(Debug, Clone)] +pub struct HmacProvider { + passphrase: Redacted, +} + +impl HmacProvider { + /// Creates an HMAC provider with the given passphrase. + #[must_use] + pub fn new(passphrase: Redacted) -> Self { + Self { passphrase } + } +} + +#[async_trait::async_trait(?Send)] +impl EdgeCookieProvider for HmacProvider { + fn id(&self) -> &'static str { + HMAC_PROVIDER_KEY + } + + fn code(&self) -> ProviderCode { + HMAC_PROVIDER_CODE + } + + async fn generate( + &self, + request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, + ) -> Result> { + let client_ip = request_info.client_ip(); + if client_ip.is_empty() { + return Err(Report::new(TrustedServerError::EdgeCookie { + message: "Edge Cookie provider `hmac` requires the client IP, and this host \ + could not supply one" + .to_owned(), + })); + } + let id = generation::generate_ec_id(self.passphrase.expose(), client_ip)?; + Ok(GeneratedEdgeCookie { + id: Some(id), + response_headers: Vec::new(), + }) + } + + fn required_permissions(&self) -> PermissionSet { + // The HMAC provider writes the Edge Cookie to the device, so it requires + // permission to store on the device (TCF Purpose 1). Whether that needs a + // signal is decided by the country rules, not by the provider. + PermissionSet::none().with(Permission::StoreOnDevice) + } +} + +/// The built-in host-signal Edge Cookie provider. +/// +/// Derives the identifier from the host signals (TLS JA4 and HTTP/2, read +/// from the injected [`HostSignals`]) plus the client IP (from [`RequestInfo`]), +/// keyed by the configured passphrase. It is host-agnostic: it depends on the +/// `HostSignals` capability, so any host that supplies one can use it. A host +/// that supplies no `HostSignals` cannot build it, and the request stops. +#[derive(Debug, Clone)] +pub struct HostSignalProvider { + passphrase: Redacted, + host_signals: Arc, +} + +impl HostSignalProvider { + /// Creates the provider with the passphrase and its injected host signals. + #[must_use] + pub fn new(passphrase: Redacted, host_signals: Arc) -> Self { + Self { + passphrase, + host_signals, + } + } +} + +#[async_trait::async_trait(?Send)] +impl EdgeCookieProvider for HostSignalProvider { + fn id(&self) -> &'static str { + HOST_SIGNALS_PROVIDER_KEY + } + + // Built from the signals of one request, so it is only ever valid for + // that request and must never be kept and reused. + fn is_request_scoped(&self) -> bool { + true + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("hs00") + } + + async fn generate( + &self, + request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, + ) -> Result> { + let ja4 = self.host_signals.ja4().unwrap_or_default(); + let h2 = self.host_signals.h2().unwrap_or_default(); + // With no signal at all, creating an identifier would silently degrade + // to an IP-only identifier under the host-signals name. Defer instead, + // meaning no identity this request, and the request proceeds. + if ja4.is_empty() && h2.is_empty() { + log::warn!("Host-signal EC provider found no TLS/HTTP-2 signals; deferring"); + return Ok(GeneratedEdgeCookie::default()); + } + let id = generation::generate_hmac_ec_id( + self.passphrase.expose(), + &[ja4, h2, request_info.client_ip()], + )?; + Ok(GeneratedEdgeCookie { + id: Some(id), + response_headers: Vec::new(), + }) + } + + fn required_permissions(&self) -> PermissionSet { + // Writes the Edge Cookie to the device, so it requires necessary.operations.storage + // (TCF Purpose 1), the same gate as the HMAC provider. + PermissionSet::none().with(Permission::StoreOnDevice) + } +} + +/// The fixed, known word shared by [`ClientFixedProvider`] and its page script. +/// +/// Kept cookie-safe (no characters [`set_provider_ec_cookie`] would reject) so +/// it can be used as the Edge Cookie value verbatim. The page script posts this +/// exact string; the provider creates only when the posted value matches. The +/// client copy lives in +/// `crates/trusted-server-js/lib/src/integrations/ec_client_fixed`. +/// +/// [`set_provider_ec_cookie`]: super::cookies::set_provider_ec_cookie +#[cfg(any(test, feature = "client-fixed-demo"))] +const EXPECTED_VALUE: &str = "an-ec"; + +/// A demonstration client-side provider, with no vendor coupling. +/// +/// Client and server share one fixed, known word (`EXPECTED_VALUE`). When no +/// Edge Cookie is present the page script (delivered through the tsjs bundle) +/// posts that word to `POST /_ts/api/v1/ec/resolve`, and this provider creates the +/// Edge Cookie only when the posted value matches. It defers from +/// [`generate`](EdgeCookieProvider::generate) so the page renders with no Edge +/// Cookie until the client reports back, then verifies and creates in +/// [`resolve_from_client`](EdgeCookieProvider::resolve_from_client). +/// +/// The value is verifiable precisely because it is a known constant, which is +/// the point of the demo: it exercises verify-before-create. It is useless in +/// production, because a fixed value is not an identity and every client posts +/// the same word, so it is for demonstration and testing only. A real +/// client-side provider verifies a real payload (for example an OWID signature) +/// instead of a shared constant. +#[derive(Debug, Clone)] +#[cfg(any(test, feature = "client-fixed-demo"))] +pub struct ClientFixedProvider; + +#[cfg(any(test, feature = "client-fixed-demo"))] +#[async_trait::async_trait(?Send)] +impl EdgeCookieProvider for ClientFixedProvider { + fn id(&self) -> &'static str { + CLIENT_FIXED_PROVIDER_KEY + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("cfix") + } + + async fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, + ) -> Result> { + // No identifier is derived at the edge: the value comes from the page + // script, which posts it to the resolve endpoint. + Ok(GeneratedEdgeCookie::default()) + } + + async fn resolve_from_client( + &self, + input: &ClientResolveInput<'_>, + _services: &crate::platform::RuntimeServices, + ) -> Result> { + // Verify the posted value against the known shared word, then create it as + // the Edge Cookie. A value that does not match yields no Edge Cookie. + // This stands in for a real provider's verification (for example + // checking a signature) before it trusts a client-supplied value. + let matches = core::str::from_utf8(input.payload) + .map(str::trim) + .is_ok_and(|value| value == EXPECTED_VALUE); + + Ok(GeneratedEdgeCookie { + id: matches.then(|| EXPECTED_VALUE.to_owned()), + response_headers: Vec::new(), + }) + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + // The fixed word has no dot separator, so the built-in default (which + // normalizes the HMAC `.` shape) would corrupt it as a + // KV key. Like any opaque-identifier provider, the value is the key. + value.to_owned() + } + + fn required_permissions(&self) -> PermissionSet { + // The provider writes the resolved value to the device as the Edge + // Cookie, so it requires necessary.operations.storage (TCF Purpose 1), the same gate + // as the HMAC provider. + PermissionSet::none().with(Permission::StoreOnDevice) + } +} + +/// Refuses an injected provider that claims a name core supplies itself. +/// +/// Two suppliers cannot own one name. Core ships the `hmac` provider, and once +/// this work merges IAB Tech Lab is itself a vendor shipping an HMAC provider, +/// so the two really can arrive under the same name in one deployment. The +/// resolution order alone would answer that by quietly preferring the built-in +/// one and dropping the injected provider, which an operator has no way to see, +/// so the pair is refused here and the error names both claimants. +/// +/// The check runs whatever the selector says, so an operator is told at startup +/// rather than on the first request that happens to select the contested name, +/// and it runs before the selection is read so a deployment cannot hide the +/// clash by selecting something else. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::EdgeCookie`] when the injected provider's id +/// is one of [`BUILTIN_PROVIDER_KEYS`]. +fn ensure_no_name_collision( + injected: Option<&dyn EdgeCookieProvider>, +) -> Result<(), Report> { + let Some(injected) = injected else { + return Ok(()); + }; + let Some(claimed) = BUILTIN_PROVIDER_KEYS + .iter() + .find(|key| **key == injected.id()) + else { + return Ok(()); + }; + Err(Report::new(TrustedServerError::EdgeCookie { + message: format!( + "Edge Cookie provider name `{claimed}` is claimed twice, by the provider \ + built into Trusted Server core and by the provider this deployment's \ + adapter injects. Give the injected provider a name of its own and select \ + it under that name, because `[ec] provider = \"{claimed}\"` cannot mean \ + both of them." + ), + })) +} + +/// Builds the Edge Cookie provider named by the `[ec] provider` selector. +/// +/// This is the composition root for the built-in providers: the adapter supplies +/// the [`HostSignals`] when the host can produce them, and this constructs the +/// selected provider. The per-request [`RequestInfo`] is passed borrowed to +/// [`generate`](EdgeCookieProvider::generate) at call time rather than stored, so +/// no request snapshot is cloned here. Returns `Ok(None)` when no provider is +/// selected, so the caller stays stateless. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::EdgeCookie`] when the named provider cannot be +/// built: a built-in name whose configuration block is missing, a built-in name +/// whose host capability this host does not supply, or a name this deployment's +/// adapter does not inject. All fail loudly rather than leaving the deployment +/// running stateless under a selector that says otherwise. +pub fn build_provider( + ec: &Ec, + host_signals: Option>, + injected: Option>, +) -> Result>, Report> { + ensure_no_name_collision(injected.as_deref())?; + let Some(selection) = ec.provider.as_ref() else { + return Ok(None); + }; + let provider: Option> = match selection { + // Explicit statelessness: the same meaning as omitting the selector. + EcProviderSelection::None => None, + // Every provider is named, and this is the one place a name is resolved + // to an implementation. Nothing else in the codebase asks whether a + // name is built in. + EcProviderSelection::Named(key) => { + Some(resolve_named_provider(key, ec, host_signals, injected)?) + } + }; + Ok(provider) +} + +/// Resolves one provider name to its implementation. +/// +/// A name is looked for among the providers built into core first, and is +/// otherwise the name of a provider the adapter injects through +/// [`RuntimeServices`](crate::platform::RuntimeServices), the same seam the +/// device and geo providers use, so core never names a vendor. The injected +/// provider is used when its own id matches the name, and its +/// `[ec.providers.]` block is read by the adapter that built it. +/// +/// Looking at core first is safe only because +/// [`ensure_no_name_collision`] has already refused an injected provider that +/// claims a built-in name, so this order can never shadow one silently. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::EdgeCookie`] when the name matches no provider +/// this deployment can build, when a built-in name has no configuration block, +/// or when a built-in name needs a host capability this host does not supply. +/// All fail loudly rather than silently running stateless. +fn resolve_named_provider( + key: &str, + ec: &Ec, + host_signals: Option>, + injected: Option>, +) -> Result, Report> { + // The only place that knows a provider is built into core rather than + // supplied as a module. Each arm disappears, along with its name constant, + // when that provider becomes a module like every other provider, after + // which its name resolves through the injected path below and nothing else + // changes. + // + // Settings validation rejects a built-in name with no block before this + // runs, so reaching the error means the two checks have drifted apart. + // Stopping is the only safe answer: returning no provider would run the + // deployment stateless under a selector that says it has an identity + // provider. + if key == HMAC_PROVIDER_KEY { + let config = ec.providers.hmac.as_ref().ok_or_else(|| { + Report::new(TrustedServerError::EdgeCookie { + message: "Edge Cookie provider `hmac` is selected but has no \ + `[ec.providers.hmac]` configuration" + .to_owned(), + }) + })?; + return Ok(Box::new(HmacProvider::new(config.passphrase.clone()))); + } + + // The host-signal provider needs signals only some hosts supply, and + // that check cannot be made in settings validation at all, so it is made + // here rather than creating a degraded identifier under this name. + if key == HOST_SIGNALS_PROVIDER_KEY { + let config = ec.providers.host_signals.as_ref().ok_or_else(|| { + Report::new(TrustedServerError::EdgeCookie { + message: "Edge Cookie provider `host-signals` is selected but has no \ + `[ec.providers.host-signals]` configuration" + .to_owned(), + }) + })?; + let signals = host_signals.ok_or_else(|| { + Report::new(TrustedServerError::EdgeCookie { + message: "The host-signals Edge Cookie provider requires a host that supplies \ + TLS/HTTP-2 signals, which this host does not" + .to_owned(), + }) + })?; + return Ok(Box::new(HostSignalProvider::new( + config.passphrase.clone(), + signals, + ))); + } + + // The client-fixed demonstration provider takes no configuration block and + // no services, so it is built whenever it is selected. A fixed shared word + // is not an identity, so it is compiled only into test and demonstration + // builds and a build without it refuses the name rather than substituting + // anything. `check_named_provider_configuration` refuses the same name at + // startup, so reaching this error means the two have drifted apart. + if key == CLIENT_FIXED_PROVIDER_KEY { + #[cfg(any(test, feature = "client-fixed-demo"))] + return Ok(Box::new(ClientFixedProvider)); + #[cfg(not(any(test, feature = "client-fixed-demo")))] + return Err(Report::new(TrustedServerError::EdgeCookie { + message: "The client-fixed demo Edge Cookie provider is not compiled into this \ + build. It is for demonstration and testing only; enable the \ + trusted-server-core `client-fixed-demo` cargo feature to use it" + .to_owned(), + })); + } + + injected + .filter(|provider| provider.id() == key) + .map(|provider| Box::new(SharedProvider(provider)) as Box) + .ok_or_else(|| { + Report::new(TrustedServerError::EdgeCookie { + message: format!( + "Edge Cookie provider `{key}` is selected but this deployment's \ + adapter does not provide it" + ), + }) + }) +} + +/// Checks that `key` names a provider this deployment could build, as far as +/// the configuration on its own can answer. +/// +/// The startup counterpart to [`resolve_named_provider`], and the reason +/// configuration validation does not ask the settings whether a +/// `[ec.providers.]` block is present. Whether a name needs a block is +/// the resolution's knowledge, not the settings', because a provider built from +/// nothing (the client-fixed demonstration provider) is configured correctly +/// with no block at all, while a build that does not compile that provider in +/// cannot honor the name however it is configured. Both arms live here beside +/// the resolution they belong to, and both go with it when these providers +/// become modules. +/// +/// Whether the host supplies a capability a provider needs is not answerable +/// from configuration, so it is not asked here. [`ensure_provider_available`] +/// asks that, with the services the adapter injects. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::Configuration`] when the name is not compiled +/// into this build, or when it needs an `[ec.providers.]` block that is +/// absent. +pub(crate) fn check_named_provider_configuration( + key: &str, + ec: &Ec, +) -> Result<(), Report> { + // The one name built from nothing, so a block lookup would reject the + // correctly configured case, and the one name a production build does not + // supply at all, which no amount of configuration can fix. Rejecting it + // here rather than when the provider is built means an operator finds out + // at startup instead of on the first request. + if key == CLIENT_FIXED_PROVIDER_KEY { + #[cfg(any(test, feature = "client-fixed-demo"))] + return Ok(()); + #[cfg(not(any(test, feature = "client-fixed-demo")))] + return Err(Report::new(TrustedServerError::Configuration { + message: "[ec] provider = \"client-fixed\" selects the demonstration provider, \ + which is not compiled into this build. Enable the trusted-server-core \ + `client-fixed-demo` cargo feature for demonstrations" + .to_owned(), + })); + } + + // Every other name, whether core builds it or the adapter injects it, is + // configured by the `[ec.providers.]` block carrying its own name. + if ec.providers.has_block(key) { + Ok(()) + } else { + Err(Report::new(TrustedServerError::Configuration { + message: format!( + "Edge Cookie provider `{key}` is selected but has no `[ec.providers.{key}]` configuration" + ), + })) + } +} + +/// Checks once, at startup, that this deployment can build the provider named +/// by the `[ec] provider` selector. +/// +/// The composition root calls this while it builds application state, passing +/// the same services it will put into +/// [`RuntimeServices`](crate::platform::RuntimeServices) on every request. +/// [`build_provider`] reads no request data, so the answer is the same for +/// every request and a selection the adapter can never supply fails at startup +/// rather than on the first request. A stateless deployment (no selector, or +/// `"none"`) passes. +/// +/// `host_signals` answers whether this adapter supplies a [`HostSignals`] +/// service at all, which is fixed per deployment, rather than what any one +/// request's signals are. An adapter that injects host signals on every +/// request passes an instance here even though its values are empty at +/// startup, and an adapter that never injects them passes `None`. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::EdgeCookie`] when the selected provider cannot +/// be built from the services this deployment injects. +pub fn ensure_provider_available( + ec: &Ec, + host_signals: Option>, + injected: Option>, +) -> Result<(), Report> { + build_shared_provider(ec, host_signals, injected)?; + Ok(()) +} + +/// Resolves the selected provider into a shared handle. +/// +/// The same resolution as [`build_provider`], returned as an `Arc` rather than +/// a `Box` so one instance can be held in +/// [`RuntimeServices`](crate::platform::RuntimeServices) and read by every +/// request. Use [`build_reusable_provider`] at a composition root, which adds +/// the one check that decides whether keeping the instance is safe. +/// +/// # Errors +/// +/// The same errors as [`build_provider`]. +pub fn build_shared_provider( + ec: &Ec, + host_signals: Option>, + injected: Option>, +) -> Result>, Report> { + Ok(build_provider(ec, host_signals, injected)?.map(Arc::from)) +} + +/// The provider a composition root may keep and hand to every request, when +/// the selection is one that can be kept at all. +/// +/// Resolving is also the startup check, so a selection this deployment cannot +/// satisfy fails here rather than on the first request, exactly as +/// [`ensure_provider_available`] makes it fail. What this adds is the answer to +/// a second question, which is whether the provider that came back is the same +/// for every request. Most are, because they are built from configuration +/// alone, and keeping one saves resolving the same settings again on every +/// request. +/// +/// [`HostSignalProvider`] is not, because it is built from the signals of +/// one request and reports +/// [`is_request_scoped`](EdgeCookieProvider::is_request_scoped). Keeping that +/// one would freeze the signals captured while application state was built, +/// which on every adapter here are empty, so every later request would find no +/// signals and defer. `Ok(None)` comes back for it, the adapter threads +/// nothing, and the request path resolves it per request against that request's +/// own signals. +/// +/// `Ok(None)` therefore means "nothing to keep", which covers both a stateless +/// deployment and a provider that must be resolved per request. Both leave the +/// request path resolving for itself, which is what it did before anything was +/// kept. +/// +/// # Errors +/// +/// The same errors as [`build_provider`]. +pub fn build_reusable_provider( + ec: &Ec, + host_signals: Option>, + injected: Option>, +) -> Result>, Report> { + let Some(provider) = build_shared_provider(ec, host_signals, injected)? else { + return Ok(None); + }; + if provider.is_request_scoped() { + log::debug!( + "Edge Cookie provider `{}` is built from request evidence, so it is resolved per request rather than kept", + provider.id(), + ); + return Ok(None); + } + Ok(Some(provider)) +} + +/// The Edge Cookie provider to use for this request. +/// +/// A provider reaches the request path through one seam only. An adapter +/// resolves `[ec] provider` once while it builds application state and threads +/// the answer into +/// [`RuntimeServices::resolved_ec_provider`](crate::platform::RuntimeServices::resolved_ec_provider), +/// and that same instance comes back here with nothing resolved or constructed +/// again on the request path. When nothing was threaded, this builds from +/// `[ec]` settings alone, which is what a deployment selecting only a built-in +/// provider does. +/// +/// # Errors +/// +/// The same errors as [`build_provider`], and only when nothing was threaded, +/// because a threaded provider has already been resolved successfully. +pub fn request_provider( + ec: &Ec, + services: &crate::platform::RuntimeServices, +) -> Result>, Report> { + if let Some(resolved) = services.resolved_ec_provider() { + return Ok(Some(resolved)); + } + build_shared_provider(ec, services.host_signals(), None) +} + +/// Adapts an injected, shared [`EdgeCookieProvider`] to the owned `Box` that +/// [`build_provider`] returns. +/// +/// A vendor or host provider is injected as an `Arc` so it can live in +/// [`RuntimeServices`](crate::platform::RuntimeServices) and be cloned per +/// request. Every method delegates to the inner provider, so its behavior is +/// unchanged. +#[derive(Debug)] +struct SharedProvider(Arc); + +#[async_trait::async_trait(?Send)] +impl EdgeCookieProvider for SharedProvider { + fn code(&self) -> ProviderCode { + self.0.code() + } + + fn id(&self) -> &'static str { + self.0.id() + } + + fn is_request_scoped(&self) -> bool { + self.0.is_request_scoped() + } + + async fn generate( + &self, + request_info: &dyn RequestInfo, + input: &IdentityInput<'_>, + services: &crate::platform::RuntimeServices, + ) -> Result> { + self.0.generate(request_info, input, services).await + } + + fn accepts_id(&self, value: &str) -> bool { + self.0.accepts_id(value) + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + self.0.normalize_id_for_kv(value) + } + + fn required_permissions(&self) -> PermissionSet { + self.0.required_permissions() + } + + async fn resolve_from_client( + &self, + input: &ClientResolveInput<'_>, + services: &crate::platform::RuntimeServices, + ) -> Result> { + self.0.resolve_from_client(input, services).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::evidence::OwnedRequestInfo; + use crate::settings::{EcProviders, HmacProviderConfig, HostSignalsProviderConfig}; + use http::HeaderMap; + + #[test] + fn a_malformed_provider_code_is_refused_rather_than_panicking() { + // `ProviderCode::new` is public, so a vendor crate can reach it with a + // value it assembled rather than a literal. Every rejected shape has to + // come back as `None`, because a panic here would take down whatever + // request the caller was serving. + for malformed in ["", "abc", "abcde", "AB12", "t0a_", "t0a-", "t0a ", "t.ac"] { + assert_eq!( + ProviderCode::new(malformed), + None, + "`{malformed}` is outside the registry format and should be refused" + ); + } + + assert_eq!( + ProviderCode::new("t0ac").map(ProviderCode::as_str), + Some("t0ac"), + "a well-formed code should still be accepted" + ); + } + + #[test] + fn the_provider_code_macro_keeps_the_compile_time_guarantee() { + // The macro checks a literal while the crate is compiled and yields the + // code itself, so the codes written across this workspace stay as + // strong as the old panicking constructor made them, with none of the + // run-time risk. + assert_eq!( + crate::provider_code!("t0ac").as_str(), + "t0ac", + "the macro should yield the code it was given" + ); + assert_eq!( + HMAC_PROVIDER_CODE.as_str(), + HMAC_PROVIDER_KEY, + "the built-in code should still be the built-in key" + ); + } + + #[test] + fn split_provider_code_separates_coded_and_legacy_forms() { + assert_eq!( + split_provider_code("hmac~abc.DEF123"), + (Some("hmac"), "abc.DEF123"), + "a four-character code before the first tilde splits off" + ); + assert_eq!( + split_provider_code("51dd~value~with~tildes"), + (Some("51dd"), "value~with~tildes"), + "only the first tilde splits, so a value may contain tildes" + ); + assert_eq!( + split_provider_code("abcdef.XYZ"), + (None, "abcdef.XYZ"), + "no tilde means the legacy bare form" + ); + assert_eq!( + split_provider_code("toolong~x"), + (None, "toolong~x"), + "a prefix that is not exactly four characters is not a code" + ); + assert_eq!( + split_provider_code("AB12~x"), + (None, "AB12~x"), + "uppercase is outside the code alphabet" + ); + } + + fn header(name: &str, value: &str) -> (http::HeaderName, http::HeaderValue) { + ( + http::HeaderName::from_bytes(name.as_bytes()).expect("should parse header name"), + http::HeaderValue::from_str(value).expect("should parse header value"), + ) + } + + #[test] + fn reserved_response_effect_rejects_the_namespace_core_manages() { + for (name, value, expected) in [ + ( + "set-cookie", + "ts-ec=hmac~deadbeef.abc123; Path=/", + ReservedResponseEffect::ManagedCookie, + ), + ( + "Set-Cookie", + " TS-EIDS=x; Path=/", + ReservedResponseEffect::ManagedCookie, + ), + ("x-ts-ec", "spoofed", ReservedResponseEffect::ReservedHeader), + ( + "X-TS-partner.example.com", + "uid", + ReservedResponseEffect::ReservedHeader, + ), + ("content-length", "0", ReservedResponseEffect::FramingHeader), + ( + "Transfer-Encoding", + "chunked", + ReservedResponseEffect::FramingHeader, + ), + ("connection", "close", ReservedResponseEffect::FramingHeader), + ( + "cache-control", + "public, max-age=31536000", + ReservedResponseEffect::FramingHeader, + ), + ( + "Cache-Control", + "public", + ReservedResponseEffect::FramingHeader, + ), + ] { + let (name, value) = header(name, value); + assert_eq!( + reserved_response_effect(&name, &value), + Some(expected), + "`{name}` should be reserved" + ); + } + } + + #[test] + fn reserved_response_effect_allows_provider_owned_effects() { + for (name, value) in [ + ("set-cookie", "acme-evidence=abc; Path=/; Secure"), + ("set-cookie", "sharedId=abc"), + ("accept-ch", "Sec-CH-UA-Full-Version-List"), + ("x-acme-probe", "1"), + ("vary", "Sec-CH-UA"), + ] { + let (name, value) = header(name, value); + assert_eq!( + reserved_response_effect(&name, &value), + None, + "`{name}` is the provider's own and should be allowed" + ); + } + } + + #[test] + fn reserved_response_effect_reads_a_non_utf8_set_cookie_as_bytes() { + // A `Set-Cookie` carrying a byte above 127 cannot be read as a string, + // so the cookie name is matched on raw bytes. Reading it as UTF-8 and + // giving up on failure would let this value through. + let name = http::header::SET_COOKIE; + let mut bytes = b"ts-ec=value".to_vec(); + bytes.push(0xff); + bytes.extend_from_slice(b"; Path=/"); + let value = + http::HeaderValue::from_bytes(&bytes).expect("should build a non-utf8 header value"); + assert!( + value.to_str().is_err(), + "the test value should not be readable as UTF-8" + ); + assert_eq!( + reserved_response_effect(&name, &value), + Some(ReservedResponseEffect::ManagedCookie), + "a non-UTF-8 Set-Cookie should still be matched on its cookie name" + ); + } + + /// A stand-in for a vendor provider an adapter injects. + #[derive(Debug)] + struct VendorProvider; + + #[async_trait::async_trait(?Send)] + impl EdgeCookieProvider for VendorProvider { + fn id(&self) -> &'static str { + "acme" + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0ac") + } + + async fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } + } + + #[test] + fn accepted_providers_splits_global_bounds_from_provider_dispatch() { + let hmac = HmacProvider::new(Redacted::new("test-secret-key-32-bytes-minimum".to_owned())); + let hmac_value = format!("{}.ABC123", "a".repeat(64)); + let active = AcceptedProviders::active(Some(&hmac)); + + // The global bounds come first and apply whoever created the value. A + // character outside the cookie-safe alphabet, or a value over the + // length cap, never reaches a provider. + assert!( + !active.accepts(&format!("hmac~{hmac_value} with spaces")), + "the cookie-safe alphabet is a global bound" + ); + assert!( + !active.accepts(&format!("hmac~{}", "a".repeat(300))), + "the length cap is a global bound" + ); + + // Then dispatch by code to the provider that owns it. + assert!( + active.accepts(&format!("hmac~{hmac_value}")), + "the active provider's own code is accepted" + ); + assert!( + active.accepts(&hmac_value), + "the legacy bare form belongs to the built-in provider" + ); + assert!( + !active.accepts(&format!("t0ac~{hmac_value}")), + "a code no configured provider reads is rejected even in the HMAC shape" + ); + + // A vendor provider's own identifiers are accepted when it is the + // active one, and the built-in bare form then belongs to nobody. + let vendor = AcceptedProviders::active(Some(&VendorProvider)); + assert!( + vendor.accepts(&format!("t0ac~{hmac_value}")), + "the vendor provider's code is accepted when it is active" + ); + assert!( + !vendor.accepts(&hmac_value), + "the legacy bare form is the built-in provider's alone" + ); + + // With no provider selected the deployment is stateless, so the + // built-in grammar is the fallback, as it has always been. + let stateless = AcceptedProviders::active(None); + assert!( + stateless.accepts(&hmac_value), + "a stateless deployment falls back to the built-in grammar" + ); + assert!( + !stateless.accepts("not-an-identifier"), + "the fallback is still the built-in grammar, not anything goes" + ); + } + + #[test] + fn the_selector_round_trips_through_serialization() { + // The typed selector must not change the configuration surface. The + // same TOML has to parse to the same choice, and serializing has to + // write the same key back, so an existing operator configuration keeps + // working and a config push does not rewrite the selector. + for (key, expected) in [ + (EcProviderSelection::NONE_KEY, EcProviderSelection::None), + ( + HMAC_PROVIDER_KEY, + EcProviderSelection::Named(HMAC_PROVIDER_KEY.to_owned()), + ), + ("acme", EcProviderSelection::Named("acme".to_owned())), + ] { + let ec: Ec = toml::from_str(&format!("provider = \"{key}\"")) + .expect("should parse the [ec] section"); + assert_eq!( + ec.provider.as_ref(), + Some(&expected), + "`{key}` should select the provider it names" + ); + assert_eq!( + expected.key(), + key, + "`{key}` should report itself under the key it was written as" + ); + + // The serialized form is the string itself, byte for byte, so an + // operator configuration written before the selector was typed + // parses and is written back identically. + let value = + toml::Value::try_from(expected.clone()).expect("should serialize the selection"); + assert_eq!( + value, + toml::Value::String(key.to_owned()), + "`{key}` should serialize to exactly its own string" + ); + + let written = toml::to_string(&ec).expect("should serialize the [ec] section"); + assert!( + written.contains(&format!("provider = \"{key}\"")), + "`{key}` should be written back unchanged, got: {written}" + ); + + // A full round trip through the document leaves the same choice. + let reparsed: Ec = toml::from_str(&written).expect("should reparse the [ec] section"); + assert_eq!( + reparsed.provider.as_ref(), + Some(&expected), + "`{key}` should survive a serialize and parse round trip" + ); + } + } + + #[test] + fn each_selection_builds_what_its_string_key_built_before() { + // `none` is stateless, exactly as omitting the selector is. + let none = Ec { + provider: Some(EcProviderSelection::None), + ..Ec::default() + }; + assert!( + build_provider(&none, None, None) + .expect("explicit statelessness should build") + .is_none(), + "`none` should select no provider" + ); + + // `hmac` with its block builds the built-in provider. + let mut providers = EcProviders::default(); + providers.hmac = Some(HmacProviderConfig { + passphrase: test_passphrase(), + }); + let hmac = Ec { + provider: Some(EcProviderSelection::from(HMAC_PROVIDER_KEY)), + providers, + ..Ec::default() + }; + let built = build_provider(&hmac, None, None) + .expect("the hmac selection should build") + .expect("the hmac selection should yield a provider"); + assert_eq!( + built.id(), + HMAC_PROVIDER_KEY, + "`hmac` should select the built-in provider" + ); + assert_eq!( + built.code(), + HMAC_PROVIDER_CODE, + "the built-in provider should carry the built-in code" + ); + + // An arbitrary vendor key selects the provider the adapter injected + // under that same key. + let vendor = Ec { + provider: Some(EcProviderSelection::Named("acme".to_owned())), + ..Ec::default() + }; + let built = build_provider(&vendor, None, Some(Arc::new(VendorProvider))) + .expect("the vendor selection should build") + .expect("the vendor selection should yield a provider"); + assert_eq!( + built.id(), + "acme", + "a vendor key should select the injected provider of that id" + ); + } + + #[test] + fn provider_ownership_follows_the_code() { + let provider = HmacProvider::new(test_passphrase()); + let legacy = format!("{}.ABC123", "a".repeat(64)); + let coded = format!("hmac~{legacy}"); + let foreign = format!("zz00~{legacy}"); + assert!( + provider_owns_id(&provider, &coded), + "the provider owns identifiers carrying its own code" + ); + assert!( + provider_owns_id(&provider, &legacy), + "the built-in hmac provider dual-reads the legacy bare form" + ); + assert!( + !provider_owns_id(&provider, &foreign), + "an identifier with another provider's code is never owned" + ); + } + use crate::permissions::PermissionMaps; + use crate::redacted::Redacted; + + fn test_passphrase() -> Redacted { + Redacted::from("a-test-passphrase-32-bytes-minimum".to_owned()) + } + + fn test_request_info() -> OwnedRequestInfo { + OwnedRequestInfo::new("203.0.113.1".to_owned(), HeaderMap::new()) + } + + /// Test host signals with fixed JA4/H2 values. + #[derive(Debug)] + struct TestHostSignals { + ja4: Option, + h2: Option, + } + + impl HostSignals for TestHostSignals { + fn ja4(&self) -> Option<&str> { + self.ja4.as_deref() + } + fn h2(&self) -> Option<&str> { + self.h2.as_deref() + } + } + + #[test] + fn default_id_semantics_match_the_builtin_shape() { + let provider = HmacProvider::new(test_passphrase()); + + // The default `accepts_id` accepts the built-in HMAC shape and rejects + // anything else, so a built-in provider's identifiers round-trip while an + // opaque value is left to a provider that overrides the check. + let valid = format!("{}.{}", "a".repeat(64), "abc123"); + assert!(provider.accepts_id(&valid), "should accept the HMAC shape"); + assert!( + !provider.accepts_id("not-hmac-shaped"), + "should reject a non-HMAC identifier by default" + ); + + // The default `normalize_id_for_kv` lowercases the hash segment. This is + // exactly the transform that would corrupt an opaque case-sensitive + // identifier, which is why such a provider overrides it. + let mixed = format!("{}.{}", "A".repeat(64), "abc123"); + assert_eq!( + provider.normalize_id_for_kv(&mixed), + format!("{}.{}", "a".repeat(64), "abc123"), + "the default should lowercase the hash segment" + ); + } + + #[test] + fn shared_provider_delegates_id_semantics_to_the_inner_provider() { + // `SharedProvider` wraps an adapter-injected provider. It must forward + // every trait method to the inner provider, including `accepts_id` and + // `normalize_id_for_kv`; a wrapper that silently used the defaults would + // drop an opaque vendor identifier on read-back. This guards that + // delegation directly. + #[derive(Debug)] + struct Inner; + + #[async_trait::async_trait(?Send)] + impl EdgeCookieProvider for Inner { + fn id(&self) -> &'static str { + "inner" + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0in") + } + + async fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } + + fn accepts_id(&self, value: &str) -> bool { + value == "opaque-ok" + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + format!("kv:{value}") + } + } + + let shared = SharedProvider(Arc::new(Inner)); + + assert_eq!(shared.id(), "inner", "should delegate id"); + assert!( + shared.accepts_id("opaque-ok"), + "should delegate accepts_id acceptance to the inner provider" + ); + assert!( + !shared.accepts_id("something-else"), + "should delegate accepts_id rejection to the inner provider" + ); + assert_eq!( + shared.normalize_id_for_kv("x"), + "kv:x", + "should delegate normalize_id_for_kv to the inner provider" + ); + } + + /// A vendor provider that claims the name core already uses for its + /// built-in HMAC provider. + #[derive(Debug)] + struct VendorNamedHmacProvider; + + #[async_trait::async_trait(?Send)] + impl EdgeCookieProvider for VendorNamedHmacProvider { + fn id(&self) -> &'static str { + HMAC_PROVIDER_KEY + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0vh") + } + + async fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } + } + + #[test] + fn two_providers_claiming_one_name_are_refused_and_both_are_named() { + // Once this work merges, IAB Tech Lab supplies an HMAC provider as a + // vendor module while core still supplies one of its own, so a + // deployment really can wire two providers called `hmac`. Resolution + // order alone would prefer the built-in one and drop the injected one + // with nothing said, which is the fault this guards. + let mut providers = EcProviders::default(); + providers.hmac = Some(HmacProviderConfig { + passphrase: test_passphrase(), + }); + let selected_hmac = Ec { + provider: Some(EcProviderSelection::from(HMAC_PROVIDER_KEY)), + providers, + ..Ec::default() + }; + + let err = build_provider( + &selected_hmac, + None, + Some(Arc::new(VendorNamedHmacProvider)), + ) + .expect_err("two providers claiming `hmac` should be refused"); + let message = err.to_string(); + assert!( + message.contains(HMAC_PROVIDER_KEY), + "the error should name the contested name, got: {message}" + ); + assert!( + message.contains("core") && message.contains("adapter"), + "the error should name both claimants, got: {message}" + ); + + // The clash is a wiring fault, not a property of the selection, so + // selecting something else does not hide it and the operator still + // learns at startup. + let selected_elsewhere = Ec { + provider: Some(EcProviderSelection::None), + ..Ec::default() + }; + let err = ensure_provider_available( + &selected_elsewhere, + None, + Some(Arc::new(VendorNamedHmacProvider)), + ) + .expect_err("the clash should be refused whatever the selector says"); + assert!( + err.to_string().contains(HMAC_PROVIDER_KEY), + "the startup check should name the contested name too, got: {err}" + ); + + // A vendor name of its own is unaffected. + let vendor = Ec { + provider: Some(EcProviderSelection::Named("acme".to_owned())), + ..Ec::default() + }; + build_provider(&vendor, None, Some(Arc::new(VendorProvider))) + .expect("a vendor provider under its own name should still build"); + } + + #[test] + fn hmac_provider_requires_store_on_device() { + let provider = HmacProvider::new(test_passphrase()); + let required = provider.required_permissions(); + assert!( + required.contains(Permission::StoreOnDevice), + "the HMAC provider writes a cookie, so it requires necessary.operations.storage" + ); + assert!( + !required.contains(Permission::SelectPersonalisedAds), + "the HMAC provider requires no advertising permissions" + ); + } + + #[tokio::test] + async fn host_signal_provider_mints_from_fingerprints_and_requires_store_on_device() { + let signals = Arc::new(TestHostSignals { + ja4: Some("t13d1516h2_8daaf6152771_e5627efa2ab1".to_owned()), + h2: Some("1:65536;4:6291456".to_owned()), + }); + let provider = HostSignalProvider::new(test_passphrase(), signals); + let request_info = test_request_info(); + let generated = provider + .generate( + &request_info, + &IdentityInput::default(), + &crate::platform::test_support::noop_services(), + ) + .await + .expect("should generate"); + assert!( + generated.id.is_some(), + "the host-signal provider should create an identifier from the signals" + ); + assert!( + provider + .required_permissions() + .contains(Permission::StoreOnDevice), + "the host-signal provider writes a cookie, so it requires necessary.operations.storage" + ); + } + + /// A minimal provider that overrides nothing optional, used to prove the + /// trait defaults. + #[derive(Debug)] + struct MinimalProvider; + + #[async_trait::async_trait(?Send)] + impl EdgeCookieProvider for MinimalProvider { + fn id(&self) -> &'static str { + "minimal" + } + + fn code(&self) -> ProviderCode { + crate::provider_code!("t0mi") + } + + async fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } + + fn accepts_id(&self, _value: &str) -> bool { + true + } + } + + #[test] + fn a_neutral_provider_requires_no_permissions_by_default() { + // MinimalProvider does not override required_permissions, so it + // inherits the trait default of none and requires no permission. + assert!( + MinimalProvider.required_permissions().is_empty(), + "a vendor-neutral provider requires nothing by default" + ); + } + + #[test] + fn the_edge_cookie_gate_blocks_until_the_permission_is_set() { + let required = HmacProvider::new(test_passphrase()).required_permissions(); + // Empty maps with no default: every permission is the requires-signal + // floor. + let maps = PermissionMaps::empty(); + + // No signal: the provider's required permission is not set, so Trusted + // Server would not commit the Edge Cookie. + assert!( + !maps.resolve(None, |_| false).all_set(required), + "the floor should not run the Edge Cookie provider without the permission set" + ); + + // A grant signal for necessary.operations.storage: the provider's permission is now set. + assert!( + maps.resolve(None, |p| p == Permission::StoreOnDevice) + .all_set(required), + "the Edge Cookie provider runs once necessary.operations.storage is set" + ); + } + + #[tokio::test] + async fn client_fixed_defers_in_generate() { + let request_info = test_request_info(); + let generated = ClientFixedProvider + .generate( + &request_info, + &IdentityInput::default(), + &crate::platform::test_support::noop_services(), + ) + .await + .expect("should generate"); + assert!( + generated.id.is_none(), + "client-fixed should defer in generate, deriving no edge identifier" + ); + } + + #[tokio::test] + async fn client_fixed_mints_when_posted_word_matches() { + let input = ClientResolveInput { + payload: EXPECTED_VALUE.as_bytes(), + permissions: None, + consent: None, + }; + let generated = ClientFixedProvider + .resolve_from_client(&input, &crate::platform::test_support::noop_services()) + .await + .expect("should resolve"); + assert_eq!( + generated.id.as_deref(), + Some(EXPECTED_VALUE), + "the known shared word should verify and create the Edge Cookie" + ); + } + + #[tokio::test] + async fn client_fixed_rejects_unknown_word() { + let input = ClientResolveInput { + payload: b"not-the-word", + permissions: None, + consent: None, + }; + let generated = ClientFixedProvider + .resolve_from_client(&input, &crate::platform::test_support::noop_services()) + .await + .expect("should resolve"); + assert!( + generated.id.is_none(), + "a value that does not match the known word should create no Edge Cookie" + ); + } + + #[test] + fn client_fixed_requires_store_on_device() { + assert!( + ClientFixedProvider + .required_permissions() + .contains(Permission::StoreOnDevice), + "client-fixed writes a cookie, so it requires necessary.operations.storage" + ); + } + + #[tokio::test] + async fn server_side_provider_inherits_no_op_resolve_from_client() { + // HmacProvider does not override resolve_from_client, so it inherits the + // no-op default: a server-side provider does not participate in the + // client cycle. + let provider = HmacProvider::new(test_passphrase()); + let input = ClientResolveInput { + payload: b"anything", + permissions: None, + consent: None, + }; + let generated = provider + .resolve_from_client(&input, &crate::platform::test_support::noop_services()) + .await + .expect("should resolve"); + assert!( + generated.id.is_none(), + "a server-side provider inherits the no-op resolve_from_client default" + ); + } + + #[test] + fn the_fixed_word_and_marker_name_match_the_page_script() { + // The demo page script and this provider share the fixed word by + // convention; the resolved-marker cookie name is likewise shared with + // the script. Assert against the script source so a rename on either + // side fails this test instead of silently breaking the round trip. + let script = include_str!( + "../../../trusted-server-js/lib/src/integrations/ec_client_fixed/index.ts" + ); + assert!( + script.contains(&format!("const FIXED_WORD = '{EXPECTED_VALUE}'")), + "the page script's FIXED_WORD should match EXPECTED_VALUE" + ); + assert!( + script.contains(&format!( + "const MARKER_COOKIE_NAME = '{}'", + crate::constants::COOKIE_TS_EC_RESOLVED + )), + "the page script's marker cookie name should match COOKIE_TS_EC_RESOLVED" + ); + // The page module declares the same permission the server-side + // provider requires, and checks it against the state the page is + // handed, so the two declarations must name the same Data Use. + assert!( + script.contains(&format!( + "const REQUIRED_PERMISSION = '{}'", + Permission::StoreOnDevice.as_str() + )), + "the page script's REQUIRED_PERMISSION should match the provider's declaration" + ); + } + + #[tokio::test] + async fn host_signal_provider_defers_without_fingerprints() { + let signals = Arc::new(TestHostSignals { + ja4: None, + h2: None, + }); + let provider = HostSignalProvider::new(test_passphrase(), signals); + let request_info = test_request_info(); + let generated = provider + .generate( + &request_info, + &IdentityInput::default(), + &crate::platform::test_support::noop_services(), + ) + .await + .expect("should generate"); + assert!( + generated.id.is_none(), + "with no host signals the provider should defer rather than create an IP-only identifier" + ); + } + + #[test] + fn a_selected_but_uninjected_vendor_provider_fails_loudly() { + let ec = Ec { + provider: Some(EcProviderSelection::from("acme")), + ..Ec::default() + }; + + let err = build_provider(&ec, None, None) + .expect_err("selecting a provider the adapter does not inject should error"); + assert!( + err.to_string().contains("acme"), + "the error should name the selected provider, got: {err}" + ); + } + + #[test] + fn selecting_hmac_without_its_block_fails_loudly() { + // `Ec::validate_provider_selection` rejects this pair before settings + // reach the composition root, so the state is built directly here to + // reach the seam. If the two checks ever drift apart, `build_provider` + // must still stop rather than hand back a stateless deployment. + let ec = Ec { + provider: Some(EcProviderSelection::from(HMAC_PROVIDER_KEY)), + ..Ec::default() + }; + + let err = build_provider(&ec, None, None) + .expect_err("selecting hmac with no [ec.providers.hmac] block should error"); + assert!( + err.to_string().contains("[ec.providers.hmac]"), + "the error should name the missing block, got: {err}" + ); + } + + #[test] + fn a_provider_built_from_request_evidence_is_never_kept_and_reused() { + // The host-signal provider captures the signals of the request it + // was built for. A composition root builds application state with an + // empty host-signal service, because there is no request yet, so + // keeping that instance would serve every later request from empty + // signals and the provider would defer forever. It must come back + // as nothing to keep, leaving the request path to resolve it against + // the signals each request actually carried. + let mut providers = EcProviders::default(); + providers.host_signals = Some(HostSignalsProviderConfig { + passphrase: test_passphrase(), + }); + let host_signals_selected = Ec { + provider: Some(EcProviderSelection::from(HOST_SIGNALS_PROVIDER_KEY)), + providers, + ..Ec::default() + }; + let startup_signals: Arc = Arc::new(TestHostSignals { + ja4: None, + h2: None, + }); + + // It resolves, so the startup check still passes on a host that + // supplies the service. + let resolved = build_shared_provider( + &host_signals_selected, + Some(Arc::clone(&startup_signals)), + None, + ) + .expect("the host-signal selection should resolve on a host that supplies signals") + .expect("the selection should yield a provider"); + assert!( + resolved.is_request_scoped(), + "the host-signal provider should declare itself built from request evidence" + ); + + // It is not offered for reuse. + assert!( + build_reusable_provider( + &host_signals_selected, + Some(Arc::clone(&startup_signals)), + None + ) + .expect("the host-signal selection should still pass the startup check") + .is_none(), + "a provider built from request evidence must never be kept for later requests" + ); + + // A provider built from configuration alone is still kept, so the + // saving stands for every selection that can take it. + let mut providers = EcProviders::default(); + providers.hmac = Some(HmacProviderConfig { + passphrase: test_passphrase(), + }); + let hmac_selected = Ec { + provider: Some(EcProviderSelection::from(HMAC_PROVIDER_KEY)), + providers, + ..Ec::default() + }; + let kept = build_reusable_provider(&hmac_selected, None, None) + .expect("the hmac selection should resolve") + .expect("a provider built from configuration alone should be kept"); + assert_eq!( + kept.id(), + HMAC_PROVIDER_KEY, + "the kept provider should be the selected one" + ); + } + + #[test] + fn the_request_path_reuses_the_provider_the_composition_root_resolved() { + // A composition root resolves the selection once while it builds + // application state, which is the same work `build_provider` does on a + // request, so doing both means doing it twice for every request. The + // resolved provider is threaded into `RuntimeServices`, and this is the + // assertion that the request path takes it rather than resolving again: + // the same allocation, not merely an equal one. + let ec = Ec { + provider: Some(EcProviderSelection::Named("acme".to_owned())), + ..Ec::default() + }; + let resolved = build_reusable_provider(&ec, None, Some(Arc::new(VendorProvider))) + .expect("the composition root should resolve the selection") + .expect("the selection should yield a provider"); + + let services = crate::platform::test_support::noop_services_with_resolved_ec_provider( + Arc::clone(&resolved), + ); + let for_request = request_provider(&ec, &services) + .expect("the request path should take the resolved provider") + .expect("the resolved provider should be there"); + + assert!( + Arc::ptr_eq(&resolved, &for_request), + "the request path should reuse the resolved provider, not build a second one" + ); + + // An adapter that threads nothing still resolves for itself, so core + // driven directly behaves exactly as it did before. + let unthreaded = + crate::platform::test_support::noop_services_with_ec_provider(Arc::new(VendorProvider)); + let built = request_provider(&ec, &unthreaded) + .expect("an unthreaded adapter should resolve on the request path") + .expect("the selection should yield a provider"); + assert_eq!( + built.id(), + "acme", + "resolving on the request path should still select the injected provider" + ); + } + + #[test] + fn the_startup_check_rejects_an_uninjected_provider_and_allows_statelessness() { + // A selection the adapter cannot supply is knowable without a request, + // so the composition root rejects it while application state is built. + let selected = Ec { + provider: Some(EcProviderSelection::from("acme")), + ..Ec::default() + }; + let err = ensure_provider_available(&selected, None, None) + .expect_err("an uninjected provider should fail the startup check"); + assert!( + err.to_string().contains("acme"), + "the error should name the selected provider, got: {err}" + ); + + // Statelessness is a supported deployment, spelled either way, and must + // never be turned into a startup error. + ensure_provider_available(&Ec::default(), None, None) + .expect("should allow a deployment that selects no provider"); + let explicit_none = Ec { + provider: Some(EcProviderSelection::None), + ..Ec::default() + }; + ensure_provider_available(&explicit_none, None, None) + .expect("should allow the explicit `none` selection"); + } + + #[test] + fn the_startup_check_rejects_host_signals_on_a_host_that_supplies_none() { + // Whether the adapter injects a host-signal service is fixed per + // deployment, so selecting the host-signal provider on an adapter that + // injects none is knowable without a request. + let mut providers = EcProviders::default(); + providers.host_signals = Some(HostSignalsProviderConfig { + passphrase: test_passphrase(), + }); + let selected = Ec { + provider: Some(EcProviderSelection::from(HOST_SIGNALS_PROVIDER_KEY)), + providers, + ..Ec::default() + }; + + let err = ensure_provider_available(&selected, None, None).expect_err( + "the host-signal provider should fail the startup check with no host signals", + ); + assert!( + err.to_string().contains("TLS/HTTP-2 signals"), + "the error should say the host supplies no signals, got: {err}" + ); + + let signals: Arc = Arc::new(TestHostSignals { + ja4: None, + h2: None, + }); + ensure_provider_available(&selected, Some(signals), None).expect( + "should pass on a host that injects host signals, whatever this request's signals are", + ); + } + + #[test] + fn the_configuration_check_and_the_resolution_agree_on_a_provider_with_no_block() { + // The demonstration provider is configured correctly with no + // `[ec.providers.*]` block at all, so a check that asked the settings + // whether a block was present rejected a valid deployment. The check + // asks the resolution instead, and the two must give the same answer, + // because a startup check that passes what the construction then + // refuses leaves the deployment failing on its first request. + let ec = Ec { + provider: Some(EcProviderSelection::from(CLIENT_FIXED_PROVIDER_KEY)), + ..Ec::default() + }; + + ec.validate_provider_selection() + .expect("client-fixed should validate with no configuration block"); + + let built = build_provider(&ec, None, None) + .expect("client-fixed should build with no configuration and no services") + .expect("client-fixed should yield a provider"); + assert_eq!( + built.id(), + CLIENT_FIXED_PROVIDER_KEY, + "the built provider should be the one the selector names" + ); + } + + #[test] + fn a_name_that_needs_a_block_still_fails_without_one() { + // Taking the block question out of the settings must not weaken it for + // the names that do need a block. + let ec = Ec { + provider: Some(EcProviderSelection::from(HMAC_PROVIDER_KEY)), + ..Ec::default() + }; + + let err = ec + .validate_provider_selection() + .expect_err("hmac with no block should still fail at startup"); + assert!( + err.to_string().contains("[ec.providers.hmac]"), + "the error should name the missing block, got: {err}" + ); + } + + #[test] + fn a_block_left_configured_alongside_a_blockless_provider_is_still_rejected() { + // The unreferenced-block rule does not soften for a provider that + // needs no block of its own: a stale block is still a mistake. + let mut providers = EcProviders::default(); + providers.hmac = Some(HmacProviderConfig { + passphrase: test_passphrase(), + }); + let ec = Ec { + provider: Some(EcProviderSelection::from(CLIENT_FIXED_PROVIDER_KEY)), + providers, + ..Ec::default() + }; + + let err = ec + .validate_provider_selection() + .expect_err("a stray hmac block should still be rejected"); + assert!( + err.to_string().contains("hmac"), + "the error should name the unreferenced block, got: {err}" + ); + } +} diff --git a/crates/trusted-server-core/src/ec/pull_sync.rs b/crates/trusted-server-core/src/ec/pull_sync.rs index 546605f8e..1184e7e59 100644 --- a/crates/trusted-server-core/src/ec/pull_sync.rs +++ b/crates/trusted-server-core/src/ec/pull_sync.rs @@ -19,7 +19,7 @@ use crate::platform::{ }; use crate::settings::Settings; -use super::generation::{ec_hash, is_valid_ec_id}; +use super::generation::ec_hash; use super::kv::KvIdentityGraph; use super::kv_types::KvEntry; use super::rate_limiter::RateLimiter; @@ -55,16 +55,26 @@ struct PullSyncResponse { /// Builds post-send pull-sync context from the route EC context. /// -/// Returns `None` when consent denies EC or there is no active EC ID. +/// Returns `None` when sharing is not permitted or there is no active EC ID. +/// Pull sync sends the identifier to a partner, so it needs the same +/// permission pair as bidstream EIDs (storage plus personalised-ad +/// selection), not only the provider's storage permission. #[must_use] pub fn build_pull_sync_context(ec_context: &EcContext) -> Option { - if !ec_context.ec_allowed() { + if !ec_context.ec_sharing_allowed() { return None; } + // Accept an identifier from whichever provider this deployment reads, + // dispatched by the identifier's provider code, rather than only the + // built-in HMAC shape. A host-signal or vendor provider's identifiers are + // valid here for the same reason they are valid in the organic path. let ec_id_ref = ec_context.ec_value()?; - if !is_valid_ec_id(ec_id_ref) { - log::debug!("Pull sync: skipping dispatch because active EC ID is invalid format"); + if !ec_context.accepts_id(ec_id_ref) { + log::debug!( + "Pull sync: skipping dispatch because the active EC ID is not one this \ + deployment's providers accept" + ); return None; } @@ -499,6 +509,88 @@ mod tests { ); } + /// A non-HMAC provider whose identifiers are opaque, modeling the + /// host-signal provider PR #1044 adds: valid identifiers that the built-in + /// HMAC grammar rejects outright. + #[derive(Debug)] + struct OpaqueProvider; + + #[async_trait::async_trait(?Send)] + impl crate::ec::provider::EdgeCookieProvider for OpaqueProvider { + fn id(&self) -> &'static str { + "opaque" + } + + fn code(&self) -> crate::ec::provider::ProviderCode { + crate::provider_code!("t0op") + } + + async fn generate( + &self, + _request_info: &dyn crate::evidence::RequestInfo, + _input: &crate::ec::provider::IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, + ) -> Result< + crate::ec::provider::GeneratedEdgeCookie, + error_stack::Report, + > { + Ok(crate::ec::provider::GeneratedEdgeCookie::default()) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + } + + #[test] + fn build_pull_sync_context_accepts_the_active_non_hmac_provider() { + // A deployment whose active provider is not the built-in HMAC one must + // still dispatch pull sync for the identifiers that provider created. + // The built-in grammar rejected every non-`hmac` code, so these + // identifiers worked in the organic path and were silently skipped + // here. + const OPAQUE_ID: &str = "t0op~Opaque_Value_MixedCase"; + + let mut settings = crate::test_support::tests::create_test_settings(); + settings.ec.provider = Some(crate::ec::provider::EcProviderSelection::from("opaque")); + let services = crate::platform::test_support::noop_services_with_ec_provider( + std::sync::Arc::new(OpaqueProvider), + ); + let req = http::Request::builder() + .method("GET") + .uri("http://example.com") + .header("cookie", format!("ts-ec={OPAQUE_ID}")) + .body(EdgeBody::empty()) + .expect("should build test request"); + let geo = crate::geo::GeoInfo { + city: String::new(), + country: "US".to_owned(), + continent: "NorthAmerica".to_owned(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: None, + asn: None, + }; + + let ec_context = + EcContext::read_from_request_with_geo(&settings, &req, &services, Some(&geo)) + .expect("should read EC context"); + assert_eq!( + ec_context.ec_value(), + Some(OPAQUE_ID), + "the opaque identifier should read back before pull sync sees it" + ); + + let context = build_pull_sync_context(&ec_context) + .expect("should dispatch pull sync for the active provider's identifier"); + assert_eq!( + context.ec_id(), + OPAQUE_ID, + "should carry the identifier through unchanged" + ); + } + #[test] fn build_pull_sync_context_rejects_invalid_ec_id() { let consent = ConsentContext { @@ -711,4 +803,23 @@ mod tests { "hour 1 rotation should move beta to front" ); } + + #[test] + fn build_pull_sync_context_accepts_a_minted_coded_ec_id() { + let consent = ConsentContext { + jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, + ..ConsentContext::default() + }; + // The form the creation path produces since the provider-code envelope. + let ec_id = format!("hmac~{}.ABC123", "a".repeat(64)); + let ec_context = EcContext::new_for_test(Some(ec_id.clone()), consent); + + let context = build_pull_sync_context(&ec_context) + .expect("should build pull sync context for a coded HMAC identifier"); + assert_eq!( + context.ec_id(), + ec_id, + "should dispatch the coded identifier as created" + ); + } } diff --git a/crates/trusted-server-core/src/ec/resolve.rs b/crates/trusted-server-core/src/ec/resolve.rs new file mode 100644 index 000000000..aa16afd66 --- /dev/null +++ b/crates/trusted-server-core/src/ec/resolve.rs @@ -0,0 +1,1129 @@ +//! Client-cycle Edge Cookie resolution endpoint (`POST /_ts/api/v1/ec/resolve`). +//! +//! A client-side Edge Cookie provider defers on the organic page request +//! (deriving no identifier at the edge) and lets the page do the work in the +//! browser. When the page has its result it posts the value here, and this +//! endpoint hands it to the configured provider's +//! [`resolve_from_client`](super::provider::EdgeCookieProvider::resolve_from_client) +//! to create the Edge Cookie. +//! +//! The endpoint is provider-agnostic: it bounds the body, gates on the +//! permission model (the same gate as organic generation), calls the provider, +//! and sets the cookie on its own response so the value is live for every +//! subsequent first-party request. Whether the posted value is trustworthy is +//! the provider's responsibility. The payload arrives from the browser, so a +//! real provider verifies it (for example an OWID signature) before creating one. + +use edgezero_core::body::Body as EdgeBody; +use error_stack::Report; +use http::{HeaderValue, Request, Response, StatusCode, header}; + +use crate::error::TrustedServerError; +use crate::settings::Settings; + +use super::EcContext; +use super::cookies::{ + ec_id_has_only_allowed_chars, set_provider_ec_cookie, set_resolved_marker_cookie, +}; +use super::kv::KvIdentityGraph; +use super::kv_types::KvEntry; +use super::provider::{ClientResolveInput, apply_provider_response_headers, build_provider}; + +/// Maximum size of a resolve request body. +/// +/// Client-cycle payloads (a random value, or a signed envelope such as a +/// vendor JSON payload) are small; this bound guards against an oversized body +/// before it is read into memory. +const MAX_BODY_SIZE: usize = 64 * 1024; + +/// Handles `POST /_ts/api/v1/ec/resolve`. +/// +/// The request must carry an `Origin` on the publisher's domain (this endpoint +/// sets identity state, so a foreign page must not be able to drive it) and a +/// `text/plain` or `application/json` body. Gates on the configured provider's +/// required permissions, then asks the provider to create an Edge Cookie from +/// the posted payload. A created identifier is persisted to the identity graph +/// before the cookie is set, so withdrawal reaches a client-set identity the +/// same way it reaches an edge-created one. With no graph available this +/// endpoint sets no cookie at all, which is stricter than the organic +/// generation path, where the identifier is committed and only the row write +/// is skipped. On +/// success the EC cookie and its `non-HttpOnly` resolved marker are set and the +/// status is `200`. +/// +/// Rejections: `403` for a missing or foreign `Origin`, `415` for another +/// content type, `413` for an oversized body, `400` when the provider creates an +/// identifier outside the identifier bounds, `409` when the request already +/// carries a different identity (a resolve must not silently replace one), and +/// `503` when the identity-graph write fails. When the permission gate is +/// closed, no provider is configured, no graph is available, or the provider +/// creates nothing, the response is `204` with no cookie. Every response this +/// handler builds carries `Cache-Control: no-store`; a provider or +/// configuration error propagates to the adapter's error response instead, +/// and so does a provider asking for a response header inside core's reserved +/// surface, which is a broken provider contract rather than a bad request. +/// +/// # Errors +/// +/// Returns [`TrustedServerError`] when the provider fails to process the +/// payload, or asks for a response header inside core's reserved surface +/// (see +/// [`reserved_response_effect`](crate::ec::provider::reserved_response_effect)). +/// A payload that is merely unverified or absent yields a `204` rather than an +/// error. +pub async fn handle_ec_resolve( + settings: &Settings, + req: Request, + ec_context: &EcContext, + kv: Option<&KvIdentityGraph>, + services: &crate::platform::RuntimeServices, +) -> Result, Report> { + // This endpoint sets identity state from a page script, so the posted + // request must originate from the publisher's own site. Browsers always + // send `Origin` on cross-origin POSTs and on same-origin `fetch` POSTs, + // so its absence means a non-browser caller, which has no business here. + if !origin_is_publisher(&req, settings) { + log::warn!("EC resolve rejected: missing or foreign Origin"); + return Ok(status_only(StatusCode::FORBIDDEN)); + } + + // The page script posts plain text; a vendor payload may be JSON. Anything + // else is not a resolve payload. + if !content_type_is_allowed(&req) { + return Ok(status_only(StatusCode::UNSUPPORTED_MEDIA_TYPE)); + } + + // Gate: the configured provider's required permissions must be set for + // this request, the same gate as the organic generation path. A + // client-driven resolve does not bypass the permission model. + if !ec_context.ec_allowed() { + log::info!("EC resolve skipped: required permissions not set"); + return Ok(status_only(StatusCode::NO_CONTENT)); + } + + // Rebuild the provider with the same host signals captured on the context, so + // a provider that needs a service the host cannot supply fails here. The + // client value is verified from the posted body below, not from request info. + let Some(provider) = build_provider( + &settings.ec, + ec_context.host_signals(), + ec_context.ec_provider(), + )? + else { + log::info!("EC resolve skipped: no Edge Cookie provider configured"); + return Ok(status_only(StatusCode::NO_CONTENT)); + }; + + // Bound the body before reading it into memory. + if content_length_exceeds_limit(&req, MAX_BODY_SIZE) { + return Ok(status_only(StatusCode::PAYLOAD_TOO_LARGE)); + } + let payload = req.into_body().into_bytes().unwrap_or_default(); + if payload.len() > MAX_BODY_SIZE { + return Ok(status_only(StatusCode::PAYLOAD_TOO_LARGE)); + } + + let input = ClientResolveInput { + payload: payload.as_ref(), + permissions: Some(ec_context.permissions()), + consent: Some(ec_context.consent()), + }; + + let generated = provider.resolve_from_client(&input, services).await?; + log::debug!( + "EC resolve handled (provider={}): id {}", + provider.id(), + if generated.id.is_some() { + "created" + } else { + "not created" + }, + ); + + // Check every response header the provider asked for against core's + // reserved surface, exactly as the organic generation path does in + // `EcContext::generate_with_provider`. A provider may set its own cookies + // and headers, but not a managed `ts-` cookie, a header in the `x-ts-` + // namespace, or a framing or hop-by-hop header. Without this a + // browser-side provider could set `ts-ec` itself and walk straight past + // the identifier bounds, the conflict check and the row-before-cookie + // rule below. The check sits before the identifier is read because a + // provider can return headers with no identifier at all, which is the + // 204 path, and that path applies headers too. + for (name, value) in &generated.response_headers { + if let Some(effect) = super::provider::reserved_response_effect(name, value) { + return Err(Report::new(TrustedServerError::EdgeCookie { + message: format!( + "Provider `{}` returned a response header `{name}` that {effect}", + provider.id(), + ), + })); + } + } + + let generated_id = generated + .id + .map(|value| super::provider::apply_provider_code(provider.as_ref(), &value)); + let Some(ec_id) = generated_id else { + let mut response = status_only(StatusCode::NO_CONTENT); + apply_provider_response_headers(response.headers_mut(), generated.response_headers); + return Ok(response); + }; + + // The same identifier bounds as the organic generation path: reject, never + // rewrite. A provider that created an out-of-bounds identifier is a bad + // request from the client's perspective, because the posted payload + // produced an unusable identity. + if !ec_id_has_only_allowed_chars(&ec_id) { + log::error!( + "EC resolve rejected: provider `{}` created an identifier outside the bounds", + provider.id(), + ); + return Ok(status_only(StatusCode::BAD_REQUEST)); + } + + // A resolve must not silently replace an identity the request already + // carries. The page script does not post when an identity exists, so a + // different identifier here is a conflict to surface, not paper + // over. + if let Some(existing) = ec_context.ec_value() + && existing != ec_id + { + log::warn!( + "EC resolve rejected: request already carries a different identity (provider={})", + provider.id(), + ); + return Ok(status_only(StatusCode::CONFLICT)); + } + + // Persist the identity-graph row before setting the cookie, keyed by the + // provider's canonical form, exactly like the organic generation path. + // Without a row, withdrawal could never reach this identity, so with no + // graph available this endpoint creates no cookie at all, which is stricter + // than the organic generation path, where the identifier is committed and + // only the row write is skipped. + let Some(graph) = kv else { + log::warn!("EC resolve skipped: no identity graph available, so no cookie is created"); + return Ok(status_only(StatusCode::NO_CONTENT)); + }; + let now = super::current_timestamp(); + let mut entry = KvEntry::new( + ec_context.consent(), + ec_context.geo_info(), + now, + &settings.publisher.domain, + ); + entry.device = ec_context + .device_signals() + .map(super::device::DeviceSignals::to_kv_device); + let kv_key = super::provider::provider_kv_key(provider.as_ref(), &ec_id); + if let Err(err) = graph.create_or_revive(&kv_key, &entry) { + log::error!("EC resolve failed to write the identity-graph row: {err:?}"); + return Ok(status_only(StatusCode::SERVICE_UNAVAILABLE)); + } + + let mut response = status_only(StatusCode::OK); + + // Apply any response headers the provider asked for (for example to request + // more client evidence on a later request). Empty for the demo provider. + // They accumulate with what this handler already set rather than replacing + // it, for the reasons on `provider::apply_provider_response_headers`; here + // that keeps the `Cache-Control: no-store` every identity response must + // carry, which a replacing write would drop. + apply_provider_response_headers(response.headers_mut(), generated.response_headers); + + set_provider_ec_cookie(settings, &mut response, &ec_id); + // The Edge Cookie is HttpOnly, so the page script cannot see it; the + // non-HttpOnly marker tells the script the resolve succeeded so it does + // not post again on every page view. + set_resolved_marker_cookie(settings, &mut response); + + Ok(response) +} + +/// Whether the request's `Origin` is one this deployment authorizes to set +/// identity. +/// +/// The comparison is the same-origin test of RFC 6454 §5, so two origins +/// match only when their scheme, host and port triples (RFC 6454 §4) are +/// equal, with a missing port standing for the scheme's default. See +/// [`origins_match`]. `https://{publisher.domain}` is always accepted and +/// `[ec] resolve_allowed_origins` adds further origins. A subdomain of the publisher is not accepted unless it is listed, +/// because the Edge Cookie is scoped to the parent domain, so a delegated or +/// compromised sibling host would otherwise be able to fix an identity that +/// lands on the apex and every sibling with it. +/// +/// This is defense in depth rather than the primary control. The primary +/// control is the provider's own verification of the value it is handed. +fn origin_is_publisher(req: &Request, settings: &Settings) -> bool { + let Some(origin) = req + .headers() + .get(header::ORIGIN) + .and_then(|value| value.to_str().ok()) + else { + return false; + }; + let origin = origin.trim(); + + let default_origin = format!("https://{}", settings.publisher.domain); + if origins_match(origin, &default_origin) { + return true; + } + settings + .ec + .resolve_allowed_origins + .iter() + .any(|allowed| origins_match(origin, allowed)) +} + +/// Whether two serialized origins are the same origin under RFC 6454. +/// +/// Each side must be a serialized origin as RFC 6454 §6.1 defines it, being +/// a scheme, `://` and a host with an optional port and nothing after it, so a +/// value carrying a path, query or fragment is not an origin and never +/// matches. The two are then compared as the scheme, host and port triple of +/// RFC 6454 §4, which is the same-origin test of §5. The scheme and host are +/// case-insensitive and a missing port stands for the scheme's default, so a +/// configured `https://www.example.com:443` matches the `https://www.example.com` +/// a browser sends, while `http://` or another port never matches. The +/// opaque `null` origin (RFC 6454 §6.2) is never the same as anything. +fn origins_match(candidate: &str, allowed: &str) -> bool { + let parse = |origin: &str| -> Option { + let url = url::Url::parse(origin).ok()?; + let is_bare_origin = url.path() == "/" + && url.query().is_none() + && url.fragment().is_none() + && !origin.trim_end().ends_with('/'); + if !is_bare_origin { + return None; + } + let origin = url.origin(); + origin.is_tuple().then_some(origin) + }; + match (parse(candidate), parse(allowed)) { + (Some(candidate), Some(allowed)) => candidate == allowed, + _ => false, + } +} + +/// Whether the request's `Content-Type` is one a resolve payload may use. +fn content_type_is_allowed(req: &Request) -> bool { + req.headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| value.split(';').next().unwrap_or(value).trim()) + .is_some_and(|media_type| { + media_type.eq_ignore_ascii_case("text/plain") + || media_type.eq_ignore_ascii_case("application/json") + }) +} + +/// Builds a bodiless response with the given status. Identity-resolution +/// responses must never be cached by the browser or an intermediary. +fn status_only(status: StatusCode) -> Response { + let mut response = Response::new(EdgeBody::empty()); + *response.status_mut() = status; + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + response +} + +/// Returns `true` when the request advertises a `Content-Length` over `limit`. +fn content_length_exceeds_limit(req: &Request, limit: usize) -> bool { + req.headers() + .get(header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .is_some_and(|len| len > limit) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::consent::types::ConsentContext; + use crate::ec::provider::{ + CLIENT_FIXED_PROVIDER_KEY, EcProviderSelection, EdgeCookieProvider, GeneratedEdgeCookie, + IdentityInput, + }; + use crate::evidence::RequestInfo; + use crate::platform::test_support::{noop_services, noop_services_with_ec_provider}; + use crate::test_support::tests::create_test_settings; + use http::Method; + use std::sync::Arc; + + fn settings_with_client_fixed() -> Settings { + let mut settings = create_test_settings(); + settings.ec.provider = Some(EcProviderSelection::from(CLIENT_FIXED_PROVIDER_KEY)); + settings + } + + // The fixed word shared by the client-fixed provider and its page script. + const FIXED_WORD: &str = "an-ec"; + + fn post(body: &str) -> Request { + post_with(Some("https://test-publisher.com"), Some("text/plain"), body) + } + + fn post_with( + origin: Option<&str>, + content_type: Option<&str>, + body: &str, + ) -> Request { + let mut builder = Request::builder() + .method(Method::POST) + .uri("https://test-publisher.com/_ts/api/v1/ec/resolve"); + if let Some(origin) = origin { + builder = builder.header(header::ORIGIN, origin); + } + if let Some(content_type) = content_type { + builder = builder.header(header::CONTENT_TYPE, content_type); + } + builder + .body(EdgeBody::from(body.to_owned())) + .expect("should build resolve request") + } + + fn in_memory_graph() -> crate::ec::kv::KvIdentityGraph { + crate::ec::kv::KvIdentityGraph::in_memory("test-ec-store") + } + + fn gated(ec_allowed: bool) -> EcContext { + EcContext::new_for_test_gated(None, ConsentContext::default(), ec_allowed) + } + + /// Returns whether `value` is a canonical UUID (`8-4-4-4-12` lowercase hex). + fn is_uuid(value: &str) -> bool { + let groups = [8, 4, 4, 4, 12]; + let parts: Vec<&str> = value.split('-').collect(); + parts.len() == groups.len() + && parts.iter().zip(groups).all(|(part, len)| { + part.len() == len + && part + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + }) + } + + /// A **test-only** provider modeling a client-generated, first-party + /// identifier (a `UUID`) that the browser creates and posts back for the server + /// to set. It is not a production provider and exists only to exercise the + /// client-set Edge Cookie value path from end to end. The edge defers, the + /// page posts a value, and it must round-trip as the cookie and the KV key. + /// + /// A `UUID` has no separator, so the built-in [`normalize_id_for_kv`] default + /// would append a trailing dot and corrupt it, which is why this provider + /// (like any opaque-identifier provider) returns the value unchanged. + /// + /// [`normalize_id_for_kv`]: EdgeCookieProvider::normalize_id_for_kv + #[derive(Debug)] + struct TestIdProvider; + + #[async_trait::async_trait(?Send)] + impl EdgeCookieProvider for TestIdProvider { + fn id(&self) -> &'static str { + "testid" + } + + fn code(&self) -> crate::ec::provider::ProviderCode { + crate::provider_code!("t0id") + } + + async fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, + ) -> Result> { + // The identifier is created in the browser, so the edge derives nothing. + Ok(GeneratedEdgeCookie::default()) + } + + async fn resolve_from_client( + &self, + input: &ClientResolveInput<'_>, + _services: &crate::platform::RuntimeServices, + ) -> Result> { + // The page posts the identifier it generated. Accept a well-formed UUID. + let value = core::str::from_utf8(input.payload) + .unwrap_or_default() + .trim(); + if is_uuid(value) { + Ok(GeneratedEdgeCookie { + id: Some(value.to_owned()), + response_headers: Vec::new(), + }) + } else { + Ok(GeneratedEdgeCookie::default()) + } + } + + fn accepts_id(&self, value: &str) -> bool { + is_uuid(value) + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + value.to_owned() + } + } + + /// The identifier [`ResolveHeaderProvider`] creates when asked to. + const HEADER_PROVIDER_ID: &str = "5c3a1b70-2f4d-4a19-9c6e-7b0d18e4a221"; + + /// A **test-only** provider that returns caller-chosen response headers + /// from the client-resolve path, so a test can drive one provider response + /// effect at a time through this endpoint, with and without an identifier. + #[derive(Debug)] + struct ResolveHeaderProvider { + headers: &'static [(&'static str, &'static str)], + mint: bool, + } + + #[async_trait::async_trait(?Send)] + impl EdgeCookieProvider for ResolveHeaderProvider { + fn id(&self) -> &'static str { + "resolve-header" + } + + fn code(&self) -> crate::ec::provider::ProviderCode { + crate::provider_code!("t0rh") + } + + async fn generate( + &self, + _request_info: &dyn RequestInfo, + _input: &IdentityInput<'_>, + _services: &crate::platform::RuntimeServices, + ) -> Result> { + Ok(GeneratedEdgeCookie::default()) + } + + async fn resolve_from_client( + &self, + _input: &ClientResolveInput<'_>, + _services: &crate::platform::RuntimeServices, + ) -> Result> { + Ok(GeneratedEdgeCookie { + id: self.mint.then(|| HEADER_PROVIDER_ID.to_owned()), + response_headers: self + .headers + .iter() + .map(|(name, value)| { + ( + http::HeaderName::from_bytes(name.as_bytes()) + .expect("should parse header name"), + HeaderValue::from_static(value), + ) + }) + .collect(), + }) + } + + fn accepts_id(&self, value: &str) -> bool { + !value.is_empty() + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + value.to_owned() + } + } + + /// Drives one resolve request through [`ResolveHeaderProvider`], returning + /// whatever the handler produced. + async fn resolve_with_header_provider( + headers: &'static [(&'static str, &'static str)], + mint: bool, + graph: Option<&crate::ec::kv::KvIdentityGraph>, + ) -> Result, Report> { + let mut settings = create_test_settings(); + settings.ec.provider = Some(EcProviderSelection::from("resolve-header")); + let services = + noop_services_with_ec_provider(Arc::new(ResolveHeaderProvider { headers, mint })); + let organic = Request::builder() + .method(Method::GET) + .uri("https://edge.example.com/") + .body(EdgeBody::empty()) + .expect("should build organic request"); + let ec = EcContext::read_from_request(&settings, &organic, &services) + .expect("should read EC context"); + handle_ec_resolve(&settings, post(HEADER_PROVIDER_ID), &ec, graph, &services).await + } + + #[tokio::test] + async fn resolve_rejects_a_reserved_response_effect_when_nothing_is_minted() { + // The 204 path applied provider headers with no check at all, so a + // browser-side provider could set the managed identity cookie while + // returning no identifier, walking past the identifier bounds, the + // conflict check and the row-before-cookie rule below it. + let outcome = resolve_with_header_provider( + &[("set-cookie", "ts-ec=forged-value; Path=/")], + false, + None, + ); + + let err = outcome + .await + .expect_err("a managed cookie effect should fail the request"); + assert!( + format!("{err:?}").contains("ts-` namespace"), + "the failure should name the reserved effect, got {err:?}" + ); + } + + #[tokio::test] + async fn resolve_rejects_a_reserved_response_effect_on_the_minted_path() { + // The same check has to cover the 200 path, where the provider does + // create and core is about to write its own cookie and headers. + let graph = in_memory_graph(); + let outcome = + resolve_with_header_provider(&[("x-ts-ec", "forged-value")], true, Some(&graph)); + + let err = outcome + .await + .expect_err("a reserved header effect should fail the request"); + assert!( + format!("{err:?}").contains("x-ts-` namespace"), + "the failure should name the reserved effect, got {err:?}" + ); + } + + #[tokio::test] + async fn resolve_accumulates_provider_response_headers_with_its_own() { + // The provider's own effects must add to what the handler already set, + // never replace it. Replacing collapsed a provider's own cookie list + // to whichever came last, and would drop the `Cache-Control: no-store` + // that every identity response has to carry. + let graph = in_memory_graph(); + let response = resolve_with_header_provider( + &[ + ("set-cookie", "vendor-ev=abc; Path=/"), + ("set-cookie", "vendor-state=xyz; Path=/"), + ], + true, + Some(&graph), + ) + .await + .expect("should handle resolve"); + + assert_eq!( + response.status(), + StatusCode::OK, + "a created identifier should return 200" + ); + + let cookies: Vec<&str> = response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|value| value.to_str().expect("should render set-cookie as utf-8")) + .collect(); + for expected in ["vendor-ev=abc", "vendor-state=xyz", "ts-ec=", "ts-ecr=1"] { + assert!( + cookies.iter().any(|cookie| cookie.starts_with(expected)), + "`{expected}` should survive on the response, got {cookies:?}" + ); + } + + let cache_control: Vec<&str> = response + .headers() + .get_all(header::CACHE_CONTROL) + .iter() + .map(|value| { + value + .to_str() + .expect("should render cache-control as utf-8") + }) + .collect(); + assert!( + cache_control.contains(&"no-store"), + "a provider header must not drop the no-store an identity response carries, got {cache_control:?}" + ); + } + + #[tokio::test] + async fn client_set_value_round_trips_through_the_ec_scenario() { + // A client-generated first-party UUID, the value the browser posts back. + const TEST_ID: &str = "3f2504e0-4f89-41d3-9a0c-0305e82c3301"; + + let provider: Arc = Arc::new(TestIdProvider); + let mut settings = create_test_settings(); + settings.ec.provider = Some(EcProviderSelection::from("testid")); + let services = noop_services_with_ec_provider(Arc::clone(&provider)); + + // 1. Organic first visit: no EC yet, and the edge defers because the + // identifier is generated in the browser, not derived server-side. + let organic = Request::builder() + .method(Method::GET) + .uri("https://edge.example.com/") + .body(EdgeBody::empty()) + .expect("should build organic request"); + let mut ec = EcContext::read_from_request(&settings, &organic, &services) + .expect("should read EC context"); + assert!( + ec.ec_value().is_none(), + "no EC should exist on the first visit" + ); + ec.generate_if_needed(&settings, None, &services) + .await + .expect("should run generation"); + assert!( + ec.ec_value().is_none(), + "a client-set provider defers creation to the browser" + ); + + // 2. The page generates its identifier and posts it to the resolve + // endpoint; the server persists the identity-graph row and sets the + // value as the EC cookie. + const CODED_TEST_ID: &str = "t0id~3f2504e0-4f89-41d3-9a0c-0305e82c3301"; + let graph = in_memory_graph(); + let response = handle_ec_resolve(&settings, post(TEST_ID), &ec, Some(&graph), &services) + .await + .expect("should handle resolve"); + assert_eq!( + response.status(), + StatusCode::OK, + "a valid client-set value should return 200" + ); + let set_cookie = response + .headers() + .get(header::SET_COOKIE) + .expect("should set the EC cookie") + .to_str() + .expect("should be utf-8"); + assert!( + set_cookie.contains(CODED_TEST_ID), + "the EC cookie should carry the coded client-set identifier, got {set_cookie}" + ); + assert!( + graph + .get(CODED_TEST_ID) + .expect("should read the graph") + .is_some(), + "the resolve should persist the identity-graph row, so withdrawal can reach it" + ); + + // 3. A later request carries the EC cookie; the server reads the + // identifier back verbatim. This is the step the built-in shape check + // used to drop. + let ret = Request::builder() + .method(Method::GET) + .uri("https://edge.example.com/") + .header("cookie", format!("ts-ec={CODED_TEST_ID}")) + .body(EdgeBody::empty()) + .expect("should build return request"); + let ec2 = EcContext::read_from_request(&settings, &ret, &services) + .expect("should read EC context"); + assert_eq!( + ec2.ec_value(), + Some(CODED_TEST_ID), + "the client-set identifier should round-trip as the coded EC value" + ); + } + + #[tokio::test] + async fn resolve_sets_cookie_marker_and_no_store_when_word_matches_and_allowed() { + let settings = settings_with_client_fixed(); + let graph = in_memory_graph(); + let response = handle_ec_resolve( + &settings, + post(FIXED_WORD), + &gated(true), + Some(&graph), + &noop_services(), + ) + .await + .expect("should handle resolve"); + + assert_eq!( + response.status(), + StatusCode::OK, + "a verified value should return 200" + ); + let cookies: Vec = response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|value| value.to_str().expect("should be utf-8").to_owned()) + .collect(); + let ec_cookie = cookies + .iter() + .find(|cookie| cookie.starts_with("ts-ec=")) + .expect("should set the EC cookie"); + assert!( + ec_cookie.contains("cfix~an-ec"), + "should set the coded verified word as the EC cookie, got {ec_cookie}" + ); + assert!( + ec_cookie.contains("HttpOnly"), + "the EC cookie should be HttpOnly" + ); + assert!( + ec_cookie.contains("Secure"), + "the EC cookie should be Secure" + ); + let marker = cookies + .iter() + .find(|cookie| cookie.starts_with("ts-ecr=1")) + .expect("should set the resolved marker cookie"); + assert!( + !marker.contains("HttpOnly"), + "the marker must be readable by the page script, so not HttpOnly" + ); + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-store"), + "identity responses must never be cached" + ); + assert!( + graph + .get("cfix~an-ec") + .expect("should read the graph") + .is_some(), + "the resolve should persist the identity-graph row under the coded key" + ); + } + + #[tokio::test] + async fn resolve_returns_204_when_not_allowed() { + let settings = settings_with_client_fixed(); + let graph = in_memory_graph(); + let response = handle_ec_resolve( + &settings, + post(FIXED_WORD), + &gated(false), + Some(&graph), + &noop_services(), + ) + .await + .expect("should handle resolve"); + + assert_eq!( + response.status(), + StatusCode::NO_CONTENT, + "a closed permission gate should return 204" + ); + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "a closed gate should set no cookie" + ); + } + + #[tokio::test] + async fn resolve_returns_204_when_no_provider_configured() { + let mut settings = create_test_settings(); + settings.ec.provider = None; + let graph = in_memory_graph(); + let response = handle_ec_resolve( + &settings, + post("123"), + &gated(true), + Some(&graph), + &noop_services(), + ) + .await + .expect("should handle resolve"); + + assert_eq!( + response.status(), + StatusCode::NO_CONTENT, + "no configured provider should return 204" + ); + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "no configured provider should set no cookie" + ); + } + + #[tokio::test] + async fn resolve_rejects_oversized_body() { + let settings = settings_with_client_fixed(); + let big = "x".repeat(MAX_BODY_SIZE + 1); + let graph = in_memory_graph(); + let response = handle_ec_resolve( + &settings, + post(&big), + &gated(true), + Some(&graph), + &noop_services(), + ) + .await + .expect("should handle resolve"); + + assert_eq!( + response.status(), + StatusCode::PAYLOAD_TOO_LARGE, + "an oversized body should be rejected with 413" + ); + } + + #[tokio::test] + async fn resolve_rejects_a_missing_or_foreign_origin() { + let settings = settings_with_client_fixed(); + let graph = in_memory_graph(); + for origin in [None, Some("https://attacker.example")] { + let request = post_with(origin, Some("text/plain"), FIXED_WORD); + let response = handle_ec_resolve( + &settings, + request, + &gated(true), + Some(&graph), + &noop_services(), + ) + .await + .expect("should handle resolve"); + assert_eq!( + response.status(), + StatusCode::FORBIDDEN, + "an identity-setting POST must come from the publisher's own site" + ); + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "a rejected origin must set no cookie" + ); + } + } + + #[tokio::test] + async fn resolve_rejects_a_publisher_subdomain_origin() { + let settings = settings_with_client_fixed(); + let graph = in_memory_graph(); + let request = post_with( + Some("https://www.test-publisher.com"), + Some("text/plain"), + FIXED_WORD, + ); + let response = handle_ec_resolve( + &settings, + request, + &gated(true), + Some(&graph), + &noop_services(), + ) + .await + .expect("should handle resolve"); + assert_eq!( + response.status(), + StatusCode::FORBIDDEN, + "a sibling subdomain should not be able to set identity that lands on the apex" + ); + } + + #[tokio::test] + async fn resolve_rejects_a_plain_http_origin() { + let settings = settings_with_client_fixed(); + let graph = in_memory_graph(); + let request = post_with( + Some("http://test-publisher.com"), + Some("text/plain"), + FIXED_WORD, + ); + let response = handle_ec_resolve( + &settings, + request, + &gated(true), + Some(&graph), + &noop_services(), + ) + .await + .expect("should handle resolve"); + assert_eq!( + response.status(), + StatusCode::FORBIDDEN, + "the scheme is part of the origin, so http should not match the https default" + ); + } + + #[tokio::test] + async fn resolve_rejects_a_non_default_port_origin() { + let settings = settings_with_client_fixed(); + let graph = in_memory_graph(); + let request = post_with( + Some("https://test-publisher.com:8443"), + Some("text/plain"), + FIXED_WORD, + ); + let response = handle_ec_resolve( + &settings, + request, + &gated(true), + Some(&graph), + &noop_services(), + ) + .await + .expect("should handle resolve"); + assert_eq!( + response.status(), + StatusCode::FORBIDDEN, + "a port is part of the origin, so it should not be discarded before comparing" + ); + } + + #[tokio::test] + async fn resolve_accepts_a_configured_extra_origin() { + let mut settings = settings_with_client_fixed(); + settings + .ec + .resolve_allowed_origins + .push("https://www.test-publisher.com".to_owned()); + let graph = in_memory_graph(); + let request = post_with( + Some("https://www.test-publisher.com"), + Some("text/plain"), + FIXED_WORD, + ); + let response = handle_ec_resolve( + &settings, + request, + &gated(true), + Some(&graph), + &noop_services(), + ) + .await + .expect("should handle resolve"); + assert_eq!( + response.status(), + StatusCode::OK, + "an operator should be able to authorize the origin their pages are served from" + ); + } + + #[test] + fn origins_match_follows_rfc_6454() { + // Same triple, so the same origin (RFC 6454 §5), however it is written. + for (candidate, allowed) in [ + ("https://www.example.com", "https://www.example.com:443"), + ("https://www.example.com", "HTTPS://WWW.EXAMPLE.COM"), + ("http://www.example.com", "http://www.example.com:80"), + ( + "https://www.example.com:8443", + "https://www.example.com:8443", + ), + ] { + assert!( + origins_match(candidate, allowed), + "{candidate} and {allowed} should be the same origin" + ); + } + // A different scheme, host or port is a different origin, and a value + // that is not a serialized origin (RFC 6454 §6.1) never matches. + for (candidate, allowed) in [ + ("http://www.example.com", "https://www.example.com"), + ("https://www.example.com:8443", "https://www.example.com"), + ("https://sub.www.example.com", "https://www.example.com"), + ("https://www.example.com/", "https://www.example.com"), + ("https://www.example.com/path", "https://www.example.com"), + ("https://www.example.com?x=1", "https://www.example.com"), + ("null", "https://www.example.com"), + ("null", "null"), + ("www.example.com", "www.example.com"), + ] { + assert!( + !origins_match(candidate, allowed), + "{candidate} and {allowed} should not be the same origin" + ); + } + } + + #[tokio::test] + async fn resolve_rejects_an_unexpected_content_type() { + let settings = settings_with_client_fixed(); + let graph = in_memory_graph(); + let request = post_with( + Some("https://test-publisher.com"), + Some("application/x-www-form-urlencoded"), + FIXED_WORD, + ); + let response = handle_ec_resolve( + &settings, + request, + &gated(true), + Some(&graph), + &noop_services(), + ) + .await + .expect("should handle resolve"); + assert_eq!( + response.status(), + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "only text/plain and application/json bodies are resolve payloads" + ); + } + + #[tokio::test] + async fn resolve_conflicts_when_a_different_identity_already_exists() { + let settings = settings_with_client_fixed(); + let graph = in_memory_graph(); + let existing = format!("{}.ABC123", "e".repeat(64)); + let ec_context = + EcContext::new_for_test_gated(Some(existing), ConsentContext::default(), true); + let response = handle_ec_resolve( + &settings, + post(FIXED_WORD), + &ec_context, + Some(&graph), + &noop_services(), + ) + .await + .expect("should handle resolve"); + assert_eq!( + response.status(), + StatusCode::CONFLICT, + "a resolve must not silently replace an existing identity" + ); + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "a conflict must set no cookie" + ); + } + + #[tokio::test] + async fn resolve_mints_nothing_without_an_identity_graph() { + let settings = settings_with_client_fixed(); + let response = handle_ec_resolve( + &settings, + post(FIXED_WORD), + &gated(true), + None, + &noop_services(), + ) + .await + .expect("should handle resolve"); + assert_eq!( + response.status(), + StatusCode::NO_CONTENT, + "with no graph there is no row to persist, so nothing is created" + ); + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "a cookie without a graph row would be a phantom identity" + ); + } + + #[tokio::test] + async fn resolve_sets_no_cookie_for_unmatched_word() { + let settings = settings_with_client_fixed(); + let graph = in_memory_graph(); + let response = handle_ec_resolve( + &settings, + post("not-the-word"), + &gated(true), + Some(&graph), + &noop_services(), + ) + .await + .expect("should handle resolve"); + + assert_eq!( + response.status(), + StatusCode::NO_CONTENT, + "a value that fails verification should yield no cookie and a 204" + ); + assert!( + response.headers().get(header::SET_COOKIE).is_none(), + "an unmatched value should set no cookie" + ); + } +} diff --git a/crates/trusted-server-core/src/edge_cookie.rs b/crates/trusted-server-core/src/edge_cookie.rs index a4cdb4730..58a20e8e3 100644 --- a/crates/trusted-server-core/src/edge_cookie.rs +++ b/crates/trusted-server-core/src/edge_cookie.rs @@ -11,57 +11,98 @@ 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; #[cfg(test)] -use crate::ec::generation::{generate_ec_id as generate_canonical_ec_id, normalize_ip}; +use crate::ec::generation::normalize_ip; +#[cfg(test)] +use crate::ec::provider::IdentityInput; +use crate::ec::provider::{provider_owns_id, request_provider}; use crate::error::TrustedServerError; #[cfg(test)] +use crate::evidence::BorrowedRequestInfo; use crate::platform::RuntimeServices; -#[cfg(test)] use crate::settings::Settings; -/// Generates a fresh EC ID based on client IP address. +/// Generates a fresh EC ID using the configured Edge Cookie provider. /// -/// 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. +/// Routes through the pluggable provider model: the active `[ec] provider` +/// selection decides the outcome. Returns `Ok(None)` when no provider is +/// configured, so Trusted Server runs statelessly and creates no Edge Cookie. +/// `request_headers` lets a provider that derives identity from request +/// evidence read it; the built-in HMAC provider ignores it and uses only the +/// normalized client IP. /// /// # Errors /// -/// - [`TrustedServerError::EdgeCookie`] if HMAC generation fails +/// - [`TrustedServerError::EdgeCookie`] if provider generation fails /// /// Currently exercised only by tests: the production EC lifecycle generates IDs /// through [`crate::ec`]/`EcContext` rather than this edge-cookie helper. #[cfg(test)] -pub fn generate_ec_id( +pub async 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. + request_headers: Option<&http::HeaderMap>, +) -> Result, Report> { + // Fall back to "unknown" when the client IP is unavailable (for example in + // local testing). All such requests share the same HMAC base; the random + // suffix provides uniqueness. let client_ip = services - .client_info + .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) + let Some(provider) = request_provider(&settings.ec, services)? else { + log::info!("No Edge Cookie provider configured; running statelessly"); + return Ok(None); + }; + + // The provider reads request data (the client IP and the request headers) + // borrowed at call time, so nothing is cloned. A provider that also reads + // host signals takes them from the injected `HostSignals` service rather + // than from this request info. + let request_info = BorrowedRequestInfo::new(&client_ip, request_headers); + // The publisher path applies the permission gate at the call site, and the + // built-in provider reads neither the resolved permissions nor consent, so + // they are not threaded here. + let generated = provider + .generate(&request_info, &IdentityInput::default(), services) + .await?; + let generated = crate::ec::provider::GeneratedEdgeCookie { + id: generated + .id + .map(|value| crate::ec::provider::apply_provider_code(provider.as_ref(), &value)), + response_headers: generated.response_headers, + }; + Ok(generated.id) } -/// Gets an existing EC ID from the request. +/// Reads whatever the request offers as an Edge Cookie identifier, before any +/// check that this deployment could have issued it. /// -/// Attempts to retrieve an existing EC ID from: -/// 1. The `x-ts-ec` header -/// 2. The `ts-ec` cookie +/// Reads the `x-ts-ec` header first and then the `ts-ec` cookie. Both are +/// client-controlled. `x-ts-ec` is stripped from responses but is not stripped +/// from inbound requests, so a caller must treat the result as an attacker's +/// choice of string. +/// +/// The only checks applied here are the global cookie bounds, the length cap +/// and the cookie-safe alphabet in +/// [`ec_id_has_only_allowed_chars`](crate::ec::cookies::ec_id_has_only_allowed_chars), +/// which every identifier must satisfy whichever provider created it. Those +/// bounds are a backstop on what may travel in a cookie, not a test of +/// authenticity, and on their own they accept any run of `[A-Za-z0-9._~-]`. /// -/// Returns `None` if neither source contains an EC ID. +/// Deciding whether this deployment issued the value needs the selected +/// provider, which this function does not have, so it is deliberately not +/// public. Use [`recognized_ec_id`], which applies provider ownership on top. /// /// # Errors /// /// - [`TrustedServerError::InvalidHeaderValue`] if cookie parsing fails -pub fn get_ec_id(req: &Request) -> Result, Report> { +pub(crate) fn unvalidated_ec_id_from_request( + req: &Request, +) -> Result, Report> { if let Some(ec_id) = req .headers() .get(HEADER_X_TS_EC) @@ -93,30 +134,96 @@ pub fn get_ec_id(req: &Request) -> Result, Report, +) -> Result, Report> { + let Some(ec_id) = unvalidated_ec_id_from_request(req)? else { + return Ok(None); + }; + + let Some(provider) = request_provider(&settings.ec, services)? else { + log::debug!( + "No Edge Cookie provider configured; withholding the request's EC ID from egress" + ); + return Ok(None); + }; + + if provider_owns_id(provider.as_ref(), &ec_id) { + return Ok(Some(ec_id)); + } + + log::debug!( + "Withholding an EC ID provider `{}` does not recognize from egress", + provider.id(), + ); + Ok(None) +} + /// Gets or creates an EC ID from the request. /// /// Attempts to retrieve an existing EC ID from: /// 1. The `x-ts-ec` header /// 2. The `ts-ec` cookie /// -/// If neither exists, generates a new EC ID. +/// If neither exists, generates a new EC ID via the configured provider. +/// +/// Returns `Ok(None)` when no existing EC ID is present and no Edge Cookie +/// provider is configured, so the caller proceeds statelessly. /// /// # Errors /// /// Returns an error if ID generation fails. #[cfg(test)] -pub(crate) fn get_or_generate_ec_id_from_http_request( +pub(crate) async fn get_or_generate_ec_id_from_http_request( settings: &Settings, services: &RuntimeServices, req: &Request, -) -> Result> { - if let Some(id) = get_ec_id(req)? { - return Ok(id); +) -> Result, Report> { + if let Some(id) = unvalidated_ec_id_from_request(req)? { + return Ok(Some(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"); + // If no existing EC ID found, generate a fresh one through the provider. + let ec_id = generate_ec_id(settings, services, Some(req.headers())).await?; + if ec_id.is_some() { + log::trace!("No existing EC ID found; generated a fresh EC ID"); + } Ok(ec_id) } @@ -126,12 +233,12 @@ pub(crate) fn get_or_generate_ec_id_from_http_request( /// /// Returns an error if ID generation fails. #[cfg(test)] -pub fn get_or_generate_ec_id( +pub async fn get_or_generate_ec_id( settings: &Settings, services: &RuntimeServices, req: &Request, -) -> Result> { - get_or_generate_ec_id_from_http_request(settings, services, req) +) -> Result, Report> { + get_or_generate_ec_id_from_http_request(settings, services, req).await } #[cfg(test)] @@ -141,11 +248,12 @@ mod tests { use http::{HeaderName, header}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + use crate::ec::generation::generate_ec_id as generate_canonical_ec_id; 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() { + #[tokio::test] + async 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 @@ -155,13 +263,25 @@ mod tests { 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)) + let id_here = generate_ec_id(&settings, &noop_services_with_client_ip(ip), None) + .await + .expect("should generate EC ID via edge_cookie") + .expect("should configure the hmac provider in test settings"); + let passphrase = settings + .ec + .providers + .hmac + .as_ref() + .map(|hmac| hmac.passphrase.expose().as_str()) + .unwrap_or(""); + let id_canonical = generate_canonical_ec_id(passphrase, &normalize_ip(ip)) .expect("should generate EC ID via canonical generator"); + let bare_here = id_here + .strip_prefix("hmac~") + .expect("should carry the hmac provider code"); assert_eq!( - crate::ec::ec_hash(&id_here), + crate::ec::ec_hash(bare_here), crate::ec::ec_hash(&id_canonical), "should produce the same identity hash prefix as the canonical generator" ); @@ -178,6 +298,10 @@ mod tests { } fn is_ec_id_format(value: &str) -> bool { + // The coded envelope: hmac~<64hex>.<6alnum>. + let Some(value) = value.strip_prefix("hmac~") else { + return false; + }; let mut parts = value.split('.'); let hmac_part = match parts.next() { Some(part) => part, @@ -202,27 +326,49 @@ mod tests { true } - #[test] - fn test_generate_ec_id() { + #[tokio::test] + async 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"); + let ec_id = generate_ec_id(&settings, &noop_services(), None) + .await + .expect("should generate EC ID") + .expect("should configure the hmac provider in test settings"); log::debug!("Generated EC ID: {}", ec_id); assert!( is_ec_id_format(&ec_id), - "should match EC ID format: {{64hex}}.{{6alnum}}" + "should match the coded EC ID format: hmac~{{64hex}}.{{6alnum}}" ); } - #[test] - fn test_generate_ec_id_uses_client_ip() { + #[tokio::test] + async fn generate_ec_id_returns_none_when_no_provider_is_configured() { + let mut settings = create_test_settings(); + // No provider selected: Trusted Server runs statelessly. + settings.ec.provider = None; + + let id = generate_ec_id(&settings, &noop_services(), None) + .await + .expect("generation should not error when no provider is configured"); + assert!( + id.is_none(), + "no Edge Cookie provider should mean no Edge Cookie is created" + ); + } + + #[tokio::test] + async 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 id_with_ip = generate_ec_id(&settings, &noop_services_with_client_ip(ip), None) + .await + .expect("should generate EC ID with client IP") + .expect("should configure the hmac provider in test settings"); + let id_without_ip = generate_ec_id(&settings, &noop_services(), None) + .await + .expect("should generate EC ID without client IP") + .expect("should configure the hmac provider in test settings"); 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; @@ -235,22 +381,28 @@ mod tests { #[test] fn test_is_ec_id_format_accepts_valid_value() { - let value = format!("{}.{}", "a".repeat(64), "Ab12z9"); + let value = format!("hmac~{}.{}", "a".repeat(64), "Ab12z9"); assert!( is_ec_id_format(&value), - "should accept a valid EC ID format" + "should accept a valid coded EC ID format" ); } #[test] fn test_is_ec_id_format_rejects_invalid_values() { - let missing_suffix = "a".repeat(64); + let bare_legacy_shape = format!("{}.{}", "a".repeat(64), "Ab12z9"); + assert!( + !is_ec_id_format(&bare_legacy_shape), + "a freshly created identifier always carries the provider code" + ); + + let missing_suffix = format!("hmac~{}", "a".repeat(64)); assert!( !is_ec_id_format(&missing_suffix), "should reject missing suffix" ); - let invalid_hex = format!("{}.{}", "a".repeat(63) + "g", "Ab12z9"); + let invalid_hex = format!("hmac~{}.{}", "a".repeat(63) + "g", "Ab12z9"); assert!( !is_ec_id_format(&invalid_hex), "should reject non-hex HMAC content" @@ -270,31 +422,87 @@ mod tests { } #[test] - fn test_get_ec_id_with_header() { + fn an_identifier_this_deployment_never_issued_is_not_recognized() { + // `x-ts-ec` is stripped from responses but not from inbound requests, + // so a client can put whatever it likes in it, and the raw reader + // prefers the header over the cookie. The global cookie bounds accept + // any run of `[A-Za-z0-9._~-]`, so they cannot tell an identifier this + // deployment created from one an attacker typed. Provider ownership is + // what draws that line. + let settings = create_test_settings(); + let services = noop_services(); + + for forged in [ + // Passes the alphabet and the length cap, owned by nobody. + "not-an-identifier", + // The built-in shape under another deployment's provider code. + "zz00~aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.Ab1234", + // This deployment's code carrying a value its provider never creates. + "hmac~not-the-hmac-shape", + ] { + let req = create_test_request(&[(HEADER_X_TS_EC, forged)]); + + // The raw reader hands it straight back, which is exactly why it is + // not the check anything may rely on. + assert_eq!( + unvalidated_ec_id_from_request(&req) + .expect("should read the header") + .as_deref(), + Some(forged), + "the global bounds alone should accept `{forged}`" + ); + + assert_eq!( + recognized_ec_id(&settings, &services, &req) + .expect("should decide without erroring"), + None, + "`{forged}` was never issued here and must not be recognized" + ); + } + + // A value the selected provider does own is still recognized, so the + // check rejects forgeries rather than everything. + let issued = format!("hmac~{}.Ab1234", "a".repeat(64)); + let req = create_test_request(&[(HEADER_X_TS_EC, issued.as_str())]); + assert_eq!( + recognized_ec_id(&settings, &services, &req) + .expect("should decide without erroring") + .as_deref(), + Some(issued.as_str()), + "an identifier the selected provider owns should still be recognized" + ); + } + + #[tokio::test] + async 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"); + let ec_id = unvalidated_ec_id_from_request(&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"); + .await + .expect("should reuse header EC ID") + .expect("an existing EC should be present"); assert_eq!(ec_id, "existing_ec_id"); } - #[test] - fn test_get_ec_id_with_cookie() { + #[tokio::test] + async 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"); + let ec_id = unvalidated_ec_id_from_request(&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"); + .await + .expect("should reuse cookie EC ID") + .expect("an existing EC should be present"); assert_eq!(ec_id, "existing_cookie_id"); } @@ -307,13 +515,14 @@ mod tests { .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"); + let ec_id = + unvalidated_ec_id_from_request(&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() { + #[tokio::test] + async fn test_get_or_generate_ec_id_from_http_request_reuses_cookie() { let settings = create_test_settings(); let req = http::Request::builder() .method("GET") @@ -326,7 +535,9 @@ mod tests { .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"); + .await + .expect("should reuse cookie EC ID from http request") + .expect("an existing EC should be present"); assert_eq!(ec_id, "existing_http_cookie_id"); } @@ -334,17 +545,19 @@ mod tests { #[test] fn test_get_ec_id_none() { let req = create_test_request(&[]); - let ec_id = get_ec_id(&req).expect("should handle missing ID"); + let ec_id = unvalidated_ec_id_from_request(&req).expect("should handle missing ID"); assert!(ec_id.is_none()); } - #[test] - fn test_get_or_generate_ec_id_generate_new() { + #[tokio::test] + async 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"); + .await + .expect("should get or generate EC ID") + .expect("should configure the hmac provider in test settings"); assert!(!ec_id.is_empty()); } @@ -355,7 +568,8 @@ mod tests { (header::COOKIE, &format!("{}=valid_cookie_id", COOKIE_TS_EC)), ]); - let ec_id = get_ec_id(&req).expect("should handle invalid header gracefully"); + let ec_id = + unvalidated_ec_id_from_request(&req).expect("should handle invalid header gracefully"); assert_eq!( ec_id, Some("valid_cookie_id".to_string()), @@ -363,13 +577,15 @@ mod tests { ); } - #[test] - fn test_get_or_generate_ec_id_replaces_invalid_header() { + #[tokio::test] + async 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"); + .await + .expect("should generate fresh ID on invalid header") + .expect("should configure the hmac provider in test settings"); assert_ne!( ec_id, "evil;injected", "should not use tampered header value" @@ -387,7 +603,8 @@ mod tests { &format!("{}=bad`. + /// Injected at `` open, ahead of [`Self::ad_slots_script`] and the + /// tsjs bundle, so page code can read the request's permission state before + /// anything runs. `None` under a shared-template mode, where the head is + /// cached and served to many readers and nothing request-scoped may appear + /// in it, so the seam carries the state there instead. + pub permissions_script: Option, /// Pre-computed ``. /// Injected at `` open. `None` when no slots matched. pub ad_slots_script: Option, @@ -226,6 +236,7 @@ impl HtmlProcessorConfig { request_host: request_host.to_owned(), request_scheme: request_scheme.to_owned(), integrations: integrations.clone(), + permissions_script: None, ad_slots_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: settings.publisher.max_buffered_body_bytes, @@ -254,6 +265,17 @@ impl HtmlProcessorConfig { self } + /// Attach the head script carrying this request's permission state. + /// + /// Separate from [`with_ad_state`](Self::with_ad_state) because the two are + /// independent decisions: the permission state travels on every HTML + /// document the processor handles, whether or not the ad stack ran. + #[must_use] + pub fn with_permissions_script(mut self, permissions_script: Option) -> Self { + self.permissions_script = permissions_script; + self + } + /// Set what the `` seam injects. /// /// Separate from [`with_ad_state`](Self::with_ad_state) because the two are @@ -372,6 +394,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let integration_registry = config.integrations.clone(); let script_rewriters = integration_registry.script_rewriters(); let ad_slots_script = config.ad_slots_script.clone(); + let permissions_script = config.permissions_script.clone(); let body_close = config.body_close.clone(); let ad_bids_state = config.ad_bids_state.clone(); let gpt_diagnostics = config.gpt_diagnostics.clone(); @@ -404,10 +427,17 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let patterns = patterns.clone(); let document_state = document_state.clone(); let ad_slots_script = ad_slots_script.clone(); + let permissions_script = permissions_script.clone(); let gpt_diagnostics = gpt_diagnostics.clone(); move |el| { if !injected_tsjs.get() { let mut snippet = String::new(); + // The permission state goes first, ahead of the slots and + // the bundle, because both of those and any vendor module + // may read it as soon as they run. + if let Some(ref state_script) = permissions_script { + snippet.push_str(state_script); + } // Inject ad slots script first so it appears before tsjs bundle. if let Some(ref slots_script) = ad_slots_script { snippet.push_str(slots_script); @@ -430,24 +460,27 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso snippet.push_str(&bootstrap); } // Main bundle: core + non-deferred integrations (synchronous). - let immediate_ids = integrations.js_module_ids_immediate(); + let immediate_parts = integrations.js_parts_immediate(); let script_attributes = integrations.tsjs_script_tag_attributes(); snippet.push_str(&tsjs::tsjs_script_tag_with_attributes( - &immediate_ids, + &immediate_parts, &script_attributes, )); // Active diagnostics loads synchronously after core so its // GPT listeners precede publisher scripts in the origin head. - if let Some(module_tag) = gpt_diagnostics - .as_ref() - .and_then(GptDiagnosticsRequestDecision::module_script_tag) - { + // The decision says whether to inject; the registry's part + // says what to inject. Nothing is injected without a part. + if let Some(module_tag) = gpt_diagnostics.as_ref().and_then(|decision| { + integrations + .js_part(GPT_DIAGNOSTICS_INTEGRATION_ID) + .and_then(|part| decision.module_script_tag(&part)) + }) { snippet.push_str(&module_tag); } // Deferred bundles: large modules like prebid loaded after // HTML parsing completes. Empty when none are enabled. - let deferred_ids = integrations.js_module_ids_deferred(); - snippet.push_str(&tsjs::tsjs_deferred_script_tags(&deferred_ids)); + let deferred_parts = integrations.js_parts_deferred(); + snippet.push_str(&tsjs::tsjs_deferred_script_tags(&deferred_parts)); el.prepend(&snippet, ContentType::Html); injected_tsjs.set(true); } @@ -829,6 +862,7 @@ mod tests { request_scheme: "https".to_owned(), integrations: IntegrationRegistry::default(), ad_slots_script: None, + permissions_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, @@ -1805,6 +1839,7 @@ mod tests { r#""# .to_string(), ), + permissions_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, @@ -1882,6 +1917,7 @@ mod tests { ad_slots_script: Some( r#""#.to_string(), ), + permissions_script: None, ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, @@ -1921,6 +1957,7 @@ mod tests { ad_slots_script: Some( r#""#.to_string(), ), + permissions_script: None, ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, @@ -1959,6 +1996,7 @@ mod tests { request_scheme: "https".to_string(), integrations: IntegrationRegistry::default(), ad_slots_script: None, + permissions_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, @@ -2015,6 +2053,7 @@ mod tests { ad_slots_script: Some( r#""#.to_string(), ), + permissions_script: None, ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, @@ -2045,6 +2084,7 @@ mod tests { request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), ad_slots_script: None, + permissions_script: None, ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, @@ -2070,6 +2110,7 @@ mod tests { request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), ad_slots_script: None, + permissions_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, @@ -2206,6 +2247,7 @@ mod tests { request_scheme: "https".to_string(), integrations: IntegrationRegistry::empty_for_tests(), ad_slots_script: None, + permissions_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, gpt_diagnostics: None, diff --git a/crates/trusted-server-core/src/integrations/adserver_mock.rs b/crates/trusted-server-core/src/integrations/adserver_mock.rs index 1d6527934..a0d13a618 100644 --- a/crates/trusted-server-core/src/integrations/adserver_mock.rs +++ b/crates/trusted-server-core/src/integrations/adserver_mock.rs @@ -32,6 +32,9 @@ use crate::settings::{IntegrationConfig, Settings}; // Configuration // ============================================================================ +/// Integration id the ad server mock provider is configured under. +const ADSERVER_MOCK_INTEGRATION_ID: &str = "adserver_mock"; + /// Configuration for mock ad server integration. #[derive(Debug, Clone, Deserialize, Serialize, Validate)] pub struct AdServerMockConfig { @@ -556,6 +559,19 @@ impl AuctionProvider for AdServerMockProvider { // Auto-Registration // ============================================================================ +/// Validates the ad server mock configuration for deployment and reports +/// whether the provider is enabled. +/// +/// # Errors +/// +/// Returns an error when the ad server mock configuration cannot be parsed or +/// fails validation. +pub(crate) fn validate(settings: &Settings) -> Result> { + settings + .integration_config::(ADSERVER_MOCK_INTEGRATION_ID) + .map(|config| config.is_some()) +} + /// Auto-register ad server mock provider based on settings configuration. /// /// # Errors @@ -567,7 +583,7 @@ pub fn register_providers( ) -> Result>, Report> { let mut providers: Vec> = Vec::new(); - match settings.integration_config::("adserver_mock") { + match settings.integration_config::(ADSERVER_MOCK_INTEGRATION_ID) { Ok(Some(config)) => { log::info!( "Registering AdServer Mock mediator (endpoint: {})", @@ -644,17 +660,27 @@ mod tests { bid_id: Some(bid_id.to_string()), ad_id: None, creative_id: Some(format!("creative-{bid_id}")), - renderer: Some(BidRenderer::Aps(ApsRendererV1 { - version: 1, - account_id: "example-account".to_string(), - bid_id: bid_id.to_string(), - creative_id: Some(format!("creative-{bid_id}")), - tag_type: ApsTagType::Iframe, - creative_url: format!("https://creative.example/{bid_id}"), - aax_response: format!("fictional-{bid_id}-base64"), - width: 728, - height: 90, - })), + // The mock is not APS, so it builds the neutral descriptor from + // the wire keys rather than from the APS descriptor type. The "aps" + // tag is used on purpose: these fixtures mimic an APS bid arriving + // at the mediator. + renderer: Some( + BidRenderer::new( + "aps", + json!({ + "version": 1, + "accountId": "example-account", + "bidId": bid_id, + "creativeId": format!("creative-{bid_id}"), + "tagType": "iframe", + "creativeUrl": format!("https://creative.example/{bid_id}"), + "aaxResponse": format!("fictional-{bid_id}-base64"), + "width": 728, + "height": 90 + }), + ) + .expect("should build renderer descriptor"), + ), cache_id: None, cache_host: None, cache_path: None, @@ -841,17 +867,25 @@ mod tests { bid_id: Some("source-bid-id".to_string()), ad_id: Some("bid-impression-id".to_string()), creative_id: Some("source-creative-id".to_string()), - renderer: Some(BidRenderer::Aps(ApsRendererV1 { - version: 1, - account_id: "example-account".to_string(), - bid_id: "source-bid-id".to_string(), - creative_id: Some("source-creative-id".to_string()), - tag_type: ApsTagType::Iframe, - creative_url: "https://creative.example/render".to_string(), - aax_response: "fictional-base64".to_string(), - width: 728, - height: 90, - })), + // Neutral descriptor with the "aps" tag: the mock mimics an + // APS bid here without depending on the APS descriptor type. + renderer: Some( + BidRenderer::new( + "aps", + json!({ + "version": 1, + "accountId": "example-account", + "bidId": "source-bid-id", + "creativeId": "source-creative-id", + "tagType": "iframe", + "creativeUrl": "https://creative.example/render", + "aaxResponse": "fictional-base64", + "width": 728, + "height": 90 + }), + ) + .expect("should build renderer descriptor"), + ), cache_id: Some("cache-uuid".to_string()), cache_host: Some("cache.example".to_string()), cache_path: Some("/cache".to_string()), @@ -1028,16 +1062,13 @@ mod tests { .first() .expect("should restore mediated APS winner"); assert_eq!(winner.bid_id.as_deref(), Some("selected")); - assert_eq!( - winner - .renderer - .as_ref() - .expect("should restore APS renderer") - .as_aps() - .expect("should be APS renderer") - .bid_id, - "selected" - ); + let renderer = winner + .renderer + .as_ref() + .expect("should restore APS renderer") + .payload_as::("aps") + .expect("should carry an APS-tagged payload"); + assert_eq!(renderer["bidId"], "selected"); assert!(winner.creative.is_none()); } diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 581e5c200..f412ea664 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -18,8 +18,7 @@ use validator::{Validate, ValidationError}; use crate::auction::provider::{AuctionProvider, ProviderRequestOutcome}; use crate::auction::types::{ - AdSlot, ApsRendererV1, ApsTagType, AuctionContext, AuctionRequest, AuctionResponse, Bid, - BidRenderer, MediaType, + AdSlot, AuctionContext, AuctionRequest, AuctionResponse, Bid, BidRenderer, MediaType, }; use crate::error::TrustedServerError; use crate::integrations::{ @@ -35,6 +34,17 @@ use crate::platform::{PlatformHttpRequest, PlatformResponse, RuntimeServices}; use crate::settings::{IntegrationConfig, Settings}; const APS_INTEGRATION_ID: &str = "aps"; +/// Renderer type tag carried on the wire by an APS bid, read by the browser to +/// select the APS renderer. +pub const APS_RENDERER_TYPE: &str = "aps"; + +/// Wire key carrying [`ApsRendererV1::bid_id`], for callers that read the bid +/// identifier out of a renderer payload without deserializing the rest. +/// +/// `ApsRendererV1` renames its fields to camelCase, so this is `bidId` rather +/// than the Rust field name. `renderer_bid_id_key_matches_the_serialized_form` +/// pins the two together. +pub const APS_RENDERER_BID_ID_KEY: &str = "bidId"; const APS_RENDERER_ROUTE: &str = "/integrations/aps/renderer"; const DEFAULT_CURRENCY: &str = "USD"; const APS_SDK_SOURCE: &str = "prebid"; @@ -48,6 +58,45 @@ const MAX_PAGE_URL_BYTES: usize = 8192; const MAX_RENDER_ENVELOPE_BYTES: usize = 256 * 1024; const APS_RENDERER_CSP: &str = "default-src 'none'; sandbox allow-forms allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts allow-top-navigation-by-user-activation; script-src 'unsafe-inline' https:; connect-src https:; frame-src https:; img-src https: data:; media-src https: blob:; style-src 'unsafe-inline' https:; font-src https: data:;"; +/// APS creative tag type accepted by the Trusted Server renderer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ApsTagType { + /// APS loads the creative URL in a nested iframe. + Iframe, + /// APS fetches creative HTML and executes it in its nested renderer frame. + Script, +} + +/// Version 1 APS renderer descriptor shared with browser clients. +/// +/// Carried by a bid as the payload of a [`BidRenderer`] tagged +/// [`APS_RENDERER_TYPE`], so it travels with the APS integration rather than +/// with the neutral auction types. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApsRendererV1 { + /// Renderer contract version. + pub version: u8, + /// APS account identifier used to initialize the fixed runner. + pub account_id: String, + /// Selected `OpenRTB` bid identifier. + pub bid_id: String, + /// Optional `OpenRTB` creative identifier. + #[serde(skip_serializing_if = "Option::is_none")] + pub creative_id: Option, + /// APS creative delivery mode. + pub tag_type: ApsTagType, + /// HTTPS creative URL consumed by the fixed APS runner. + pub creative_url: String, + /// Base64-encoded exact one-bid APS response envelope. + pub aax_response: String, + /// Creative width. + pub width: u32, + /// Creative height. + pub height: u32, +} + const APS_RENDERER_DOCUMENT: &str = r#" @@ -719,7 +768,7 @@ impl ApsAuctionProvider { if serialized.len() > MAX_RENDER_ENVELOPE_BYTES { return None; } - Some(BidRenderer::Aps(ApsRendererV1 { + let descriptor = ApsRendererV1 { version: 1, account_id: self.config.account_id.clone(), bid_id: input.bid_id.to_string(), @@ -729,7 +778,17 @@ impl ApsAuctionProvider { aax_response: BASE64_STANDARD.encode(serialized), width: input.width, height: input.height, - })) + }; + match BidRenderer::from_typed(APS_RENDERER_TYPE, &descriptor) { + Ok(renderer) => Some(renderer), + Err(error) => { + log::warn!( + "Dropping APS bid '{}': its renderer descriptor could not be built: {error:?}", + input.bid_id + ); + None + } + } } fn increment_reason(reasons: &mut BTreeMap, reason: &'static str) { @@ -1264,6 +1323,19 @@ impl IntegrationHeadInjector for ApsRendererIntegration { } } +/// Validates the APS configuration for deployment and reports whether +/// the integration is enabled. +/// +/// # Errors +/// +/// Returns an error when the APS configuration cannot be parsed or fails +/// validation. +pub(crate) fn validate(settings: &Settings) -> Result> { + settings + .integration_config::(APS_INTEGRATION_ID) + .map(|config| config.is_some()) +} + /// Register the APS static renderer endpoint when APS is enabled. /// /// # Errors @@ -1816,7 +1888,7 @@ mod tests { .renderer .as_ref() .expect("should include renderer") - .as_aps() + .payload_as::(APS_RENDERER_TYPE) .expect("should be APS renderer"); let decoded = BASE64_STANDARD .decode(&renderer.aax_response) @@ -1870,7 +1942,7 @@ mod tests { bid.renderer .as_ref() .expect("should retain renderer") - .as_aps() + .payload_as::(APS_RENDERER_TYPE) .expect("should be APS renderer") .creative_id .is_none() @@ -2282,7 +2354,7 @@ mod tests { .renderer .as_ref() .expect("should keep script renderer") - .as_aps() + .payload_as::(APS_RENDERER_TYPE) .expect("should be APS renderer"); assert_eq!(renderer.tag_type, ApsTagType::Script); } @@ -2613,4 +2685,39 @@ mod tests { assert!(APS_RENDERER_CSP.contains("sandbox allow-forms")); assert!(!APS_RENDERER_CSP.contains("allow-same-origin")); } + + #[test] + fn renderer_bid_id_key_matches_the_serialized_form() { + let descriptor = ApsRendererV1 { + version: 1, + account_id: "example-account".to_string(), + bid_id: "fictional-bid-id".to_string(), + creative_id: None, + tag_type: ApsTagType::Iframe, + creative_url: "https://creative.example/render".to_string(), + aax_response: "fictional-base64".to_string(), + width: 300, + height: 250, + }; + let renderer = BidRenderer::from_typed(APS_RENDERER_TYPE, &descriptor) + .expect("should build APS renderer descriptor"); + + assert_eq!( + renderer + .payload_field(APS_RENDERER_TYPE, APS_RENDERER_BID_ID_KEY) + .and_then(serde_json::Value::as_str), + Some(descriptor.bid_id.as_str()), + "should name the wire key `ApsRendererV1` serializes `bid_id` to" + ); + assert_eq!( + renderer + .payload_field(APS_RENDERER_TYPE, APS_RENDERER_BID_ID_KEY) + .and_then(serde_json::Value::as_str), + renderer + .payload_as::(APS_RENDERER_TYPE) + .as_ref() + .map(|full| full.bid_id.as_str()), + "should read the same value as deserializing the whole descriptor" + ); + } } diff --git a/crates/trusted-server-core/src/integrations/datadome.rs b/crates/trusted-server-core/src/integrations/datadome.rs index d95ee35ee..4f0091772 100644 --- a/crates/trusted-server-core/src/integrations/datadome.rs +++ b/crates/trusted-server-core/src/integrations/datadome.rs @@ -973,6 +973,23 @@ fn build( Ok(Some(integration)) } +/// Validates the `DataDome` configuration for deployment and reports whether +/// the integration is enabled. +/// +/// # Errors +/// +/// Returns an error when the `DataDome` configuration cannot be parsed, fails +/// validation, or fails the startup checks on protection, bypass, or +/// client-tag settings. +pub(crate) fn validate(settings: &Settings) -> Result> { + let Some(config) = settings.integration_config::(DATADOME_INTEGRATION_ID)? + else { + return Ok(false); + }; + DataDomeIntegration::validate_config_for_startup(config)?; + Ok(true) +} + /// Register the `DataDome` integration with Trusted Server. /// /// # Errors diff --git a/crates/trusted-server-core/src/integrations/datadome/protection.rs b/crates/trusted-server-core/src/integrations/datadome/protection.rs index 75de88afb..31e1c1e5f 100644 --- a/crates/trusted-server-core/src/integrations/datadome/protection.rs +++ b/crates/trusted-server-core/src/integrations/datadome/protection.rs @@ -46,10 +46,13 @@ impl DataDomeIntegration { let test_bypass_matched = self.take_protection_test_bypass_header(input.request, input.services); if test_bypass_matched { - input - .request - .extensions_mut() - .insert(super::DataDomeClientTagSuppressed); + // Both markers travel together. The first is DataDome's own + // tag-suppression signal, read by its head injector. The second tells + // core the response is personalized to this request and cannot be + // shared through a cache or a template. + let extensions = input.request.extensions_mut(); + extensions.insert(super::DataDomeClientTagSuppressed); + extensions.insert(crate::response_privacy::PersonalizedResponse); log_protection_test_bypass(&input); return RequestFilterDecision::Continue(RequestFilterEffects::default()); } @@ -162,10 +165,13 @@ impl DataDomeIntegration { suppress_client_tag, } => { if suppress_client_tag { - input - .request - .extensions_mut() - .insert(super::DataDomeClientTagSuppressed); + // Both markers travel together. The first is DataDome's own + // tag-suppression signal, read by its head injector. The second + // tells core the response is personalized to this request and + // cannot be shared through a cache or a template. + let extensions = input.request.extensions_mut(); + extensions.insert(super::DataDomeClientTagSuppressed); + extensions.insert(crate::response_privacy::PersonalizedResponse); } log_protection_skip(input, &rule_id, reason, suppress_client_tag); return false; @@ -886,6 +892,7 @@ mod tests { services, request, geo_info: None, + permissions: None, is_integration_route: false, })) }) @@ -926,6 +933,7 @@ mod tests { services, request: &mut request, geo_info, + permissions: None, is_integration_route: false, }, )); @@ -943,6 +951,17 @@ mod tests { .is_some() } + /// The filter sets this alongside the `DataDome` marker at every site. The + /// two are asserted together everywhere below, so dropping either insert + /// fails a test rather than silently making a personalized HTML response + /// shareable through a cache or a template. + fn has_personalized_response_marker(request: &Request) -> bool { + request + .extensions() + .get::() + .is_some() + } + #[test] fn protection_test_bypass_skips_api_suppresses_tag_and_strips_header() { let config = DataDomeConfig { @@ -983,6 +1002,10 @@ mod tests { has_client_tag_suppression_marker(&request), "the bypass should suppress the automatic DataDome client tag" ); + assert!( + has_personalized_response_marker(&request), + "the bypass should mark the response personalized to this request" + ); assert!( request .headers() @@ -1053,6 +1076,10 @@ mod tests { !has_client_tag_suppression_marker(&request), "an inactive bypass must not suppress the DataDome client tag" ); + assert!( + !has_personalized_response_marker(&request), + "an inactive bypass must not mark the response personalized" + ); assert_eq!( http_client.recorded_backend_names().len(), 1, @@ -1113,6 +1140,7 @@ mod tests { services: &services, request: &mut request, geo_info: None, + permissions: None, is_integration_route: false, }, )) @@ -1134,6 +1162,10 @@ mod tests { !has_client_tag_suppression_marker(&request), "the bypass must not suppress the DataDome client tag outside staging" ); + assert!( + !has_personalized_response_marker(&request), + "the bypass must not mark the response personalized outside staging" + ); assert_eq!( http_client.recorded_backend_names().len(), 1, @@ -1189,6 +1221,10 @@ mod tests { has_client_tag_suppression_marker(&request), "a matching test credential should suppress the tag even on an excluded path" ); + assert!( + has_personalized_response_marker(&request), + "a matching test credential should mark the response personalized even on an excluded path" + ); assert!( http_client.recorded_backend_names().is_empty(), "a matching test credential must not call the Protection API" @@ -1244,6 +1280,10 @@ mod tests { !has_client_tag_suppression_marker(&request), "a non-matching credential must not suppress the DataDome client tag" ); + assert!( + !has_personalized_response_marker(&request), + "a non-matching credential must not mark the response personalized" + ); assert!( request .headers() @@ -1313,6 +1353,7 @@ mod tests { "all duplicate bypass values should be stripped" ); assert!(!has_client_tag_suppression_marker(&request)); + assert!(!has_personalized_response_marker(&request)); assert_eq!(http_client.recorded_backend_names().len(), 1); } @@ -1372,6 +1413,7 @@ mod tests { assert!(matches!(decision, RequestFilterDecision::Continue(_))); assert_eq!(has_client_tag_suppression_marker(&request), should_match); + assert_eq!(has_personalized_response_marker(&request), should_match); assert_eq!( http_client.recorded_backend_names().is_empty(), should_match, @@ -1425,6 +1467,10 @@ mod tests { has_client_tag_suppression_marker(&inline_request), "inline IP exclusions should mark the request" ); + assert!( + has_personalized_response_marker(&inline_request), + "inline IP exclusions should mark the response personalized" + ); inline.protection_excluded_ip_cidrs.clear(); inline.protection_excluded_ip_cidr_sources = @@ -1444,6 +1490,10 @@ mod tests { has_client_tag_suppression_marker(&source_request), "Config Store IP exclusions should mark the request" ); + assert!( + has_personalized_response_marker(&source_request), + "Config Store IP exclusions should mark the response personalized" + ); let structured_ip = DataDomeConfig { enabled: true, @@ -1464,6 +1514,10 @@ mod tests { has_client_tag_suppression_marker(&structured_request), "structured IP exclusions should mark the request" ); + assert!( + has_personalized_response_marker(&structured_request), + "structured IP exclusions should mark the response personalized" + ); let structured_source = DataDomeConfig { enabled: true, @@ -1492,6 +1546,10 @@ mod tests { has_client_tag_suppression_marker(&structured_source_request), "structured Config Store IP exclusions should mark the request" ); + assert!( + has_personalized_response_marker(&structured_source_request), + "structured Config Store IP exclusions should mark the response personalized" + ); } #[test] @@ -1542,6 +1600,10 @@ mod tests { !has_client_tag_suppression_marker(&request), "matching non-IP exclusion should not mark {uri}" ); + assert!( + !has_personalized_response_marker(&request), + "matching non-IP exclusion should not mark {uri} as personalized" + ); } } @@ -1578,6 +1640,10 @@ mod tests { has_client_tag_suppression_marker(&request), "overlapping IP exclusion should suppress even when path remains the primary reason" ); + assert!( + has_personalized_response_marker(&request), + "overlapping IP exclusion should mark the response personalized even when path remains the primary reason" + ); } #[test] @@ -1607,6 +1673,10 @@ mod tests { !has_client_tag_suppression_marker(&request), "ASN exclusions should not mark the request" ); + assert!( + !has_personalized_response_marker(&request), + "ASN exclusions should not mark the response personalized" + ); } #[test] @@ -1625,6 +1695,10 @@ mod tests { !has_client_tag_suppression_marker(&request), "a non-matching IP should not mark the request" ); + assert!( + !has_personalized_response_marker(&request), + "a non-matching IP should not mark the response personalized" + ); } #[test] @@ -1724,6 +1798,7 @@ mod tests { services: &services, request: &mut request, geo_info: None, + permissions: None, is_integration_route: false, }, )); diff --git a/crates/trusted-server-core/src/integrations/didomi.rs b/crates/trusted-server-core/src/integrations/didomi.rs index f8472b796..802b59e7e 100644 --- a/crates/trusted-server-core/src/integrations/didomi.rs +++ b/crates/trusted-server-core/src/integrations/didomi.rs @@ -240,6 +240,19 @@ fn build( Ok(Some(DidomiIntegration::new(Arc::new(config)))) } +/// Validates the Didomi configuration for deployment and reports whether +/// the integration is enabled. +/// +/// # Errors +/// +/// Returns an error when the Didomi configuration cannot be parsed or fails +/// validation. +pub(crate) fn validate(settings: &Settings) -> Result> { + settings + .integration_config::(DIDOMI_INTEGRATION_ID) + .map(|config| config.is_some()) +} + /// Register the Didomi consent notice integration when enabled. /// /// # Errors diff --git a/crates/trusted-server-core/src/integrations/google_tag_manager.rs b/crates/trusted-server-core/src/integrations/google_tag_manager.rs index 162e9eb8c..85d382501 100644 --- a/crates/trusted-server-core/src/integrations/google_tag_manager.rs +++ b/crates/trusted-server-core/src/integrations/google_tag_manager.rs @@ -400,6 +400,19 @@ fn build( Ok(Some(GoogleTagManagerIntegration::new(config))) } +/// Validates the Google Tag Manager configuration for deployment and reports whether +/// the integration is enabled. +/// +/// # Errors +/// +/// Returns an error when the Google Tag Manager configuration cannot be parsed or fails +/// validation. +pub(crate) fn validate(settings: &Settings) -> Result> { + settings + .integration_config::(GTM_INTEGRATION_ID) + .map(|config| config.is_some()) +} + /// Register the Google Tag Manager integration when enabled. /// /// # Errors @@ -1566,12 +1579,18 @@ origin_url = "https://origin.test-publisher.com" proxy_secret = "test-secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [integrations.google_tag_manager] enabled = true container_id = "GTM-PARSED" upstream_url = "https://custom.gtm.example" + +[geo] +assume_single_jurisdiction = true "#; let settings = Settings::from_toml(toml_str).expect("should parse TOML"); let config = settings @@ -1599,10 +1618,16 @@ origin_url = "https://origin.test-publisher.com" proxy_secret = "test-secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [integrations.google_tag_manager] container_id = "GTM-DEFAULT" + +[geo] +assume_single_jurisdiction = true "#; let settings = Settings::from_toml(toml_str).expect("should parse TOML"); let config = settings diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index 84158c27e..aaa10f171 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -377,6 +377,19 @@ fn build(settings: &Settings) -> Result>, Report Result> { + settings + .integration_config::(GPT_INTEGRATION_ID) + .map(|config| config.is_some()) +} + /// Register the GPT integration. /// /// # Errors diff --git a/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs b/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs index b4a188f2a..e3f522618 100644 --- a/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs +++ b/crates/trusted-server-core/src/integrations/gpt_diagnostics.rs @@ -17,6 +17,7 @@ use crate::http_util::is_navigation_request; use crate::response_privacy::enforce_synthesized_html_cache_privacy; use crate::settings::{IntegrationConfig, Settings}; use crate::tsjs; +use crate::tsjs_bundle::JsModulePart; use super::IntegrationRegistration; @@ -102,13 +103,20 @@ impl GptDiagnosticsRequestDecision { Some(script) } - /// Build the synchronous standalone diagnostics module tag. + /// Build the synchronous standalone diagnostics module tag for `module`, + /// the registry's part for [`GPT_DIAGNOSTICS_INTEGRATION_ID`]. + /// + /// Returns `None` when this decision is not active. #[must_use] - pub fn module_script_tag(&self) -> Option { + pub fn module_script_tag(&self, module: &JsModulePart) -> Option { + debug_assert_eq!( + module.id, GPT_DIAGNOSTICS_INTEGRATION_ID, + "should tag the diagnostics module" + ); self.active.then(|| { format!( "", - tsjs::tsjs_single_module_script_src(GPT_DIAGNOSTICS_INTEGRATION_ID) + tsjs::tsjs_single_module_script_src(module) ) }) } @@ -131,6 +139,12 @@ impl GptDiagnosticsRequestDecision { mod head_seam_invariant_tests { use super::*; + /// The compile-time diagnostics module, as the registry would serve it. + fn diagnostics_part() -> JsModulePart { + JsModulePart::compile_time(GPT_DIAGNOSTICS_INTEGRATION_ID) + .expect("should have compiled the diagnostics module in") + } + /// Every combination of the three fields the decision carries. fn all_decisions() -> Vec { let mut out = Vec::new(); @@ -162,9 +176,10 @@ mod head_seam_invariant_tests { // // If a future change makes a script emit without also requiring the stamp, // this fails here rather than silently in a cached template. + let module = diagnostics_part(); for decision in all_decisions() { - let injects = - decision.bootstrap_script().is_some() || decision.module_script_tag().is_some(); + let injects = decision.bootstrap_script().is_some() + || decision.module_script_tag(&module).is_some(); if injects { assert!( decision.requires_private_no_store(), @@ -184,7 +199,7 @@ mod head_seam_invariant_tests { "should not inject a bootstrap for an inert decision" ); assert_eq!( - decision.module_script_tag(), + decision.module_script_tag(&diagnostics_part()), None, "should not inject a module for an inert decision" ); @@ -209,6 +224,19 @@ struct ConsoleCookieState { canonical: bool, } +/// Validates the GPT diagnostics configuration for deployment and reports whether +/// the integration is enabled. +/// +/// # Errors +/// +/// Returns an error when the GPT diagnostics configuration cannot be parsed or fails +/// validation. +pub(crate) fn validate(settings: &Settings) -> Result> { + settings + .integration_config::(GPT_DIAGNOSTICS_INTEGRATION_ID) + .map(|config| config.is_some()) +} + /// Register GPT diagnostics when explicitly enabled. /// /// # Errors @@ -226,7 +254,7 @@ pub fn register( Ok(Some( IntegrationRegistration::builder(GPT_DIAGNOSTICS_INTEGRATION_ID) - .without_js() + .with_standalone_js() .build(), )) } @@ -305,6 +333,19 @@ pub fn prepare_request( Ok(decision) } +/// Builder hook: prepares the request and discards the decision, which stays +/// in the request extensions for the publisher path to read. +/// +/// # Errors +/// +/// Returns the error from [`prepare_request`]. +pub(crate) fn prepare_request_hook( + settings: &Settings, + request: &mut Request, +) -> Result<(), Report> { + prepare_request(settings, request).map(|_| ()) +} + /// Read the request decision, defaulting to inactive when not prepared. #[must_use] pub fn request_decision(request: &Request) -> GptDiagnosticsRequestDecision { @@ -480,6 +521,10 @@ mod tests { .js_module_ids_deferred() .contains(&GPT_DIAGNOSTICS_INTEGRATION_ID) ); + assert!( + registry.js_part(GPT_DIAGNOSTICS_INTEGRATION_ID).is_some(), + "should serve the diagnostics module standalone" + ); } #[test] diff --git a/crates/trusted-server-core/src/integrations/lockr.rs b/crates/trusted-server-core/src/integrations/lockr.rs index 1f4f04b73..337dc5360 100644 --- a/crates/trusted-server-core/src/integrations/lockr.rs +++ b/crates/trusted-server-core/src/integrations/lockr.rs @@ -312,6 +312,19 @@ fn build(settings: &Settings) -> Result>, Report Result> { + settings + .integration_config::(LOCKR_INTEGRATION_ID) + .map(|config| config.is_some()) +} + /// Register the Lockr integration. /// /// # Errors diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index 90d688693..a173acf2d 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -5,6 +5,7 @@ use std::time::Duration; use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; use futures::StreamExt as _; +use http::Request; use url::Url; use crate::error::TrustedServerError; @@ -27,10 +28,12 @@ mod registry; pub mod sourcepoint; pub mod testlight; +#[cfg(test)] +pub(crate) use registry::test_support as registry_test_support; pub use registry::{ - AttributeRewriteAction, AttributeRewriteOutcome, HeaderMutation, HeaderMutationMode, - IntegrationAttributeContext, IntegrationAttributeRewriter, IntegrationDocumentState, - IntegrationEndpoint, IntegrationHeadInjector, IntegrationHtmlContext, + AttributeRewriteAction, AttributeRewriteOutcome, CarriedJsModule, HeaderMutation, + HeaderMutationMode, IntegrationAttributeContext, IntegrationAttributeRewriter, + IntegrationDocumentState, IntegrationEndpoint, IntegrationHeadInjector, IntegrationHtmlContext, IntegrationHtmlPostProcessor, IntegrationMetadata, IntegrationProxy, IntegrationRegistration, IntegrationRegistrationBuilder, IntegrationRegistry, IntegrationRequestFilter, IntegrationScriptContext, IntegrationScriptRewriter, ProxyDispatchInput, RequestFilterDecision, @@ -279,72 +282,194 @@ pub(crate) async fn collect_response_bounded( } } -type IntegrationBuilderFn = +/// Builds an integration's registration from settings, or `None` when the +/// integration is not enabled. +pub type IntegrationBuilderFn = fn(&Settings) -> Result, Report>; -pub(crate) struct IntegrationBuilder { +/// Validates an integration's configuration for deployment and reports +/// whether the integration is enabled. +/// +/// Runs for every builder, enabled or not, so a typo in a disabled block is +/// still caught. +pub type IntegrationValidateFn = fn(&Settings) -> Result>; + +/// Prepares a request before routing, for every routed request except the +/// health check. +/// +/// Runs whether or not the integration is enabled, so an integration can +/// strip its reserved query or cookie even when it is switched off. +pub type IntegrationPrepareRequestFn = + fn(&Settings, &mut Request) -> Result<(), Report>; + +/// Source label for the built-in integrations. +pub const CORE_SOURCE: &str = "trusted-server-core"; + +/// A named factory for one integration, the unit an adapter or a vendor crate +/// hands to [`IntegrationRegistry::with_registrations`]. +/// +/// # Examples +/// +/// ``` +/// use error_stack::Report; +/// use trusted_server_core::error::TrustedServerError; +/// use trusted_server_core::integrations::{ +/// IntegrationBuilder, IntegrationRegistration, IntegrationRegistry, +/// }; +/// use trusted_server_core::settings::Settings; +/// +/// fn build( +/// _settings: &Settings, +/// ) -> Result, Report> { +/// Ok(Some(IntegrationRegistration::builder("example").build())) +/// } +/// +/// fn validate(_settings: &Settings) -> Result> { +/// Ok(true) +/// } +/// +/// # fn demo(settings: &Settings) -> Result<(), Report> { +/// let builder = IntegrationBuilder::new("example", "example-crate", build, validate); +/// let registry = IntegrationRegistry::with_registrations(settings, &[builder])?; +/// assert!(registry.integration_enabled("example")); +/// # Ok(()) +/// # } +/// ``` +#[derive(Clone, Copy, Debug)] +pub struct IntegrationBuilder { id: &'static str, + source: &'static str, build: IntegrationBuilderFn, + validate: IntegrationValidateFn, + prepare_request: Option, } +impl IntegrationBuilder { + /// Creates a builder for the integration `id`, attributed to `source` + /// (a crate or package name used in duplicate-id errors). + #[must_use] + pub const fn new( + id: &'static str, + source: &'static str, + build: IntegrationBuilderFn, + validate: IntegrationValidateFn, + ) -> Self { + Self { + id, + source, + build, + validate, + prepare_request: None, + } + } + + /// Attaches a request preparation function that runs before routing on + /// every request, enabled or not. + #[must_use] + pub const fn with_request_preparer(mut self, prepare: IntegrationPrepareRequestFn) -> Self { + self.prepare_request = Some(prepare); + self + } + + /// The integration id this builder produces. + #[must_use] + pub const fn id(&self) -> &'static str { + self.id + } + + /// The source label used in diagnostics. + #[must_use] + pub const fn source(&self) -> &'static str { + self.source + } + + /// Builds the registration, or `None` when the integration is not enabled. + /// + /// # Errors + /// + /// Returns an error when the integration is enabled with invalid + /// configuration. + pub(crate) fn build( + &self, + settings: &Settings, + ) -> Result, Report> { + (self.build)(settings) + } + + /// Validates the integration's configuration for deployment and reports + /// whether the integration is enabled. + /// + /// # Errors + /// + /// Returns an error when the configuration cannot be parsed or fails + /// validation. + pub(crate) fn validate(&self, settings: &Settings) -> Result> { + (self.validate)(settings) + } + + /// The request preparation function, when one is attached. + pub(crate) fn prepare_request(&self) -> Option { + self.prepare_request + } +} + +/// The built-in integrations, in hook order. +const BUILT_IN_BUILDERS: &[IntegrationBuilder] = &[ + IntegrationBuilder::new("aps", CORE_SOURCE, aps::register, aps::validate), + IntegrationBuilder::new("prebid", CORE_SOURCE, prebid::register, prebid::validate), + IntegrationBuilder::new( + "testlight", + CORE_SOURCE, + testlight::register, + testlight::validate, + ), + IntegrationBuilder::new("nextjs", CORE_SOURCE, nextjs::register, nextjs::validate), + IntegrationBuilder::new( + "permutive", + CORE_SOURCE, + permutive::register, + permutive::validate, + ), + IntegrationBuilder::new("lockr", CORE_SOURCE, lockr::register, lockr::validate), + IntegrationBuilder::new("didomi", CORE_SOURCE, didomi::register, didomi::validate), + IntegrationBuilder::new( + "sourcepoint", + CORE_SOURCE, + sourcepoint::register, + sourcepoint::validate, + ), + IntegrationBuilder::new("osano", CORE_SOURCE, osano::register, osano::validate), + IntegrationBuilder::new( + "google_tag_manager", + CORE_SOURCE, + google_tag_manager::register, + google_tag_manager::validate, + ), + IntegrationBuilder::new( + "datadome", + CORE_SOURCE, + datadome::register, + datadome::validate, + ), + IntegrationBuilder::new("gpt", CORE_SOURCE, gpt::register, gpt::validate), + IntegrationBuilder::new( + "gpt_diagnostics", + CORE_SOURCE, + gpt_diagnostics::register, + gpt_diagnostics::validate, + ) + .with_request_preparer(gpt_diagnostics::prepare_request_hook), +]; + +/// The built-in integration builders, in hook order. pub(crate) fn builders() -> &'static [IntegrationBuilder] { - &[ - IntegrationBuilder { - id: "aps", - build: aps::register, - }, - IntegrationBuilder { - id: "prebid", - build: prebid::register, - }, - IntegrationBuilder { - id: "testlight", - build: testlight::register, - }, - IntegrationBuilder { - id: "nextjs", - build: nextjs::register, - }, - IntegrationBuilder { - id: "permutive", - build: permutive::register, - }, - IntegrationBuilder { - id: "lockr", - build: lockr::register, - }, - IntegrationBuilder { - id: "didomi", - build: didomi::register, - }, - IntegrationBuilder { - id: "sourcepoint", - build: sourcepoint::register, - }, - IntegrationBuilder { - id: "osano", - build: osano::register, - }, - IntegrationBuilder { - id: "google_tag_manager", - build: google_tag_manager::register, - }, - IntegrationBuilder { - id: "datadome", - build: datadome::register, - }, - IntegrationBuilder { - id: "gpt", - build: gpt::register, - }, - IntegrationBuilder { - id: "gpt_diagnostics", - build: gpt_diagnostics::register, - }, - ] + BUILT_IN_BUILDERS } -#[cfg(test)] -pub(crate) fn registered_builder_ids() -> impl Iterator { - builders().iter().map(|builder| builder.id) +/// Every builder the registry will consider: the built-in set followed by +/// `extra`, in that order, so hook order for the built-ins never changes. +pub(crate) fn all_builders( + extra: &[IntegrationBuilder], +) -> impl Iterator + '_ { + builders().iter().copied().chain(extra.iter().copied()) } diff --git a/crates/trusted-server-core/src/integrations/nextjs/mod.rs b/crates/trusted-server-core/src/integrations/nextjs/mod.rs index 5452260e7..b048b3b31 100644 --- a/crates/trusted-server-core/src/integrations/nextjs/mod.rs +++ b/crates/trusted-server-core/src/integrations/nextjs/mod.rs @@ -70,6 +70,19 @@ pub(super) fn configuration_error(message: impl Into) -> Report Result> { + settings + .integration_config::(NEXTJS_INTEGRATION_ID) + .map(|config| config.is_some()) +} + /// Register the Next.js integration when enabled. /// /// # Errors diff --git a/crates/trusted-server-core/src/integrations/osano.rs b/crates/trusted-server-core/src/integrations/osano.rs index 9fc3aa66c..da9677975 100644 --- a/crates/trusted-server-core/src/integrations/osano.rs +++ b/crates/trusted-server-core/src/integrations/osano.rs @@ -31,6 +31,19 @@ impl IntegrationConfig for OsanoConfig { } } +/// Validates the Osano configuration for deployment and reports whether +/// the integration is enabled. +/// +/// # Errors +/// +/// Returns an error when the Osano configuration cannot be parsed or fails +/// validation. +pub(crate) fn validate(settings: &Settings) -> Result> { + settings + .integration_config::(OSANO_INTEGRATION_ID) + .map(|config| config.is_some()) +} + /// Register the Osano JS integration when enabled. /// /// # Errors diff --git a/crates/trusted-server-core/src/integrations/permutive.rs b/crates/trusted-server-core/src/integrations/permutive.rs index aa684c620..d0e14cada 100644 --- a/crates/trusted-server-core/src/integrations/permutive.rs +++ b/crates/trusted-server-core/src/integrations/permutive.rs @@ -303,6 +303,19 @@ fn build( Ok(Some(PermutiveIntegration::new(config))) } +/// Validates the Permutive configuration for deployment and reports whether +/// the integration is enabled. +/// +/// # Errors +/// +/// Returns an error when the Permutive configuration cannot be parsed or fails +/// validation. +pub(crate) fn validate(settings: &Settings) -> Result> { + settings + .integration_config::(PERMUTIVE_INTEGRATION_ID) + .map(|config| config.is_some()) +} + /// Register the Permutive integration. /// /// # Errors diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index d0cf37275..4843d213f 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -977,6 +977,17 @@ fn build( Ok(Some(PrebidIntegration::try_new(config)?)) } +/// Validates the Prebid configuration for deployment and reports whether +/// the integration is enabled. +/// +/// # Errors +/// +/// Returns an error when the Prebid configuration cannot be parsed or fails +/// validation. +pub(crate) fn validate(settings: &Settings) -> Result> { + validate_config_for_startup(settings).map(|config| config.is_some()) +} + /// Register the Prebid integration when enabled. /// /// # Errors @@ -1841,8 +1852,9 @@ impl PrebidAuctionProvider { .map(|ac| ConsentedProvidersSettings { consented_providers: Some(ac.clone()), }), - // EIDs resolved from the KV identity graph and consent-gated - // in `handle_auction` via `gate_eids_by_consent`. + // EIDs resolved from the KV identity graph and gated on the + // resolved permission state in `handle_auction` via + // `gate_eids_by_permissions`. eids: request.user.eids.clone(), } .to_ext(), @@ -3057,7 +3069,13 @@ origin_url = "https://origin.test-publisher.com" proxy_secret = "test-secret" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + +[geo] +assume_single_jurisdiction = true "#; /// Parse a TOML string containing only the `[integrations.prebid]` section diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 280eae847..9a36bce65 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -7,14 +7,17 @@ use edgezero_core::body::Body as EdgeBody; use error_stack::Report; use http::{Method, Request, Response}; use matchit::Router; +use sha2::{Digest as _, Sha256}; use crate::constants::HEADER_X_TS_EC; use crate::ec::EcContext; +use crate::ec::device::DeviceProvider; use crate::ec::kv::KvIdentityGraph; +use crate::ec::provider::{EcProviderSelection, EdgeCookieProvider}; use crate::error::TrustedServerError; use crate::geo::GeoInfo; use crate::http_util::is_navigation_request; -use crate::platform::RuntimeServices; +use crate::platform::{DisabledGeo, PlatformGeo, RuntimeServices}; use crate::settings::Settings; /// Action returned by attribute rewriters to describe how the runtime should mutate the element. @@ -329,6 +332,11 @@ pub struct RequestFilterInput<'a> { pub services: &'a RuntimeServices, pub request: &'a mut Request, pub geo_info: Option<&'a GeoInfo>, + /// The permission state resolved for this request at the start of the + /// request cycle, so a filter reads the same permissions the rest of the + /// request uses rather than resolving its own. `None` only on paths that + /// build no EC context, such as batch sync and admin diagnostics. + pub permissions: Option<&'a crate::permissions::PermissionState>, /// Whether the request matches a registered integration proxy route. pub is_integration_route: bool, } @@ -409,6 +417,10 @@ pub struct RequestFilterRegistryInput<'a> { pub services: &'a RuntimeServices, pub req: &'a mut Request, pub geo_info: Option<&'a GeoInfo>, + /// The permission state resolved for this request at the start of the + /// request cycle, passed on to every filter. `None` only on paths that + /// build no EC context, such as batch sync and admin diagnostics. + pub permissions: Option<&'a crate::permissions::PermissionState>, } /// Outcome returned by [`IntegrationRegistry::filter_request`]. @@ -582,17 +594,53 @@ pub trait IntegrationHeadInjector: Send + Sync { } } +/// A browser module a registration carries, for a module built outside +/// `trusted-server-js`. The crate embeds its built IIFE with `include_str!` +/// and states its SHA-256 as a literal next to it; the registry verifies the +/// two agree when it is built, and the served `?v=` hash is derived from it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CarriedJsModule { + /// The built IIFE. + pub source: &'static str, + /// SHA-256 of `source`, hex encoded, lower case. + pub sha256: &'static str, +} + /// Registration payload returned by integration builders. pub struct IntegrationRegistration { pub integration_id: &'static str, pub js_deferred: bool, pub js_disabled: bool, + /// Browser module carried by the registration, when the module is not + /// compiled into `trusted-server-js`. + pub js_module: Option, + /// Serve the module only on its own `/static/tsjs=tsjs-.min.js` path, + /// never in the unified bundle and never as a deferred tag; the + /// integration injects the tag itself when it decides to. + pub js_standalone: bool, pub proxies: Vec>, pub attribute_rewriters: Vec>, pub script_rewriters: Vec>, pub html_post_processors: Vec>, pub head_injectors: Vec>, pub request_filters: Vec>, + /// Geo provider this module supplies, selectable by `[geo] provider`. + /// + /// Declaring one does not make it active, because the module is only asked + /// to resolve location when `[geo] provider` names this module's id. + pub geo_provider: Option>, + /// Edge Cookie provider this module supplies, selectable by `[ec] provider`. + /// + /// Declaring one does not make it active, because the module is only asked + /// to create an identifier when `[ec] provider` names this module's id. This + /// is the same route geo takes, so identity is not a second extension + /// mechanism sitting beside the integration system. + pub ec_provider: Option>, + /// Device provider this module supplies, selectable by `[device] provider`. + /// + /// Declaring one does not make it active, because the module is only asked + /// to classify a request when `[device] provider` names this module's id. + pub device_provider: Option>, } impl IntegrationRegistration { @@ -613,12 +661,17 @@ impl IntegrationRegistrationBuilder { integration_id, js_deferred: false, js_disabled: false, + js_module: None, + js_standalone: false, proxies: Vec::new(), attribute_rewriters: Vec::new(), script_rewriters: Vec::new(), html_post_processors: Vec::new(), head_injectors: Vec::new(), request_filters: Vec::new(), + geo_provider: None, + ec_provider: None, + device_provider: None, }, } } @@ -665,11 +718,46 @@ impl IntegrationRegistrationBuilder { self } + /// Declare the geo provider this module supplies. + /// + /// The provider only resolves location when `[geo] provider` names this + /// module's id, and a declared provider the selector does not choose is + /// logged as a warning when the registry is built. + #[must_use] + pub fn with_geo_provider(mut self, provider: Arc) -> Self { + self.registration.geo_provider = Some(provider); + self + } + + /// Declare the Edge Cookie provider this module supplies. + /// + /// The provider only creates identifiers when `[ec] provider` names this + /// module's id, and a declared provider the selector does not choose is + /// logged as a warning when the registry is built, the same as geo. + #[must_use] + pub fn with_ec_provider(mut self, provider: Arc) -> Self { + self.registration.ec_provider = Some(provider); + self + } + + /// Declare the device provider this module supplies. + /// + /// The provider only classifies requests when `[device] provider` names + /// this module's id, and a declared provider the selector does not choose + /// is logged as a warning when the registry is built, the same as geo. + #[must_use] + pub fn with_device_provider(mut self, provider: Arc) -> Self { + self.registration.device_provider = Some(provider); + self + } + /// Mark this integration's JS module for deferred loading via /// `", + html_escape_for_script(&permissions_json_or_empty(permissions_json)), html_escape_for_script(slots_json), html_escape_for_script(&bids) ) } +/// Build the `` seam script for a request whose ad stack did not run. +/// +/// The head of a shared template carries nothing request-scoped, so the seam is +/// the only place this reader's permission state can be delivered. Before this +/// existed the seam was empty whenever the ad stack was skipped, which left a +/// bot-classified or permission-denied visitor with no state on the page at all. +/// +/// Carries the state and nothing else. It deliberately does not set `adSlots` or +/// `bids` and does not call `scheduleInitialAdInit`, because scheduling `adInit` +/// for traffic that opted out is what the gate in [`seam_script_for`] exists to +/// prevent. +pub(crate) fn build_permissions_seam_script(permissions_json: &str) -> String { + format!( + "", + html_escape_for_script(&permissions_json_or_empty(permissions_json)) + ) +} + /// The slot definitions a shared-mode seam must carry, as JSON. /// /// Mirrors [`template_ad_slots_script`]'s gating: same `should_run_ad_stack` condition, @@ -6120,6 +6250,61 @@ pub(crate) fn template_ad_slots_script( } } +/// The permission-state `", + escaped + ) +} + +/// The permission state as page JSON, substituting the empty state for an unset +/// value. +/// +/// [`PermissionState::page_json`] never returns an empty string, so this only +/// covers a params value nothing filled in. `JSON.parse("")` throws, and a +/// thrown head script takes the rest of the snippet with it, so an unset value +/// renders as the empty state rather than as broken JavaScript. +fn permissions_json_or_empty(permissions_json: &str) -> Cow<'_, str> { + if permissions_json.is_empty() { + Cow::Owned(PermissionState::default().page_json()) + } else { + Cow::Borrowed(permissions_json) + } +} + /// Build the `tsjs.adSlots` `".to_string(), - width: 300, - height: 250, - })); + bid.renderer = Some( + BidRenderer::from_typed( + APS_RENDERER_TYPE, + &ApsRendererV1 { + version: 1, + account_id: "example-account".to_string(), + bid_id: "selected-bid".to_string(), + creative_id: None, + tag_type: ApsTagType::Iframe, + creative_url: "https://creative.example/render".to_string(), + aax_response: "fictional-base64".to_string(), + width: 300, + height: 250, + }, + ) + .expect("should build APS renderer descriptor"), + ); let winning_bids = HashMap::from([("atf_sidebar_ad".to_string(), bid)]); let map = build_bid_map(&winning_bids, PriceGranularity::Dense, &settings, "", false); @@ -18764,6 +19579,71 @@ mod tests { assert!(script.contains("\\u003C/script\\u003E")); } + #[test] + fn bid_map_prefers_the_renderer_bid_id_over_ad_id_and_the_openrtb_bid_id() { + // Every hb_adid source carries a different value, so the assertion + // below passes only when the renderer field is the one read. The + // envelope is oversized on purpose: reading this field must not + // copy it. + let mut settings = test_settings(); + settings.auction.sanitize_creatives = true; + let mut bid = make_bid("atf_sidebar_ad", 1.50, "aps", "ad-id-value", "", ""); + bid.bid_id = Some("openrtb-bid-id".to_string()); + bid.cache_id = None; + bid.creative = Some("".to_string()); + bid.renderer = Some( + BidRenderer::from_typed( + APS_RENDERER_TYPE, + &ApsRendererV1 { + version: 1, + account_id: "example-account".to_string(), + bid_id: "renderer-bid-id".to_string(), + creative_id: None, + tag_type: ApsTagType::Iframe, + creative_url: "https://creative.example/render".to_string(), + aax_response: "A".repeat(200 * 1024), + width: 300, + height: 250, + }, + ) + .expect("should build APS renderer descriptor"), + ); + let winning_bids = HashMap::from([("atf_sidebar_ad".to_string(), bid)]); + + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, &settings, "", false); + let obj = map["atf_sidebar_ad"] + .as_object() + .expect("should include APS bid"); + + assert_eq!( + obj["hb_adid"], "renderer-bid-id", + "should read hb_adid from the renderer payload rather than ad_id or bid_id" + ); + } + + #[test] + fn bid_map_ignores_a_renderer_bid_id_carried_under_another_type_tag() { + let mut settings = test_settings(); + settings.auction.sanitize_creatives = true; + let mut bid = make_bid("atf_sidebar_ad", 1.50, "example", "ad-id-value", "", ""); + bid.cache_id = None; + bid.renderer = Some( + BidRenderer::new("example", serde_json::json!({ "bidId": "renderer-bid-id" })) + .expect("should build renderer descriptor"), + ); + let winning_bids = HashMap::from([("atf_sidebar_ad".to_string(), bid)]); + + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, &settings, "", false); + let obj = map["atf_sidebar_ad"] + .as_object() + .expect("should include the bid"); + + assert_eq!( + obj["hb_adid"], "ad-id-value", + "should ignore a bidId carried under a tag that is not the APS renderer" + ); + } + #[test] fn bid_map_omits_creative_rejected_by_processing_without_renderer() { // Sanitization is opt-in, so enable it: script-only markup is what @@ -19460,10 +20340,15 @@ mod tests { winning_bid: bool, ) -> AuctionOrchestrator { let mut orchestrator = AuctionOrchestrator::new(settings.auction.clone()); - orchestrator.register_provider(Arc::new(AuctionIdTestProvider { - captured_request, - winning_bid, - })); + orchestrator + .register_provider( + Arc::new(AuctionIdTestProvider { + captured_request, + winning_bid, + }), + crate::integrations::CORE_SOURCE, + ) + .expect("should register"); orchestrator } @@ -20284,7 +21169,7 @@ mod tests { [creative_opportunities]\ngam_network_id = \"12345\"\n", crate_test_settings_str() ); - Settings::from_toml(&toml).expect("should parse settings with a capturing provider") + settings_from_toml(&toml) } fn article_slot() -> Vec { @@ -20369,9 +21254,14 @@ mod tests { captured: &Arc>>, ) -> AuctionOrchestrator { let mut orchestrator = AuctionOrchestrator::new(settings.auction.clone()); - orchestrator.register_provider(Arc::new(RequestCapturingProvider { - captured: Arc::clone(captured), - })); + orchestrator + .register_provider( + Arc::new(RequestCapturingProvider { + captured: Arc::clone(captured), + }), + crate::integrations::CORE_SOURCE, + ) + .expect("should register"); orchestrator } @@ -20453,8 +21343,12 @@ mod tests { .body(EdgeBody::empty()) .expect("should build test request"); + let registry = test_registry(&settings); let _ = handle_publisher_request( - &settings, + AppContext { + settings: &settings, + integration_registry: ®istry, + }, &services, None, &mut ec_context, @@ -20537,8 +21431,12 @@ mod tests { .expect("should build test request"); let slots = slots_with_over_limit_dynamic_sibling(); + let registry = test_registry(&settings); let _ = handle_publisher_request( - &settings, + AppContext { + settings: &settings, + integration_registry: ®istry, + }, &services, None, &mut ec_context, diff --git a/crates/trusted-server-core/src/response_privacy.rs b/crates/trusted-server-core/src/response_privacy.rs index 8674429ea..58c614dac 100644 --- a/crates/trusted-server-core/src/response_privacy.rs +++ b/crates/trusted-server-core/src/response_privacy.rs @@ -24,6 +24,17 @@ use crate::settings::Settings; #[derive(Clone, Copy, Debug)] pub struct TerminalPrivateResponse; +/// Request marker meaning the response is personalized to this request and +/// must not be shared through any cache or template. +/// +/// Any integration may set it from a request filter, and core acts on it +/// without knowing which integration asked. Whenever it is present the request +/// keeps to the origin path rather than a shared template. For an HTML +/// document core also buffers the full body, and it enforces private caching +/// on the HTML response that body produces. +#[derive(Debug, Clone, Copy)] +pub struct PersonalizedResponse; + const INACTIVE_AD_STACK_BROWSER_CACHE_CONTROL: &str = "private, max-age=60"; #[derive(Debug, Clone, Copy)] @@ -218,7 +229,13 @@ mod tests { origin_url = "https://origin.test-publisher.example.com" proxy_secret = "unit-test-proxy-secret" + [geo] + assume_single_jurisdiction = true + [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 0c78ab00b..72518b8f8 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -20,6 +20,10 @@ use crate::cache_policy::{CachePolicy, CacheVisibility}; use crate::consent_config::ConsentConfig; use crate::constants::INTERNAL_HEADERS; use crate::creative_opportunities::CreativeOpportunitiesConfig; +use crate::ec::provider::{ + EcProviderSelection, HMAC_PROVIDER_KEY, HOST_SIGNALS_PROVIDER_KEY, + check_named_provider_configuration, +}; use crate::error::TrustedServerError; use crate::host_header::validate_host_header_override_value; use crate::platform::PlatformImageOptimizerRegion; @@ -476,9 +480,59 @@ impl EcPartner { #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] #[serde(deny_unknown_fields)] pub struct Ec { - /// Publisher passphrase used as HMAC key for EC generation. - #[validate(custom(function = Ec::validate_passphrase))] - pub passphrase: Redacted, + /// The key of the Edge Cookie identity provider to activate. + /// + /// Names one of the blocks under [`providers`](Self::providers), for + /// example `"hmac"`. Set it in the `[ec]` TOML section. Deployment tooling + /// can merge a `TRUSTED_SERVER__EC__PROVIDER` environment value into the + /// published configuration before it is loaded, so the same compiled + /// WebAssembly can switch providers at deployment. The running server reads + /// its settings from the platform config store, not the environment. When + /// absent, no Edge Cookie is generated and Trusted Server runs statelessly, + /// and the explicit `"none"` spells the same choice. Selecting a provider + /// whose block is missing is rejected at startup by + /// [`validate_provider_selection`](Self::validate_provider_selection). + /// + /// Typed as [`EcProviderSelection`], which reads and writes the same + /// string, so every check that asks which provider is selected matches on + /// one vocabulary rather than comparing string literals. + #[serde(default)] + pub provider: Option, + + /// Deprecated location of the HMAC passphrase, read so a configuration + /// written for the previous release still starts. + /// + /// [`migrate_legacy_ec_layout`](Self::migrate_legacy_ec_layout) maps it + /// to `provider = "hmac"` with the passphrase in the `[ec.providers.hmac]` + /// block and logs a deprecation warning, so a fleet can move configuration + /// and binaries independently. A configuration carrying both the old and + /// the new form is rejected rather than guessed at. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub passphrase: Option>, + + /// Configuration blocks for the available Edge Cookie identity providers. + /// + /// Each provider has its own optional `[ec.providers.]` block. The + /// [`provider`](Self::provider) selector names which one is active. Exactly + /// one block may be present, and it must be the one the selector names, so + /// [`validate_provider_selection`](Self::validate_provider_selection) + /// rejects an unselected block. + /// Extra exact origins allowed to POST the client resolve endpoint. + /// + /// The endpoint always accepts `https://{publisher.domain}` and nothing + /// else by default. A publisher whose pages are served from another origin, + /// `www` being the common case, lists those origins here. Each entry is a + /// serialized origin (RFC 6454 §6.1) and is compared with the request's + /// `Origin` by the same-origin test of RFC 6454 §5, so the scheme, the host + /// and the port all have to match, with a missing port meaning the scheme's + /// default. A suffix or subdomain match is never performed, because control + /// of a DNS namespace does not make every host under it a trusted + /// identity-setting origin. + #[serde(default)] + pub resolve_allowed_origins: Vec, + #[serde(default)] + #[validate(nested)] + pub providers: EcProviders, /// Fastly KV store name for the EC identity graph. #[serde(default)] @@ -566,6 +620,410 @@ impl Ec { } Ok(()) } + + /// Validates that the selected provider can be configured in this build. + /// + /// When [`provider`](Self::provider) is set, this build must be able to + /// honor the name and whatever that name needs from + /// [`providers`](Self::providers) must be present, so a deployment that + /// selects a provider (in TOML or via the environment override) but has not + /// configured it fails fast at startup rather than silently running + /// stateless. When no provider is selected, Trusted Server runs statelessly + /// and this check passes. + /// + /// What a name needs is answered by `check_named_provider_configuration` + /// in [`crate::ec::provider`], beside the resolution it belongs to, rather + /// than by a block lookup here, because the settings cannot know which + /// names read a block, which are built from nothing, and which are compiled + /// out of this build. Only the resolution knows that. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Configuration`] when the selected provider + /// is not compiled into this build, when the `[ec.providers.]` block + /// it needs is absent, or when a configured block is not the selected one. + pub fn validate_provider_selection(&self) -> Result<(), Report> { + let Some(selection) = self.provider.as_ref() else { + if !self.providers.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: "[ec.providers.*] blocks are configured but no [ec] provider is \ + selected. Set [ec] provider = \"\" to activate one, or \ + remove the blocks to run statelessly" + .to_owned(), + })); + } + return Ok(()); + }; + + // `"none"` is explicit statelessness: the same meaning as omitting the + // selector, spelled out. It is subject to the same rule that no + // provider blocks may be left configured. + let EcProviderSelection::Named(key) = selection else { + if !self.providers.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: "[ec] provider = \"none\" selects stateless operation, but \ + [ec.providers.*] blocks are configured. Remove the blocks, or \ + select the provider they configure" + .to_owned(), + })); + } + return Ok(()); + }; + + // Whether this deployment can honor the name is the resolution's + // question, not the settings', so it is asked there. A provider the + // adapter injects has the contents of its block validated by that + // adapter when it builds the provider. + let key = key.as_str(); + check_named_provider_configuration(key, self)?; + + // Every configured block must be the selected one. An unreferenced + // block is almost always a mistake (a mistyped selector or a stale + // block), and accepting it silently invites configuration drift. + let unreferenced: Vec = self + .providers + .configured_keys() + .filter(|configured| *configured != key) + .map(str::to_owned) + .collect(); + if unreferenced.is_empty() { + Ok(()) + } else { + Err(Report::new(TrustedServerError::Configuration { + message: format!( + "[ec.providers.{}] is configured but `{key}` is selected. Remove the \ + unselected block, or correct the selector", + unreferenced.join("], [ec.providers.") + ), + })) + } + } + + /// Migrates the deprecated `[ec] passphrase` form to the provider layout. + /// + /// A configuration still carrying the old key keeps working for one + /// release cycle: it maps to `provider = "hmac"` with the passphrase in + /// the `[ec.providers.hmac]` block, and a deprecation warning names the + /// new location. A configuration carrying both forms is rejected so a + /// half-edited file fails loudly instead of one form silently winning. + /// + /// The deprecated key is held to the same passphrase rules as the new + /// `[ec.providers.hmac]` block. Derive validation runs before this + /// migration and the deprecated field carries no `#[validate]` attribute of + /// its own, so without the check here a short or empty passphrase in the old + /// location would start a deployment that the new location rejects. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Configuration`] when both the deprecated + /// key and any part of the provider configuration are present, or when the + /// deprecated passphrase fails [`Self::validate_passphrase`]. + pub fn migrate_legacy_ec_layout(&mut self) -> Result<(), Report> { + let Some(passphrase) = self.passphrase.take() else { + return Ok(()); + }; + if self.provider.is_some() || !self.providers.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: "[ec] passphrase (deprecated) and the [ec] provider configuration \ + are both present. Keep exactly one form: move the passphrase to \ + [ec.providers.hmac] and delete the old key" + .to_owned(), + })); + } + Self::validate_passphrase(&passphrase).map_err(|err| { + Report::new(TrustedServerError::Configuration { + message: format!( + "[ec] passphrase (deprecated) is invalid ({err}): use a random secret \ + of at least {} bytes, placed in [ec.providers.hmac]", + Self::MIN_PASSPHRASE_LENGTH, + ), + }) + })?; + log::warn!( + "[ec] passphrase is deprecated; move it to [ec.providers.hmac] passphrase and \ + set [ec] provider = \"hmac\"" + ); + self.provider = Some(EcProviderSelection::from(HMAC_PROVIDER_KEY)); + self.providers.hmac = Some(HmacProviderConfig { passphrase }); + Ok(()) + } +} + +/// Configuration blocks for the available Edge Cookie identity providers. +/// +/// Each provider is configured in its own `[ec.providers.]` block, for +/// example: +/// +/// ```toml +/// [ec.providers.hmac] +/// passphrase = "replace-with-32-plus-byte-random-secret" +/// ``` +/// +/// The active provider is chosen by the [`Ec::provider`] selector, and the one +/// block present must be the one it names (see +/// [`Ec::validate_provider_selection`]). +#[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] +pub struct EcProviders { + /// The built-in HMAC-over-client-IP provider, keyed `hmac`. + #[serde(default)] + #[validate(nested)] + pub hmac: Option, + + /// The built-in host-signal provider, keyed `host-signals`. Creates the Edge + /// Cookie from the host's TLS and HTTP/2 signals plus the client IP, so it + /// requires a host that supplies those signals. + #[serde(default, rename = "host-signals")] + #[validate(nested)] + pub host_signals: Option, + + /// Configuration blocks for vendor or host providers that live in their own + /// crates and are injected by the adapter. Any `[ec.providers.]` block + /// whose key is not a built-in is captured here as raw values, and the + /// adapter that constructs the provider deserializes its own block into the + /// vendor crate's config type. Core never names a vendor, so a new provider + /// adds nothing here. + #[serde(flatten)] + vendor: HashMap, +} + +impl EcProviders { + /// Returns the raw configuration block for a vendor provider `key`, or + /// `None` when no `[ec.providers.]` block is present. The adapter that + /// builds the provider deserializes this into its own config type. + #[must_use] + pub fn vendor_config(&self, key: &str) -> Option<&JsonValue> { + self.vendor.get(key) + } + + /// Whether a `[ec.providers.]` block is present for `key`. + /// + /// The answer is the same question for every provider, whichever crate + /// supplies it, so nothing calling this has to know which providers are + /// built into core. + #[must_use] + pub fn has_block(&self, key: &str) -> bool { + self.configured_keys().any(|configured| configured == key) + } + + /// The keys of every configured `[ec.providers.]` block. + /// + /// Each typed built-in block is reported under the name it is configured + /// with, so it appears alongside the vendor blocks rather than being + /// counted separately by each caller. This is the one place that mapping is + /// made, and each entry goes away when its built-in provider becomes a + /// module and its block joins the others. + pub(crate) fn configured_keys(&self) -> impl Iterator { + self.hmac + .iter() + .map(|_| HMAC_PROVIDER_KEY) + .chain(self.host_signals.iter().map(|_| HOST_SIGNALS_PROVIDER_KEY)) + .chain(self.vendor.keys().map(String::as_str)) + } + + /// Whether any provider configuration block is present. + /// + /// Used by [`Ec::validate_provider_selection`] to reject a half-migrated + /// configuration that carries provider blocks with no selector, which + /// would otherwise silently run stateless. + #[must_use] + pub fn is_empty(&self) -> bool { + self.configured_keys().next().is_none() + } +} + +/// Configuration for the built-in HMAC Edge Cookie provider. +/// +/// Mapped from the `[ec.providers.hmac]` TOML block. Unknown keys are +/// rejected, so a mistyped setting fails at startup instead of being accepted +/// silently and leaving the intended setting at its default. +#[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct HmacProviderConfig { + /// Publisher passphrase used as the HMAC key for EC generation. + #[validate(custom(function = Ec::validate_passphrase))] + pub passphrase: Redacted, +} + +/// Configuration for the built-in host-signal Edge Cookie provider. +/// +/// Mapped from the `[ec.providers.host-signals]` TOML block. +#[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct HostSignalsProviderConfig { + /// Passphrase used as the HMAC key over the host signals and client IP. + #[validate(custom(function = Ec::validate_passphrase))] + pub passphrase: Redacted, +} + +/// Device-detection configuration. +/// +/// Mapped from the `[device]` TOML section. Selects which device-detection +/// provider classifies a request into device signals, mirroring the Edge +/// Cookie provider selection in [`Ec`]. +#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct DeviceConfig { + /// The key of the device-detection provider to activate. + /// + /// Defaults to the built-in `builtin` provider when absent, which classifies + /// from the User-Agent alone, so device classification itself makes no + /// host-specific call. The opt-in `fastly` provider strengthens the + /// browser/bot gate with the host's TLS and HTTP/2 signals, which the Fastly + /// entry point reads on every request regardless of this selector. Override + /// it with the + /// `TRUSTED_SERVER__device__provider` environment variable so the same + /// compiled WebAssembly can switch providers at deployment. An unknown key is + /// rejected at startup by + /// [`validate_provider_selection`](Self::validate_provider_selection). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, +} + +impl DeviceConfig { + /// Returns the active device-detection provider key, defaulting to the + /// built-in heuristic. + #[must_use] + pub fn provider_key(&self) -> &str { + self.provider.as_deref().unwrap_or("builtin") + } + + /// Validates the selected device-detection provider. + /// + /// `builtin` and `fastly` are resolved by the core and the Fastly adapter. + /// Any other key names an integration module that declares a device + /// provider, and the registry rejects a key no module supplies, so this + /// cannot be a closed list without shutting modules out of device detection + /// entirely. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Configuration`] never, today. The error + /// type is kept because the caller treats provider validation uniformly + /// across the three capabilities. + pub fn validate_provider_selection(&self) -> Result<(), Report> { + Ok(()) + } +} + +/// Geo / IP intelligence configuration. +/// +/// Mapped from the `[geo]` TOML section. Selects which provider resolves a +/// client IP into [`GeoInfo`](crate::platform::GeoInfo), mirroring the Edge +/// Cookie provider selection in [`Ec`]. +#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct GeoConfig { + /// The key of the geo provider to activate. + /// + /// No provider is the default: Trusted Server resolves no geolocation and + /// makes no host geo call, so a default deployment is not tied to any host + /// geo service, and the permission baseline comes from the top of the + /// `rules` tree in `permissions.yaml`. `provider = "none"` spells + /// the same choice explicitly. The host platform's own geo lookup is + /// opt-in via `provider = "platform"`. Override it with the + /// `TRUSTED_SERVER__geo__provider` environment variable so the same compiled + /// WebAssembly can switch providers at deployment. An unknown key is rejected + /// at startup by + /// [`validate_provider_selection`](Self::validate_provider_selection). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + + /// Acknowledges that, with no geo provider, every request is treated as + /// coming from the place at the top of the `permissions.yaml` `rules` tree. + /// + /// With geolocation off, a visitor from any other jurisdiction silently + /// receives that top node's permission rules. A deployment that + /// runs an Edge Cookie provider without a geo provider must set this to + /// `true`, checked at startup by + /// [`validate_jurisdiction_acknowledgment`](Self::validate_jurisdiction_acknowledgment), + /// so serving a single jurisdiction is an explicit operator decision rather + /// than an accident of the default configuration. + #[serde(default)] + pub assume_single_jurisdiction: bool, +} + +impl GeoConfig { + /// Validates that the selected geo provider is available in this build. + /// + /// No selector is valid and is the default, running without geolocation, + /// the same way the Edge Cookie provider runs statelessly when none is + /// selected. The explicit `"none"` spells the same choice. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Configuration`] when the selected provider + /// key is not one this build provides. + pub fn validate_provider_selection(&self) -> Result<(), Report> { + // Unset, `none` and `platform` are resolved by the core. Any other key + // names an integration module that declares a geo provider, and the + // registry rejects one that no module supplies, so this check cannot be + // a closed list without shutting modules out of geo entirely. + Ok(()) + } + + /// Validates that the compiled `permissions.yaml` parses and declares its + /// top node. + /// + /// The top node is required: its `group` is the permission baseline for a + /// request the geo provider leaves unmatched, and its `jurisdiction` is the + /// consent handling for that same request, so there must always be one. + /// Checking it here turns a malformed policy into a configuration error at + /// startup rather than a panic on the first lookup. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Configuration`] when the compiled policy + /// fails to parse, most usefully when its top node omits `group` or + /// `jurisdiction`. + pub fn validate_permission_policy() -> Result<(), Report> { + crate::permissions::validate_default_policy().map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: format!("permissions.yaml is not usable: {error}"), + }) + }) + } + + /// Validates that running jurisdiction consumers without geolocation is + /// explicitly acknowledged. + /// + /// With no geo provider, every request resolves to the permission baseline + /// at the top of the `permissions.yaml` `rules` tree, so a visitor from any + /// other jurisdiction silently receives that node's rules. That is + /// acceptable only as an explicit operator + /// decision. When an Edge Cookie provider is configured (the permission + /// model gates it by jurisdiction) and no geo provider is selected, + /// [`assume_single_jurisdiction`](Self::assume_single_jurisdiction) must be + /// `true`. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::Configuration`] when an Edge Cookie + /// provider is configured, no geo provider is selected, and + /// `assume_single_jurisdiction` is not set. + pub fn validate_jurisdiction_acknowledgment( + &self, + ec: &Ec, + ) -> Result<(), Report> { + // Location is resolved by the host lookup (`platform`) or by any + // integration module that declares a geo provider. Only an unset + // selector and the explicit `none` resolve nothing, so only those + // two leave every request on the default country. + let geo_disabled = matches!(self.provider.as_deref(), None | Some("none")); + // The selector is a typed enum on this branch, so statelessness is the + // absent selector or the explicit `none`, matched rather than compared + // as a string. + let ec_active = !matches!(ec.provider, None | Some(EcProviderSelection::None)); + if geo_disabled && ec_active && !self.assume_single_jurisdiction { + return Err(Report::new(TrustedServerError::Configuration { + message: "[ec] provider is configured but no [geo] provider is selected, so \ + every request would be treated as the top of the permissions.yaml \ + rules tree. Set [geo] assume_single_jurisdiction = true to \ + acknowledge single-jurisdiction operation, or select a geo provider" + .to_owned(), + })); + } + Ok(()) + } } #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] @@ -2540,6 +2998,18 @@ fn is_default_auction_debug_comment_options(value: &AuctionDebugCommentOptions) *value == AuctionDebugCommentOptions::default() } +// The provider selectors are new sections, so a serialized blob that carries +// them is rejected by a base-revision binary that has never heard of them. +// Omitting the default table keeps an unchanged `ts config push` readable +// across a rollout or a rollback. +fn is_default_device_config(value: &DeviceConfig) -> bool { + *value == DeviceConfig::default() +} + +fn is_default_geo_config(value: &GeoConfig) -> bool { + *value == GeoConfig::default() +} + /// Behavior of the `` auction dump. Only consulted when /// [`DebugConfig::auction_html_comment`] is true. /// @@ -2830,6 +3300,12 @@ pub struct Settings { pub tinybird: TinybirdSettings, #[serde(default)] pub debug: DebugConfig, + #[serde(default, skip_serializing_if = "is_default_device_config")] + #[validate(nested)] + pub device: DeviceConfig, + #[serde(default, skip_serializing_if = "is_default_geo_config")] + #[validate(nested)] + pub geo: GeoConfig, } impl Settings { @@ -2917,6 +3393,14 @@ impl Settings { }) })?; + settings.ec.migrate_legacy_ec_layout()?; + settings.ec.validate_provider_selection()?; + settings.device.validate_provider_selection()?; + settings.geo.validate_provider_selection()?; + GeoConfig::validate_permission_policy()?; + settings + .geo + .validate_jurisdiction_acknowledgment(&settings.ec)?; settings.validate_admin_coverage()?; settings.validate_admin_handler_passwords()?; @@ -2926,6 +3410,22 @@ impl Settings { ); } + // Log the policy's declared default once per settings load, so an + // operator can see which permissions an unmatched request is granted + // without a signal, and which jurisdiction its consent gates apply. + let maps = crate::permissions::PermissionMaps::standard(); + let granted: Vec = maps + .baseline(None, None) + .permissions() + .iter() + .map(|permission| permission.to_string()) + .collect(); + log::info!( + "Permission baseline: permissions.yaml top node, jurisdiction {}; granted without a signal: [{}]", + maps.default_jurisdiction(), + granted.join(", ") + ); + Ok(settings) } @@ -3007,8 +3507,15 @@ impl Settings { pub fn reject_placeholder_secrets(&self) -> Result<(), Report> { let mut insecure_fields: Vec = Vec::new(); - if Ec::is_placeholder_passphrase(self.ec.passphrase.expose()) { - insecure_fields.push("ec.passphrase".to_owned()); + if let Some(hmac) = &self.ec.providers.hmac + && Ec::is_placeholder_passphrase(hmac.passphrase.expose()) + { + insecure_fields.push("ec.providers.hmac.passphrase".to_owned()); + } + if let Some(host_signals) = &self.ec.providers.host_signals + && Ec::is_placeholder_passphrase(host_signals.passphrase.expose()) + { + insecure_fields.push("ec.providers.host-signals.passphrase".to_owned()); } if Publisher::is_placeholder_proxy_secret(self.publisher.proxy_secret.expose()) { insecure_fields.push("publisher.proxy_secret".to_owned()); @@ -3662,15 +4169,77 @@ mod tests { #[test] fn serialized_default_config_stays_readable_by_the_base_revision_schema() { - let settings = Settings::from_toml(&crate_test_settings_str()) + let mut settings = Settings::from_toml(&crate_test_settings_str()) .expect("should parse settings without trusted client IP configuration"); + // The guarantee covers a config that configures none of the sections + // added since the base revision. A configured section is serialized and, + // like a configured `trusted_client_ip`, needs a compatible blob restored + // before rolling back to a binary that predates it. The shared test + // config sets `[geo]` because the permission model requires a default + // country, so both selector tables are reset to unset here. + settings.geo = GeoConfig::default(); + settings.device = DeviceConfig::default(); + let value = serde_json::to_value(&settings).expect("should serialize settings"); serde_json::from_value::(value) .expect("base revision schema should accept a config blob with no trusted client IP"); } + #[test] + fn geo_selector_is_omitted_from_serialized_config_when_unset() { + // `ts config push` serializes `Settings` verbatim, so a selector nobody + // set must not appear in the blob. A `deny_unknown_fields` binary that + // predates the selector rejects the key during rollout or rollback. + // + // The shared fixture writes a `[geo]` table (it acknowledges running + // with no geo provider), so the assertion is about the selector key + // rather than the table, which is what the blob's compatibility + // actually turns on. + let settings = Settings::from_toml(&crate_test_settings_str()) + .expect("should parse settings without a geo selector"); + + let value = serde_json::to_value(&settings).expect("should serialize settings"); + + let geo = value + .get("geo") + .expect("the geo table is written because the fixture acknowledges no geo provider"); + assert!( + geo.get("provider").is_none(), + "an unset geo selector should not be serialized, got {geo}" + ); + assert!( + value + .get("device") + .is_none_or(|device| device.get("provider").is_none()), + "an unset device selector should not be serialized, got {value}" + ); + } + + #[test] + fn a_selected_geo_provider_stays_in_the_serialized_config() { + // The shared test settings already carry a `[geo]` table, so the + // selector is set inside that table rather than in a second one, which + // TOML rejects as a duplicate key. + let settings = Settings::from_toml(&crate_test_settings_str().replace( + "[geo]", + "[geo] +provider = \"none\"", + )) + .expect("should parse settings with a geo selector"); + + let value = serde_json::to_value(&settings).expect("should serialize settings"); + + assert_eq!( + value + .pointer("/geo/provider") + .and_then(serde_json::Value::as_str), + Some("none"), + "a selected geo provider should survive serialization" + ); + } + #[test] fn trusted_client_ip_parses_and_redacts_shared_secret_in_debug_output() { let settings = Settings::from_toml(&trusted_client_ip_toml( @@ -4371,9 +4940,14 @@ mod tests { ); assert_eq!(settings.publisher.origin_host_header_override, None); assert_eq!( - settings.ec.passphrase.expose(), - "test-secret-key-32-bytes-minimum" + settings.ec.provider.as_ref(), + Some(&EcProviderSelection::from(HMAC_PROVIDER_KEY)), + "test settings should select the hmac EC provider" ); + let Some(hmac) = &settings.ec.providers.hmac else { + panic!("test settings should configure the hmac EC provider"); + }; + assert_eq!(hmac.passphrase.expose(), "test-secret-key-32-bytes-minimum"); settings.validate().expect("Failed to validate settings"); } @@ -4540,6 +5114,34 @@ mod tests { ); } + #[test] + fn provider_selection_allows_no_provider_for_stateless_operation() { + let ec = Ec::default(); + assert!(ec.provider.is_none(), "default Ec selects no provider"); + ec.validate_provider_selection() + .expect("should allow no provider selected and run statelessly"); + } + + #[test] + fn provider_selection_rejects_a_selector_without_a_configured_block() { + // Point the selector at a provider whose `[ec.providers.]` block is + // absent, mirroring a deployment that sets the env override to a + // provider it never configured. + let toml_str = + crate_test_settings_str().replace(r#"provider = "hmac""#, r#"provider = "acme""#); + + let err = Settings::from_toml(&toml_str) + .expect_err("selecting an unconfigured provider should fail at startup"); + assert!( + matches!( + err.current_context(), + TrustedServerError::Configuration { .. } + ), + "unconfigured provider selection should be a configuration error, got: {:?}", + err.current_context() + ); + } + #[test] fn cache_asset_rule_globs_respect_path_separators() { let toml_str = format!( @@ -4646,6 +5248,29 @@ mod tests { ); } + #[test] + fn provider_blocks_without_a_selector_are_rejected() { + // A half-migrated configuration that carries an [ec.providers.hmac] + // block but never selects it would silently run stateless; reject it + // at startup instead. + let toml_str = crate_test_settings_str().replace( + "provider = \"hmac\" +", + "", + ); + + let err = Settings::from_toml(&toml_str) + .expect_err("a provider block with no selector should fail at startup"); + assert!( + matches!( + err.current_context(), + TrustedServerError::Configuration { .. } + ), + "should be a configuration error, got: {:?}", + err.current_context() + ); + } + #[test] fn cache_asset_rule_policy_validation_rejects_unsafe_config() { let missing_ttl = format!( @@ -4759,6 +5384,97 @@ mod tests { ); } + #[test] + fn legacy_passphrase_migrates_to_the_hmac_provider() { + let mut ec = Ec { + passphrase: Some(Redacted::new("test-secret-key-32-bytes-minimum".to_owned())), + ..Ec::default() + }; + ec.migrate_legacy_ec_layout() + .expect("should migrate the deprecated form"); + assert_eq!( + ec.provider.as_ref(), + Some(&EcProviderSelection::from(HMAC_PROVIDER_KEY)), + "the deprecated passphrase should select the hmac provider" + ); + assert_eq!( + ec.providers + .hmac + .as_ref() + .expect("should configure the hmac block") + .passphrase + .expose(), + "test-secret-key-32-bytes-minimum", + "the passphrase should move into the hmac block" + ); + assert!( + ec.passphrase.is_none(), + "the deprecated field should be consumed by the migration" + ); + } + + /// The crate test configuration with its `[ec]` section rewritten to the + /// deprecated single-passphrase form. + fn legacy_ec_settings_str(passphrase: &str) -> String { + let base = crate_test_settings_str(); + let (before, rest) = base + .split_once("[ec]") + .expect("should find the [ec] section in the test settings"); + let (_, after) = rest + .split_once("[request_signing]") + .expect("should find the [request_signing] section in the test settings"); + let legacy = + format!("{before}[ec]\npassphrase = \"{passphrase}\"\n\n[request_signing]{after}"); + assert!( + !legacy.contains("[ec.providers.hmac]"), + "the legacy configuration should carry no provider block" + ); + legacy + } + + #[test] + fn a_legacy_passphrase_is_held_to_the_passphrase_rules() { + // Derive validation runs before the migration and the deprecated field + // carries no `#[validate]` attribute, so the migration itself has to + // apply the passphrase rules. Without that, a value the new + // `[ec.providers.hmac]` block rejects would still start a deployment + // from the old location. + let short = Settings::from_toml(&legacy_ec_settings_str("short")) + .expect_err("a short legacy passphrase should be rejected"); + assert!( + format!("{short:?}").contains("passphrase (deprecated) is invalid"), + "should name the deprecated passphrase as the fault: {short:?}" + ); + + let empty = Settings::from_toml(&legacy_ec_settings_str("")) + .expect_err("an empty legacy passphrase should be rejected"); + assert!( + format!("{empty:?}").contains("passphrase (deprecated) is invalid"), + "should name the deprecated passphrase as the fault: {empty:?}" + ); + + let settings = + Settings::from_toml(&legacy_ec_settings_str("test-secret-key-32-bytes-minimum")) + .expect("a legacy passphrase of adequate length should still start"); + assert_eq!( + settings.ec.provider.as_ref(), + Some(&EcProviderSelection::from(HMAC_PROVIDER_KEY)), + "an adequate legacy passphrase should still select the hmac provider" + ); + assert_eq!( + settings + .ec + .providers + .hmac + .as_ref() + .expect("should configure the hmac block") + .passphrase + .expose(), + "test-secret-key-32-bytes-minimum", + "an adequate legacy passphrase should still move into the hmac block" + ); + } + #[test] fn cache_asset_rule_validation_rejects_invalid_config() { let duplicate_ids = format!( @@ -4836,6 +5552,269 @@ mod tests { ); } + #[test] + fn legacy_passphrase_alongside_provider_config_is_rejected() { + let mut ec = Ec { + passphrase: Some(Redacted::new("test-secret-key-32-bytes-minimum".to_owned())), + provider: Some(EcProviderSelection::from(HMAC_PROVIDER_KEY)), + ..Ec::default() + }; + let err = ec + .migrate_legacy_ec_layout() + .expect_err("both forms present should be rejected"); + assert!( + matches!( + err.current_context(), + TrustedServerError::Configuration { .. } + ), + "should be a configuration error, got: {:?}", + err.current_context() + ); + } + + #[test] + fn an_unknown_key_in_the_hmac_provider_block_is_rejected() { + // A mistyped key in a provider block used to be dropped silently, which + // leaves the setting the operator meant to change at its default. + let toml_str = crate_test_settings_str().replace( + "passphrase = \"test-secret-key-32-bytes-minimum\"", + "passphrase = \"test-secret-key-32-bytes-minimum\"\n typo_key = \"x\"", + ); + assert!( + toml_str.contains("typo_key"), + "the test configuration should carry the unknown key" + ); + + let err = Settings::from_toml(&toml_str) + .expect_err("an unknown key in [ec.providers.hmac] should be rejected"); + assert!( + format!("{err:?}").contains("typo_key"), + "should name the unknown key: {err:?}" + ); + } + + #[test] + fn provider_none_is_explicit_stateless() { + let ec = Ec { + provider: Some(EcProviderSelection::None), + ..Ec::default() + }; + ec.validate_provider_selection() + .expect("explicit none with no blocks should be valid"); + } + + #[test] + fn provider_none_with_configured_blocks_is_rejected() { + let ec = Ec { + provider: Some(EcProviderSelection::None), + providers: EcProviders { + hmac: Some(HmacProviderConfig { + passphrase: Redacted::new("test-secret-key-32-bytes-minimum".to_owned()), + }), + ..EcProviders::default() + }, + ..Ec::default() + }; + assert!( + ec.validate_provider_selection().is_err(), + "none alongside configured blocks should be rejected" + ); + } + + #[test] + fn device_provider_defaults_to_builtin_and_rejects_unknown() { + let config = DeviceConfig::default(); + assert_eq!( + config.provider_key(), + "builtin", + "no selector should default to the built-in provider" + ); + config + .validate_provider_selection() + .expect("should validate the built-in default"); + + let fastly = DeviceConfig { + provider: Some("fastly".to_owned()), + }; + fastly + .validate_provider_selection() + .expect("should validate the fastly opt-in"); + + // As with geo, a key core does not know is no longer a settings error, + // because a module id is a legitimate value here too. + let module_key = DeviceConfig { + provider: Some("acme".to_owned()), + }; + module_key + .validate_provider_selection() + .expect("a module id should be accepted by settings validation"); + + // And as with geo, the rejection happens at registry build, so a + // mistyped selector cannot fall back to the built-in provider in + // silence. + let mut settings = crate::test_support::tests::create_test_settings(); + settings.device.provider = Some("acme".to_owned()); + let error = match crate::integrations::IntegrationRegistry::new(&settings) { + Ok(_) => { + panic!("a device provider no module supplies should be rejected at registry build") + } + Err(error) => error, + }; + let message = error.to_string(); + assert!( + message.contains("acme") && message.contains("[device] provider"), + "the error should name the selector and the module, got: {message}" + ); + } + + #[test] + fn an_unselected_provider_block_is_rejected() { + // A vendor selector with the vendor block present, plus a stray hmac + // block, is almost always a stale or mistyped configuration. + let toml_str = crate_test_settings_str().replace( + "provider = \"hmac\"", + "provider = \"acme\"\n\n [ec.providers.acme]\n api_key = \"example\"", + ); + let err = Settings::from_toml(&toml_str) + .expect_err("a configured but unselected block should fail at startup"); + assert!( + matches!( + err.current_context(), + TrustedServerError::Configuration { .. } + ), + "should be a configuration error, got: {:?}", + err.current_context() + ); + } + + #[test] + fn geo_provider_accepts_default_platform_and_none_and_rejects_unknown() { + let config = GeoConfig::default(); + assert!( + config.provider.is_none(), + "geo should default to no selector, which selects no geo provider" + ); + config + .validate_provider_selection() + .expect("should validate the default of running without geolocation"); + + let platform = GeoConfig { + provider: Some("platform".to_owned()), + assume_single_jurisdiction: false, + }; + platform + .validate_provider_selection() + .expect("should validate the explicit platform selection"); + + let none = GeoConfig { + provider: Some("none".to_owned()), + assume_single_jurisdiction: false, + }; + none.validate_provider_selection() + .expect("should validate the explicit opt-out of geolocation"); + + // A key core does not know is no longer a settings error, because a + // module id is a legitimate value and a closed list here would shut + // every module out of geo. Settings accepts it and the registry decides. + let module_key = GeoConfig { + provider: Some("acme".to_owned()), + assume_single_jurisdiction: false, + }; + module_key + .validate_provider_selection() + .expect("a module id should be accepted by settings validation"); + + // The rejection moved to registry build, where it is known whether any + // module supplies the name. With no module supplying `acme`, building + // the registry fails and the error names the selector and the module. + let mut settings = crate::test_support::tests::create_test_settings(); + settings.geo.provider = Some("acme".to_owned()); + // `IntegrationRegistry` is not `Debug`, so the error is taken by match + // rather than `expect_err`. + let error = match crate::integrations::IntegrationRegistry::new(&settings) { + Ok(_) => { + panic!("a geo provider no module supplies should be rejected at registry build") + } + Err(error) => error, + }; + let message = error.to_string(); + assert!( + message.contains("acme") && message.contains("[geo] provider"), + "the error should name the selector and the module, got: {message}" + ); + } + + #[test] + fn unknown_keys_in_provider_sections_are_rejected() { + // A mistyped key must fail at startup rather than silently selecting + // a default behind the operator's back. + for (section, bad_key) in [ + ("[geo]", "providr = \"platform\""), + ("[device]", "providr = \"builtin\""), + ] { + let toml_str = format!( + "{}\n\n {section}\n {bad_key}\n", + crate_test_settings_str() + ); + assert!( + Settings::from_toml(&toml_str).is_err(), + "an unknown key in {section} should be rejected" + ); + } + + let toml_str = crate_test_settings_str().replace( + "[ec.providers.hmac]", + "[ec.providers.hmac]\n unexpected = \"value\"", + ); + assert!( + Settings::from_toml(&toml_str).is_err(), + "an unknown key in [ec.providers.hmac] should be rejected" + ); + } + + #[test] + fn the_compiled_permission_policy_validates_at_startup() { + // The compiled-in sample declares its top node, so startup + // accepts it. A policy that omitted `group` or `jurisdiction` would be + // rejected here rather than panicking on the first lookup, which the + // parser tests in `permissions` cover directly. + GeoConfig::validate_permission_policy() + .expect("the compiled-in sample should validate at startup"); + } + + #[test] + fn ec_without_geo_requires_the_single_jurisdiction_acknowledgment() { + // The base test settings acknowledge single-jurisdiction operation. + // Removing the acknowledgment while an EC provider is configured and + // no geo provider is selected must fail at startup. + let toml_str = crate_test_settings_str().replace("assume_single_jurisdiction = true\n", ""); + let err = Settings::from_toml(&toml_str) + .expect_err("an EC provider with no geo provider needs the acknowledgment"); + assert!( + matches!( + err.current_context(), + TrustedServerError::Configuration { .. } + ), + "should be a configuration error, got: {:?}", + err.current_context() + ); + + // Selecting a geo provider removes the requirement. + let toml_str = crate_test_settings_str() + .replace("assume_single_jurisdiction = true\n", "") + .replace("[geo]", "[geo]\n provider = \"platform\""); + Settings::from_toml(&toml_str) + .expect("a geo provider resolves jurisdictions, so no acknowledgment is needed"); + + // With no EC provider there is no jurisdiction consumer to protect. + let toml_str = crate_test_settings_str() + .replace("assume_single_jurisdiction = true\n", "") + .replace("provider = \"hmac\"", "") + .replace("[ec.providers.hmac]\n passphrase = \"test-secret-key-32-bytes-minimum\"", ""); + Settings::from_toml(&toml_str) + .expect("stateless operation needs no jurisdiction acknowledgment"); + } + #[test] fn validate_rejects_trailing_slash_in_origin_url() { let toml_str = crate_test_settings_str().replace( @@ -5230,7 +6209,9 @@ origin_host_header_overide = "www.example.com""#, let mut settings = Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings"); settings.publisher.proxy_secret = Redacted::new("unit-test-proxy-secret".to_owned()); - settings.ec.passphrase = Redacted::new("test-secret-key-32-bytes-minimum".to_owned()); + settings.ec.providers.hmac = Some(HmacProviderConfig { + passphrase: Redacted::new("test-secret-key-32-bytes-minimum".to_owned()), + }); settings.handlers[0].password = Redacted::new("replace-with-admin-password-32-bytes".to_owned()); @@ -5869,7 +6850,13 @@ origin_host_header_overide = "www.example.com""#, proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should parse settings without max_buffered_body_bytes"); @@ -5899,13 +6886,20 @@ origin_host_header_overide = "www.example.com""#, proxy_secret = "unit-test-proxy-secret" max_buffered_body_bytes = 0 + [geo] + assume_single_jurisdiction = true + [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ); + let error = result.expect_err("should reject a zero buffered-body cap"); assert!( - result.is_err(), - "publisher.max_buffered_body_bytes = 0 must fail config validation" + error.to_string().contains("max_buffered_body_bytes"), + "the rejection should be for the zero cap, not another validation, got: {error}" ); } @@ -7054,8 +8048,14 @@ origin_host_header_overide = "www.example.com""#, proxy_secret = "unit-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + [geo] + assume_single_jurisdiction = true + [request_signing] config_store_id = "test-config-store-id" secret_store_id = "test-secret-store-id" @@ -7386,7 +8386,13 @@ cookie_domain = ".example.com" origin_url = "https://origin.example.com" proxy_secret = "secret" +[geo] +assume_single_jurisdiction = true + [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [creative_opportunities] @@ -7470,7 +8476,13 @@ cookie_domain = ".example.com" origin_url = "https://origin.example.com" proxy_secret = "secret" +[geo] +assume_single_jurisdiction = true + [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [creative_opportunities] @@ -7506,7 +8518,13 @@ cookie_domain = ".example.com" origin_url = "https://origin.example.com" proxy_secret = "secret" +[geo] +assume_single_jurisdiction = true + [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [creative_opportunities] @@ -7548,7 +8566,13 @@ cookie_domain = ".example.com" origin_url = "https://origin.example.com" proxy_secret = "secret" +[geo] +assume_single_jurisdiction = true + [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" [creative_opportunities] @@ -7649,6 +8673,29 @@ formats = [{{ width = 300, height = 250 }}] ); } + /// An unset selector must not serialize as `"provider": null`. + /// + /// The section as a whole is skipped while every field is default, so this + /// serializes the struct directly. Once a later change makes another field + /// required, the section is always emitted and a null selector would then + /// reach a config blob, where a binary that predates the field rejects it. + #[test] + fn an_unset_provider_selector_is_omitted_from_the_serialized_section() { + let geo = GeoConfig::default(); + let json = serde_json::to_string(&geo).expect("should serialize the geo section"); + assert!( + !json.contains("provider"), + "an unset geo selector should be omitted rather than serialized as null, got {json}" + ); + + let device = DeviceConfig::default(); + let json = serde_json::to_string(&device).expect("should serialize the device section"); + assert!( + !json.contains("provider"), + "an unset device selector should be omitted rather than serialized as null, got {json}" + ); + } + #[test] fn admin_endpoints_match_fastly_router() { let router_source = include_str!("../../trusted-server-adapter-fastly/src/app.rs"); diff --git a/crates/trusted-server-core/src/test_support.rs b/crates/trusted-server-core/src/test_support.rs index 5f094c0d2..6dbba9b98 100644 --- a/crates/trusted-server-core/src/test_support.rs +++ b/crates/trusted-server-core/src/test_support.rs @@ -21,6 +21,14 @@ pub mod tests { origin_url = "https://origin.test-publisher.com" proxy_secret = "unit-test-proxy-secret" + [geo] + # A gdpr-eu country, where every permission requires a signal. This + # reproduces the prior no-default floor, so existing tests are + # unaffected by the now-required default. + # Tests run with no geo provider, so single-jurisdiction operation + # is acknowledged the same way a deployment would. + assume_single_jurisdiction = true + [integrations.prebid] enabled = true server_url = "https://test-prebid.com/openrtb2/auction" @@ -31,7 +39,11 @@ pub mod tests { rewrite_attributes = ["href", "link", "url"] [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + [request_signing] config_store_id = "test-config-store-id" secret_store_id = "test-secret-store-id" diff --git a/crates/trusted-server-core/src/tsjs.rs b/crates/trusted-server-core/src/tsjs.rs index e8275b814..8cb2216a2 100644 --- a/crates/trusted-server-core/src/tsjs.rs +++ b/crates/trusted-server-core/src/tsjs.rs @@ -1,23 +1,29 @@ -use trusted_server_js::{concatenated_hash, single_module_hash}; +//! URLs and `", - tsjs_script_src(module_ids), + tsjs_script_src(parts), ) } -/// `/static` URL for the unified bundle when exact module IDs are unavailable. +/// `/static` URL for the unified bundle when the exact parts are unavailable. /// /// This intentionally omits `?v=` because the serving path can only mark a URL /// immutable when the hash matches the exact enabled module set. Use -/// [`tsjs_script_src`] with exact module IDs when [`IntegrationRegistry`] is -/// available. +/// [`tsjs_script_src`] with the registry's parts when [`IntegrationRegistry`] +/// is available. /// /// [`IntegrationRegistry`]: crate::integrations::IntegrationRegistry #[must_use] @@ -59,7 +65,7 @@ pub fn tsjs_unified_script_src() -> String { "/static/tsjs=tsjs-unified.min.js".to_string() } -/// `", - tsjs_deferred_script_src(module_id) + tsjs_deferred_script_src(part) ) } -/// Generate all deferred `"), "should generate exactly one trusted server script tag" ); @@ -204,18 +251,18 @@ mod tests { #[test] fn publisher_tsjs_script_tag_renders_static_attributes() { - let module_ids = ["gpt"]; - let src = tsjs_script_src(&module_ids); + let parts = compile_time_parts(&["gpt"]); + let src = tsjs_script_src(&parts); assert_eq!( - tsjs_script_tag_with_attributes(&module_ids, &[("data-ts-gam-attribution", "true")]), + tsjs_script_tag_with_attributes(&parts, &[("data-ts-gam-attribution", "true")]), format!( "" ), "should render trusted static attributes on the publisher bundle tag" ); assert_eq!( - tsjs_script_tag(&module_ids), + tsjs_script_tag(&parts), format!(""), "should keep the generic tag byte-for-byte unmarked" ); @@ -226,7 +273,10 @@ mod tests { expected = "attribute name should contain only lowercase ASCII letters, digits, and hyphens" )] fn publisher_tsjs_script_tag_rejects_invalid_attribute_name() { - let _ = tsjs_script_tag_with_attributes(&["gpt"], &[("data-bad_name", "true")]); + let _ = tsjs_script_tag_with_attributes( + &compile_time_parts(&["gpt"]), + &[("data-bad_name", "true")], + ); } #[test] @@ -234,31 +284,43 @@ mod tests { expected = "attribute name should contain only lowercase ASCII letters, digits, and hyphens" )] fn publisher_tsjs_script_tag_rejects_empty_attribute_name() { - let _ = tsjs_script_tag_with_attributes(&["gpt"], &[("", "true")]); + let _ = tsjs_script_tag_with_attributes(&compile_time_parts(&["gpt"]), &[("", "true")]); } #[test] #[should_panic(expected = "attribute value should not contain HTML-sensitive characters")] fn publisher_tsjs_script_tag_rejects_double_quote_in_attribute_value() { - let _ = tsjs_script_tag_with_attributes(&["gpt"], &[("data-safe-name", "bad\"value")]); + let _ = tsjs_script_tag_with_attributes( + &compile_time_parts(&["gpt"]), + &[("data-safe-name", "bad\"value")], + ); } #[test] #[should_panic(expected = "attribute value should not contain HTML-sensitive characters")] fn publisher_tsjs_script_tag_rejects_ampersand_in_attribute_value() { - let _ = tsjs_script_tag_with_attributes(&["gpt"], &[("data-safe-name", "bad&value")]); + let _ = tsjs_script_tag_with_attributes( + &compile_time_parts(&["gpt"]), + &[("data-safe-name", "bad&value")], + ); } #[test] #[should_panic(expected = "attribute value should not contain HTML-sensitive characters")] fn publisher_tsjs_script_tag_rejects_less_than_in_attribute_value() { - let _ = tsjs_script_tag_with_attributes(&["gpt"], &[("data-safe-name", "badvalue")]); + let _ = tsjs_script_tag_with_attributes( + &compile_time_parts(&["gpt"]), + &[("data-safe-name", "bad>value")], + ); } #[test] @@ -278,36 +340,58 @@ mod tests { #[test] fn tsjs_single_module_script_src_formats_known_module_url_with_hash() { - let src = tsjs_single_module_script_src("creative"); + let parts = compile_time_parts(&["creative"]); + let src = tsjs_single_module_script_src(&parts[0]); assert!( src.starts_with("/static/tsjs=tsjs-creative.min.js?v="), "should use per-module static bundle path" ); assert_sha256_hex_hash(hash_query_value(&src)); + assert_eq!( + src, + format!( + "/static/tsjs=tsjs-creative.min.js?v={}", + trusted_server_js::single_module_hash("creative") + .expect("should have compiled creative in") + ), + "should keep today's URL for a compile-time module" + ); } #[test] - fn tsjs_deferred_script_src_hashes_prebid_shim_and_empties_unknown_module() { - let prebid_src = tsjs_deferred_script_src("prebid"); + fn tsjs_deferred_script_src_hashes_prebid_shim() { + let parts = compile_time_parts(&["prebid"]); + let prebid_src = tsjs_deferred_script_src(&parts[0]); + assert!( prebid_src.starts_with("/static/tsjs=tsjs-prebid.min.js?v="), "prebid shim should be served from the deferred tsjs route" ); assert_sha256_hex_hash(hash_query_value(&prebid_src)); + } + + #[test] + fn tsjs_deferred_script_tag_carries_a_carried_parts_own_hash() { + let part = carried_part("probe", "(() => { window.probe = 1; })()"); + assert_eq!( - tsjs_deferred_script_src("unknown-module"), - "/static/tsjs=tsjs-unknown-module.min.js?v=", - "should document current unknown-module hash behavior" + tsjs_deferred_script_tag(&part), + format!( + "", + part.sha256 + ), + "should version a carried deferred module by its own content hash" ); } #[test] fn tsjs_deferred_script_tag_marks_script_defer() { - let src = tsjs_deferred_script_src("prebid"); + let parts = compile_time_parts(&["prebid"]); + let src = tsjs_deferred_script_src(&parts[0]); assert_eq!( - tsjs_deferred_script_tag("prebid"), + tsjs_deferred_script_tag(&parts[0]), format!(""), "should generate a deferred script tag" ); @@ -324,12 +408,14 @@ mod tests { #[test] fn tsjs_deferred_script_tags_preserves_input_order() { + let parts = compile_time_parts(&["prebid", "creative"]); + assert_eq!( - tsjs_deferred_script_tags(&["prebid", "creative"]), + tsjs_deferred_script_tags(&parts), format!( "{}{}", - tsjs_deferred_script_tag("prebid"), - tsjs_deferred_script_tag("creative") + tsjs_deferred_script_tag(&parts[0]), + tsjs_deferred_script_tag(&parts[1]) ), "should preserve caller-provided deferred module order" ); @@ -353,18 +439,9 @@ mod tests { #[test] fn tsjs_script_src_differs_for_different_module_sets() { assert_ne!( - tsjs_script_src(&["lockr"]), - tsjs_script_src(&["lockr", "permutive"]), + tsjs_script_src(&compile_time_parts(&["lockr"])), + tsjs_script_src(&compile_time_parts(&["lockr", "permutive"])), "should bust the cache when the module set content changes" ); } - - #[test] - fn tsjs_deferred_script_src_has_empty_hash_for_unknown_module() { - assert_eq!( - tsjs_deferred_script_src("does-not-exist"), - "/static/tsjs=tsjs-does-not-exist.min.js?v=", - "should fall back to an empty cache-busting hash for an unknown module" - ); - } } diff --git a/crates/trusted-server-core/src/tsjs_bundle.rs b/crates/trusted-server-core/src/tsjs_bundle.rs new file mode 100644 index 000000000..db66bff04 --- /dev/null +++ b/crates/trusted-server-core/src/tsjs_bundle.rs @@ -0,0 +1,413 @@ +//! Composition of the served tsjs script from module parts. +//! +//! `trusted-server-js` knows only the modules compiled into it. A module a +//! vendor crate carries on its registration is not in that map, so the +//! composition of the served script and its cache-busting hash live here, +//! keyed on content rather than on ids. The byte rule is unchanged from +//! `trusted_server_js::concatenate_modules`: core first, then each part in +//! order, joined by `;\n`, so every existing `?v=` hash is preserved. + +use std::collections::HashMap; +use std::sync::{Mutex, MutexGuard, OnceLock}; + +use sha2::{Digest as _, Sha256}; + +/// One module of the served script. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct JsModulePart { + /// Module id, for example `core` or `lockr`. + pub id: &'static str, + /// The built IIFE. + pub source: &'static str, + /// SHA-256 of `source`, hex encoded. Identifies the content in the memo. + /// + /// The memo in [`compose_hash`] trusts this value rather than hashing + /// `source` again, so a part that declares the wrong hash serves a stale + /// `?v=` under a valid-looking URL. Debug builds and tests check the + /// value against `source`; release builds do not. + pub sha256: &'static str, +} + +impl JsModulePart { + /// Looks up a compile-time module of `trusted-server-js` by id. + /// + /// Returns `None` when no module with that id was compiled in. + /// + /// # Examples + /// + /// ``` + /// use trusted_server_core::tsjs_bundle::JsModulePart; + /// + /// assert!(JsModulePart::compile_time("core").is_some()); + /// assert!(JsModulePart::compile_time("not-a-module").is_none()); + /// ``` + #[must_use] + pub fn compile_time(id: &'static str) -> Option { + let source = trusted_server_js::module_bundle(id)?; + let sha256 = trusted_server_js::single_module_hash(id)?; + Some(Self { id, source, sha256 }) + } +} + +/// Resolves compile-time parts for a list of ids, dropping unknown ids, as +/// `trusted_server_js` does today. +/// +/// # Examples +/// +/// ``` +/// use trusted_server_core::tsjs_bundle::compile_time_parts; +/// +/// let parts = compile_time_parts(&["core", "not-a-module"]); +/// +/// assert_eq!(parts.len(), 1); +/// assert_eq!(parts[0].id, "core"); +/// ``` +#[must_use] +pub fn compile_time_parts(ids: &[&'static str]) -> Vec { + ids.iter() + .filter_map(|id| JsModulePart::compile_time(id)) + .collect() +} + +/// Concatenates the parts into the served script. +/// +/// Core comes first (the given `core` part, or the compile-time core when +/// none is given), then the remaining parts in input order. Each id appears +/// once, keeping its first occurrence, and parts are joined by `;\n` so one +/// IIFE cannot run into the next. +/// +/// # Examples +/// +/// ``` +/// use sha2::{Digest as _, Sha256}; +/// use trusted_server_core::tsjs_bundle::{JsModulePart, compose}; +/// +/// fn part(id: &'static str, source: &'static str) -> JsModulePart { +/// let sha256 = Box::leak(hex::encode(Sha256::digest(source)).into_boxed_str()); +/// JsModulePart { id, source, sha256 } +/// } +/// +/// let parts = [ +/// part("example", "(() => { window.example = true; })()"), +/// part("core", "(() => { window.tsjs = {}; })()"), +/// ]; +/// +/// assert_eq!( +/// compose(&parts), +/// "(() => { window.tsjs = {}; })();\n(() => { window.example = true; })()" +/// ); +/// ``` +#[must_use] +pub fn compose(parts: &[JsModulePart]) -> String { + let ordered = ordered(parts); + // Every piece the visit yields has a known length before the walk, so + // reserve the exact byte count once rather than letting the pushes grow + // the buffer. A bundle of a dozen parts is hundreds of kilobytes, and + // growing to that size copies roughly twice the bundle on every request + // that serves it. + let mut size = 0; + visit_parts(&ordered, |part| size += part.len()); + let mut body = String::with_capacity(size); + visit_parts(&ordered, |part| body.push_str(part)); + body +} + +/// SHA-256 of [`compose`]'s output, hex encoded, without materializing it. +/// +/// The result is memoized per ordered set of `(id, sha256)` pairs. Because the +/// key carries each part's content hash, a carried module that keeps its id +/// but changes its source gets a new hash. The memo never evicts, so feed it +/// only sets derived from configuration, never sets derived from request +/// input. +/// +/// The memo can only hit where the process outlives the request, which of the +/// four adapters means the Axum dev server alone, because its `main` builds +/// the router once before serving. Fastly starts a fresh Wasm instance per +/// request, and `edgezero_adapter_cloudflare::run_app` and +/// `edgezero_adapter_spin::run_app` both call `build_app` inside the +/// per-request entry point, so on those three every call is a miss. A miss +/// costs a key vector, two mutex locks and a stored copy of the hash, all +/// beside a SHA-256 over the whole bundle, so the memo is close to free where +/// it cannot hit and removes the hash entirely where it can. +/// +/// # Panics +/// +/// In debug builds, panics when a part's `sha256` is not the SHA-256 of its +/// `source`. Release builds trust the declared hash. +/// +/// # Examples +/// +/// ``` +/// use sha2::{Digest as _, Sha256}; +/// use trusted_server_core::tsjs_bundle::{JsModulePart, compose_hash}; +/// +/// fn part(id: &'static str, source: &'static str) -> JsModulePart { +/// let sha256 = Box::leak(hex::encode(Sha256::digest(source)).into_boxed_str()); +/// JsModulePart { id, source, sha256 } +/// } +/// +/// let core = JsModulePart::compile_time("core").expect("should have compiled core in"); +/// let before = [core, part("example", "(() => { window.example = 1; })()")]; +/// let after = [core, part("example", "(() => { window.example = 2; })()")]; +/// +/// assert_eq!(compose_hash(&before).len(), 64); +/// assert_eq!(compose_hash(&before), compose_hash(&before)); +/// assert_ne!(compose_hash(&before), compose_hash(&after)); +/// ``` +#[must_use] +pub fn compose_hash(parts: &[JsModulePart]) -> String { + for part in parts { + debug_assert_eq!( + part.sha256, + hex::encode(Sha256::digest(part.source)), + "should declare the SHA-256 of its source for part `{}`", + part.id + ); + } + + let ordered = ordered(parts); + let key = ordered + .iter() + .map(|part| (part.id, part.sha256)) + .collect::>(); + if let Some(hash) = lock_cache().get(&key).cloned() { + return hash; + } + + let mut hasher = Sha256::new(); + visit_parts(&ordered, |part| hasher.update(part.as_bytes())); + let hash = hex::encode(hasher.finalize()); + lock_cache().insert(key, hash.clone()); + hash +} + +/// Orders the parts for the served script: core first, then every non-core +/// part in input order, keeping the first occurrence of each id. +fn ordered(parts: &[JsModulePart]) -> Vec { + let mut result = Vec::with_capacity(parts.len() + 1); + + let core = parts + .iter() + .find(|part| part.id == "core") + .copied() + .or_else(|| JsModulePart::compile_time("core")); + if let Some(core) = core { + result.push(core); + } + + for part in parts { + if part.id == "core" { + continue; + } + if result.iter().any(|taken| taken.id == part.id) { + continue; + } + result.push(*part); + } + + result +} + +/// Visits the byte pieces of the served script in order, with `;\n` between +/// consecutive parts. +fn visit_parts(parts: &[JsModulePart], mut visit: F) { + let mut first = true; + for part in parts { + if first { + first = false; + } else { + visit(";\n"); + } + visit(part.source); + } +} + +type HashCache = HashMap, String>; + +fn lock_cache() -> MutexGuard<'static, HashCache> { + static CACHE: OnceLock> = OnceLock::new(); + match CACHE.get_or_init(|| Mutex::new(HashMap::new())).lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) + } + + /// Builds a part whose `sha256` really is the hash of `source`, so no two + /// parts with different content ever share a memo key. + fn part(id: &'static str, source: &'static str) -> JsModulePart { + let sha256 = Box::leak(sha256_hex(source.as_bytes()).into_boxed_str()); + JsModulePart { id, source, sha256 } + } + + #[test] + fn compose_puts_core_first_and_joins_with_a_semicolon_and_newline() { + let parts = [ + part("lockr", "L"), + part("core", "C"), + part("permutive", "P"), + ]; + + assert_eq!( + compose(&parts), + "C;\nL;\nP", + "should order core first and join parts" + ); + } + + #[test] + fn compose_hash_matches_the_compile_time_hash_for_built_in_modules() { + let ids = ["lockr", "permutive"]; + let parts = compile_time_parts(&ids); + + assert_eq!( + compose_hash(&parts), + trusted_server_js::concatenated_hash(&ids), + "should reproduce today's hash for a built-in module set" + ); + assert_eq!( + compose(&parts), + trusted_server_js::concatenate_modules(&ids), + "should reproduce today's bytes for a built-in module set" + ); + } + + #[test] + fn compose_of_no_parts_is_the_compile_time_core_alone() { + assert_eq!( + compose(&[]), + trusted_server_js::concatenate_modules(&[]), + "should serve core alone when no parts are given" + ); + assert_eq!( + compose_hash(&[]), + trusted_server_js::concatenated_hash(&[]), + "should hash core alone when no parts are given" + ); + } + + #[test] + fn compose_matches_every_compile_time_module_set_the_old_api_produces() { + let all = trusted_server_js::all_module_ids(); + let non_core = all + .iter() + .copied() + .filter(|id| *id != "core") + .collect::>(); + let mut cases = vec![all.clone(), non_core.clone()]; + cases.push(non_core.iter().rev().copied().collect()); + cases.push(vec!["core"]); + + for ids in cases { + let parts = compile_time_parts(&ids); + assert_eq!( + compose(&parts), + trusted_server_js::concatenate_modules(&ids), + "should reproduce today's bytes for {ids:?}" + ); + assert_eq!( + compose_hash(&parts), + trusted_server_js::concatenated_hash(&ids), + "should reproduce today's hash for {ids:?}" + ); + } + } + + #[test] + fn compose_hash_changes_when_a_carried_module_changes() { + let before = [part("core", "C"), part("probe", "A")]; + let after = [part("core", "C"), part("probe", "B")]; + + assert_ne!( + compose_hash(&before), + compose_hash(&after), + "should hash carried content" + ); + } + + #[test] + fn compose_keeps_a_duplicate_id_once_using_its_first_occurrence() { + let parts = [ + part("core", "C"), + part("lockr", "L1"), + part("permutive", "P"), + part("lockr", "L2"), + part("core", "C2"), + ]; + + assert_eq!( + compose(&parts), + "C;\nL1;\nP", + "should keep the first occurrence of each id and drop later ones" + ); + } + + #[test] + fn compose_reserves_the_exact_bundle_size_before_writing_it() { + let parts = compile_time_parts(&trusted_server_js::all_module_ids()); + let body = compose(&parts); + + assert_eq!( + body.capacity(), + body.len(), + "should allocate the bundle once at its exact size" + ); + } + + #[test] + fn compose_hash_is_the_hex_sha256_of_compose() { + let parts = [ + part("core", "(() => {})()"), + part("carried", "(() => { window.carried = true; })()"), + part("lockr", "L"), + ]; + + assert_eq!( + compose_hash(&parts), + sha256_hex(compose(&parts).as_bytes()), + "should hash the exact bytes compose produces" + ); + } + + #[test] + #[should_panic(expected = "should declare the SHA-256 of its source for part `lying`")] + fn compose_hash_rejects_a_part_whose_declared_hash_is_wrong() { + let parts = [ + part("core", "C"), + JsModulePart { + id: "lying", + source: "A", + sha256: "not-the-hash-of-a", + }, + ]; + + let _ = compose_hash(&parts); + } + + #[test] + fn compile_time_parts_drops_unknown_ids() { + let parts = compile_time_parts(&["lockr", "not-a-module", "permutive"]); + let ids = parts.iter().map(|part| part.id).collect::>(); + + assert_eq!( + ids, + ["lockr", "permutive"], + "should keep known ids in order and drop unknown ids" + ); + for part in parts { + assert_eq!( + part.sha256, + sha256_hex(part.source.as_bytes()), + "should carry the compile-time hash of {}", + part.id + ); + } + } +} diff --git a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml index d8e35d179..9f5ee36f4 100644 --- a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml +++ b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml @@ -9,11 +9,24 @@ cookie_domain = "localhost" origin_url = "http://127.0.0.1:8888" proxy_secret = "integration-test-proxy-secret" +# Viceroy does not resolve geolocation for the loopback test client, so the +# request carries no country and the top of the permissions.yaml rules tree +# supplies the baseline the permission model uses. +[geo] +# Viceroy maps the loopback client to US/CA (see viceroy-template.toml), so +# the platform geo provider resolves a real place and the EC lifecycle +# scenarios exercise the US state opt-out machinery, the same posture the +# retired default_country setting pointed at, now through the real lookup. +provider = "platform" + [ec] -passphrase = "integration-test-ec-secret-padded-32" +provider = "hmac" ec_store = "ec_identity_store" pull_sync_concurrency = 3 +[ec.providers.hmac] +passphrase = "integration-test-ec-secret-padded-32" + [[ec.partners]] name = "Integration Test Partner" source_domain = "inttest.example.com" diff --git a/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml b/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml index 9f1443d20..ec08a3fc8 100644 --- a/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml +++ b/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml @@ -4,6 +4,53 @@ [local_server] + # Maps the loopback client to California, United States, so the platform + # geo provider resolves a real place and the permission model applies the + # US/CA rules from the permissions.yaml tree. This replaces the retired + # [geo] default_country lever with the genuine lookup path. + [local_server.geolocation] + format = "inline-toml" + + [local_server.geolocation.addresses."127.0.0.1"] + as_name = "Integration Test" + as_number = 64496 + area_code = 0 + city = "Test City" + conn_speed = "broadband" + conn_type = "wired" + continent = "NA" + country_code = "US" + country_code3 = "USA" + country_name = "United States" + latitude = 0.0 + longitude = 0.0 + metro_code = 0 + postal_code = "00000" + proxy_description = "?" + proxy_type = "?" + region = "CA" + utc_offset = -800 + + [local_server.geolocation.addresses."::1"] + as_name = "Integration Test" + as_number = 64496 + area_code = 0 + city = "Test City" + conn_speed = "broadband" + conn_type = "wired" + continent = "NA" + country_code = "US" + country_code3 = "USA" + country_name = "United States" + latitude = 0.0 + longitude = 0.0 + metro_code = 0 + postal_code = "00000" + proxy_description = "?" + proxy_type = "?" + region = "CA" + utc_offset = -800 + [local_server.backends] [local_server.kv_stores] diff --git a/crates/trusted-server-integration-tests/tests/frameworks/scenarios.rs b/crates/trusted-server-integration-tests/tests/frameworks/scenarios.rs index c1bbafe8d..a7a45f2f6 100644 --- a/crates/trusted-server-integration-tests/tests/frameworks/scenarios.rs +++ b/crates/trusted-server-integration-tests/tests/frameworks/scenarios.rs @@ -445,9 +445,10 @@ pub enum EcScenario { /// returns the scoped UID. FullLifecycle, - /// Consent withdrawal: GPC header triggers EC cookie deletion for a - /// seeded EC in the default US-state test geo. - ConsentWithdrawal, + /// Opt-out suppression: a GPC header suppresses use of a seeded EC + /// (identify answers 403) without expiring the cookie, in the default + /// US-state test geo. Opt-outs are never destructive. + OptOutSuppression, /// Identify without EC cookie returns 204. IdentifyWithoutEc, @@ -470,7 +471,7 @@ impl EcScenario { pub fn all() -> Vec { vec![ Self::FullLifecycle, - Self::ConsentWithdrawal, + Self::OptOutSuppression, Self::IdentifyWithoutEc, Self::IdentifyConsentDenied, Self::ConcurrentPartnerSyncs, @@ -489,7 +490,7 @@ impl EcScenario { pub fn run(&self, base_url: &str) -> TestResult<()> { match self { Self::FullLifecycle => ec_full_lifecycle(base_url), - Self::ConsentWithdrawal => ec_consent_withdrawal(base_url), + Self::OptOutSuppression => ec_opt_out_suppression(base_url), Self::IdentifyWithoutEc => ec_identify_without_ec(base_url), Self::IdentifyConsentDenied => ec_identify_consent_denied(base_url), Self::ConcurrentPartnerSyncs => ec_concurrent_partner_syncs(base_url), @@ -577,37 +578,48 @@ fn ec_full_lifecycle(base_url: &str) -> TestResult<()> { } /// Consent withdrawal: GPC header clears EC cookie. -fn ec_consent_withdrawal(base_url: &str) -> TestResult<()> { +fn ec_opt_out_suppression(base_url: &str) -> TestResult<()> { let client = EcTestClient::new(base_url); allow_ec_generation(&client); let seeded_ec_id = seeded_ec_id('b', "test02"); let ec_id = use_seeded_ec(&client, &seeded_ec_id); - log::info!("EC consent withdrawal: using seeded EC = {ec_id}"); + log::info!("EC opt-out suppression: using seeded EC = {ec_id}"); - // GPC overrides the allow cookie in US-CA, so this is an explicit - // withdrawal and must expire the EC cookie. + // GPC is a US-style opt-out. It suppresses use of the identifier for the + // request but is never destructive: the cookie is not expired and no + // tombstone is written, so lifting the opt-out restores the identity. let resp = client.get_with_headers("/", &[("sec-gpc", "1")])?; - - if !is_ec_cookie_expired(&resp) { + if is_ec_cookie_expired(&resp) { return Err(Report::new(TestError::UnexpectedContent) - .attach("consent withdrawal should expire ts-ec cookie (expected Max-Age=0)")); + .attach("an opt-out must suppress without expiring the ts-ec cookie")); } - if client.ec_cookie_value().is_some() { + if client.ec_cookie_value().is_none() { return Err(Report::new(TestError::UnexpectedContent) - .attach("client should stop tracking ts-ec after explicit withdrawal")); + .attach("the client should keep tracking ts-ec through an opt-out")); } - // 3. With consent still granted and the EC cookie revoked, identify should - // now report no EC present. - let resp = identify(&client, INTTEST_API_TOKEN)?; - assert_status(&resp, 204).attach("identify should return 204 after cookie revocation")?; - - // 4. With GPC still asserted, identify should reflect consent denial. + // With GPC asserted, identify reflects the suppressed permissions. let resp = identify_with_headers(&client, INTTEST_API_TOKEN, &[("sec-gpc", "1")])?; assert_status(&resp, 403) - .attach("identify with GPC should return 403 after consent withdrawal")?; + .attach("identify with GPC should return 403 while the opt-out is asserted")?; + + // Without GPC the identity is usable again: the cookie still carries the + // identifier, so identify answers 200 with consent ok. No row was seeded + // for this identifier, so there is no enrichment, and the absence of a + // 403 proves the opt-out destroyed nothing. + let resp = identify(&client, INTTEST_API_TOKEN)?; + let body = assert_json_response(resp, 200)?; + if body.get("consent").and_then(|v| v.as_str()) != Some("ok") { + return Err(Report::new(TestError::UnexpectedContent).attach(format!( + "identify without GPC should report consent ok, got {body}" + ))); + } + if body.get("uid").is_some() { + return Err(Report::new(TestError::UnexpectedContent) + .attach("no partner UID was seeded, so identify should carry no enrichment")); + } - log::info!("EC consent withdrawal: PASSED"); + log::info!("EC opt-out suppression: PASSED"); Ok(()) } diff --git a/crates/trusted-server-integration-tests/tests/parity.rs b/crates/trusted-server-integration-tests/tests/parity.rs index acf7f5f4b..eeeee9d5d 100644 --- a/crates/trusted-server-integration-tests/tests/parity.rs +++ b/crates/trusted-server-integration-tests/tests/parity.rs @@ -43,7 +43,13 @@ fn test_settings() -> Settings { proxy_secret = "parity-test-proxy-secret" [ec] + provider = "hmac" + + [ec.providers.hmac] passphrase = "test-secret-key-32-bytes-minimum" + + [geo] + assume_single_jurisdiction = true "#, ) .expect("should parse parity test settings") diff --git a/crates/trusted-server-js/lib/src/core/index.ts b/crates/trusted-server-js/lib/src/core/index.ts index b2c4e41e1..2678a879f 100644 --- a/crates/trusted-server-js/lib/src/core/index.ts +++ b/crates/trusted-server-js/lib/src/core/index.ts @@ -13,6 +13,7 @@ import { log } from './log'; import { setConfig, getConfig } from './config'; import { requestAds } from './request'; import { installQueue } from './queue'; +import { installPermissions } from './permissions'; const VERSION = '0.1.0'; @@ -42,6 +43,10 @@ api.requestAds = requestAds; // instead of throwing. Injected scripts overwrite these wholesale. api.adSlots ??= []; api.bids ??= {}; +// The edge also injects the request's resolved permission state, either at head +// open (inline mode) or at the seam (shared-template mode). An accessor +// observes the seam's plain assignment so page code can await it either way. +installPermissions(api); // Point global tsjs w.tsjs = api; @@ -68,5 +73,6 @@ log.info('tsjs initialized', { 'addAdUnits', 'renderAdUnit', 'renderAllAdUnits', + 'whenPermissions', ], }); diff --git a/crates/trusted-server-js/lib/src/core/permissions.ts b/crates/trusted-server-js/lib/src/core/permissions.ts new file mode 100644 index 000000000..636e61af6 --- /dev/null +++ b/crates/trusted-server-js/lib/src/core/permissions.ts @@ -0,0 +1,72 @@ +// Permission state the edge injects into the page, and the promise page code +// awaits so it can read that state whichever order the injection arrives in. +import { log } from './log'; +import type { PermissionsSnapshot, TsjsApi } from './types'; + +function isSnapshot(value: unknown): value is PermissionsSnapshot { + return typeof value === 'object' && value !== null; +} + +/** + * Install the `permissions` accessor and `whenPermissions()` on the API object. + * + * The edge injects `window.tsjs.permissions` either before this bundle runs + * (inline mode, at head open) or after it has initialized (shared-template + * mode, as a plain assignment at the `` seam), and on a page with no + * seam it never arrives at all. An accessor observes the plain assignment, so + * the promise resolves in every one of those orders. + */ +export function installPermissions(api: TsjsApi): void { + // A value already on the API object came from the head-open injection, so it + // is the current value; otherwise page code must still read a defined value. + const injected = api.permissions; + let current: PermissionsSnapshot = isSnapshot(injected) ? injected : { set: [] }; + let settled = false; + let resolvePending: (snapshot: PermissionsSnapshot) => void = () => {}; + const pending = new Promise((resolve) => { + resolvePending = resolve; + }); + + function settle(snapshot: PermissionsSnapshot): void { + if (settled) return; + settled = true; + resolvePending(snapshot); + } + + Object.defineProperty(api, 'permissions', { + get(): PermissionsSnapshot { + return current; + }, + set(value: PermissionsSnapshot) { + current = value; + log.debug('permissions: received', value); + settle(value); + }, + enumerable: true, + configurable: true, + }); + + if (isSnapshot(injected)) { + log.debug('permissions: present at initialization', injected); + settle(current); + } else if (typeof document !== 'undefined') { + // The seam sits at ``, so anything it was going to assign has run by + // the time the document is parsed. Resolve with whatever the current value + // is rather than leaving page code waiting on a page that has no seam. A + // bundle that initializes after parsing has already missed any seam, so it + // resolves at once instead of waiting for an event that will never fire. + if (document.readyState === 'loading') { + document.addEventListener( + 'DOMContentLoaded', + () => { + settle(current); + }, + { once: true } + ); + } else { + settle(current); + } + } + + api.whenPermissions = () => pending; +} diff --git a/crates/trusted-server-js/lib/src/core/types.ts b/crates/trusted-server-js/lib/src/core/types.ts index 03ff0aca2..436f21617 100644 --- a/crates/trusted-server-js/lib/src/core/types.ts +++ b/crates/trusted-server-js/lib/src/core/types.ts @@ -365,6 +365,16 @@ export interface GptSlotHandoff { suppressPublisherRefresh: boolean; } +/** + * Permission state the server resolved for this request. + * + * The names in `set` are IAB Privacy Taxonomy Data Use keys, as resolved by the + * server for this request. + */ +export interface PermissionsSnapshot { + set: string[]; +} + export interface TsjsApi { version: string; que: Array<() => void>; @@ -392,6 +402,20 @@ export interface TsjsApi { adSlots?: AuctionSlot[]; /** Winning bid targeting data injected before . */ bids?: Record; + /** + * Permission state resolved by the server for this request, injected at head + * open in inline mode or at the body seam in shared-template mode. + */ + permissions?: PermissionsSnapshot; + /** + * Resolves with the permission state once it arrives. + * + * It resolves straight away when the state was already present at + * initialization, on the first assignment when the body seam makes one, and + * on `DOMContentLoaded` with whatever the current value is when no assignment + * arrives at all. Every later call returns the same resolved promise. + */ + whenPermissions?(): Promise; /** * Bounded client-side Prebid APS renderer capabilities keyed by Prebid's generated * `hb_adid`. The Universal Creative bridge consumes each entry at most once. diff --git a/crates/trusted-server-js/lib/src/integrations/ec_client_fixed/index.ts b/crates/trusted-server-js/lib/src/integrations/ec_client_fixed/index.ts new file mode 100644 index 000000000..2dabafb52 --- /dev/null +++ b/crates/trusted-server-js/lib/src/integrations/ec_client_fixed/index.ts @@ -0,0 +1,95 @@ +// Demonstration client for the client-cycle Edge Cookie provider (client-fixed). +// +// Client and server share one fixed, known word. When the resolved marker is +// absent, this posts that word to the resolve endpoint. With the `client-fixed` +// provider selected, the server verifies the word and, on a match, persists the +// identity-graph row and sets the coded form of the word (cfix~an-ec) as an +// HttpOnly Edge Cookie on the +// response, together with a non-HttpOnly marker cookie. The Edge Cookie itself +// is HttpOnly, so this script can never see it; the marker is what tells it a +// resolve already succeeded, so it does not post again on every page view. +// +// The value is verifiable precisely because it is a known constant, which is the +// point of the demo. It is useless in production, because a fixed value is not an +// identity and every client posts the same word. For demonstration and testing +// only. A real client-cycle provider posts and verifies a real payload (for +// example an OWID signature) instead of a shared constant. +import { log } from '../../core/log'; + +const RESOLVE_ENDPOINT = '/_ts/api/v1/ec/resolve'; + +// The non-HttpOnly companion the server sets alongside the Edge Cookie. Must +// match COOKIE_TS_EC_RESOLVED in crates/trusted-server-core/src/constants.rs; +// a Rust test asserts the two stay in sync. +const MARKER_COOKIE_NAME = 'ts-ecr'; + +// The fixed, known word shared with the server. Must match EXPECTED_VALUE in +// crates/trusted-server-core/src/ec/provider.rs; a Rust test asserts the two +// stay in sync. +const FIXED_WORD = 'an-ec'; + +// The permission this provider requires, the same declaration the server-side +// provider makes in `required_permissions` (crates/trusted-server-core/src/ec/ +// provider.rs). A page module is treated like any other provider: it declares +// what it requires and checks that against the resolved state the server +// hands the page before it does anything. The server enforces the same gate on +// the resolve endpoint, so this check is the page's half of one decision, not +// a substitute for the server's. A Rust test asserts the two stay in sync. +const REQUIRED_PERMISSION = 'necessary.operations.storage'; + +// Waits for the resolved permission state the edge injects into the page +// (`window.tsjs.permissions`, via `tsjs.whenPermissions()`) and returns +// whether the permission this module requires is set. With no permission state +// on the page there is nothing to check against, so the answer is no. +export async function requiredPermissionIsSet(): Promise { + const whenPermissions = window.tsjs?.whenPermissions; + if (typeof whenPermissions !== 'function') { + log.warn('ec client-fixed: no permission state on the page, not posting'); + return false; + } + const snapshot = await whenPermissions(); + return Array.isArray(snapshot?.set) && snapshot.set.includes(REQUIRED_PERMISSION); +} + +// Returns true when the resolved marker is present in `cookieString`. The Edge +// Cookie itself is HttpOnly and never appears in `document.cookie`, so the +// marker is the only signal the page has. +export function hasResolvedMarker(cookieString: string): boolean { + return cookieString.split(';').some((part) => part.trim().startsWith(`${MARKER_COOKIE_NAME}=`)); +} + +// Posts the fixed known word to the resolve endpoint when no resolved marker is +// present and the required permission is set. Returns the word posted, or null +// when nothing was sent or the post failed (a resolve already succeeded, the +// required permission is not set, the environment lacks `document`/`fetch`, or +// the request threw). +export async function resolveEdgeCookie(): Promise { + if (typeof document === 'undefined' || typeof fetch !== 'function') { + return null; + } + if (hasResolvedMarker(document.cookie)) { + return null; + } + if (!(await requiredPermissionIsSet())) { + log.info('ec client-fixed: required permission not set, not posting'); + return null; + } + + try { + await fetch(RESOLVE_ENDPOINT, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'text/plain' }, + body: FIXED_WORD, + }); + log.info('ec client-fixed: posted the known word to the resolve endpoint'); + return FIXED_WORD; + } catch (err) { + log.warn('ec client-fixed: resolve request failed', err); + return null; + } +} + +if (typeof window !== 'undefined') { + void resolveEdgeCookie(); +} diff --git a/crates/trusted-server-js/lib/test/core/permissions.test.ts b/crates/trusted-server-js/lib/test/core/permissions.test.ts new file mode 100644 index 000000000..9a12b340a --- /dev/null +++ b/crates/trusted-server-js/lib/test/core/permissions.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import type { PermissionsSnapshot, TsjsApi } from '../../src/core/types'; + +describe('core/permissions', () => { + // The bundle is injected at head open, so the document is still parsing + // when core initializes. Tests set the state explicitly because the + // fallback path depends on it. + function setReadyState(state: DocumentReadyState): void { + Object.defineProperty(document, 'readyState', { + value: state, + configurable: true, + }); + } + + beforeEach(async () => { + await vi.resetModules(); + document.body.innerHTML = ''; + delete window.tsjs; + setReadyState('loading'); + }); + + it('keeps permissions injected before the bundle loads and resolves with them', async () => { + const injected: PermissionsSnapshot = { set: ['necessary.operations'] }; + window.tsjs = { permissions: injected } as TsjsApi; + + await import('../../src/core/index'); + const api = window.tsjs as TsjsApi; + + expect(api.permissions).toEqual({ set: ['necessary.operations'] }); + await expect(api.whenPermissions!()).resolves.toEqual({ set: ['necessary.operations'] }); + }); + + it('resolves on the body seam assignment and reads the value back', async () => { + await import('../../src/core/index'); + const api = window.tsjs as TsjsApi; + + const settled = api.whenPermissions!(); + api.permissions = { set: ['marketing.advertising.serving'] }; + + await expect(settled).resolves.toEqual({ set: ['marketing.advertising.serving'] }); + expect(api.permissions).toEqual({ set: ['marketing.advertising.serving'] }); + }); + + it('falls back to the empty default when no assignment arrives before DOMContentLoaded', async () => { + await import('../../src/core/index'); + const api = window.tsjs as TsjsApi; + + const settled = api.whenPermissions!(); + document.dispatchEvent(new Event('DOMContentLoaded')); + + await expect(settled).resolves.toEqual({ set: [] }); + }); + + it('resolves at once when the document has already been parsed', async () => { + // A bundle initializing after parsing has missed any seam, so waiting for + // DOMContentLoaded would wait forever. + setReadyState('interactive'); + await import('../../src/core/index'); + const api = window.tsjs as TsjsApi; + + await expect(api.whenPermissions!()).resolves.toEqual({ set: [] }); + }); + + it('returns the same resolved value from every later call', async () => { + await import('../../src/core/index'); + const api = window.tsjs as TsjsApi; + + api.permissions = { set: ['analytics.reporting'] }; + const first = await api.whenPermissions!(); + const second = await api.whenPermissions!(); + + expect(second).toBe(first); + expect(second).toEqual({ set: ['analytics.reporting'] }); + }); +}); diff --git a/crates/trusted-server-js/lib/test/integrations/ec_client_fixed/index.test.ts b/crates/trusted-server-js/lib/test/integrations/ec_client_fixed/index.test.ts new file mode 100644 index 000000000..97f59bebf --- /dev/null +++ b/crates/trusted-server-js/lib/test/integrations/ec_client_fixed/index.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const ORIGINAL_FETCH = global.fetch; + +async function importModule() { + vi.resetModules(); + return import('../../../src/integrations/ec_client_fixed/index'); +} + +function clearResolvedMarker() { + document.cookie = 'ts-ecr=; expires=Thu, 01 Jan 1970 00:00:00 GMT'; +} + +describe('ec_client_fixed', () => { + // The page state the edge injects, as core exposes it. The default grants + // the permission this module requires so the existing posting tests hold. + function setPageState(set: string[] | undefined): void { + if (set === undefined) { + delete window.tsjs; + return; + } + window.tsjs = { + whenPermissions: () => Promise.resolve({ set }), + } as unknown as NonNullable; + } + + beforeEach(() => { + clearResolvedMarker(); + setPageState(['necessary.operations.storage']); + global.fetch = vi.fn().mockResolvedValue({ ok: true }); + }); + + afterEach(() => { + global.fetch = ORIGINAL_FETCH; + clearResolvedMarker(); + delete window.tsjs; + vi.resetModules(); + }); + + it('does not post when the required permission is not set', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + global.fetch = fetchMock as unknown as typeof fetch; + setPageState(['advertising_marketing.first_party.contextual']); + const { resolveEdgeCookie, requiredPermissionIsSet } = await importModule(); + fetchMock.mockClear(); + + await expect(requiredPermissionIsSet()).resolves.toBe(false); + await expect(resolveEdgeCookie()).resolves.toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('does not post when the page carries no permission state at all', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + global.fetch = fetchMock as unknown as typeof fetch; + setPageState(undefined); + const { resolveEdgeCookie } = await importModule(); + fetchMock.mockClear(); + + await expect(resolveEdgeCookie()).resolves.toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('waits for permission state that arrives after the module runs', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + global.fetch = fetchMock as unknown as typeof fetch; + let resolveState: (snapshot: { set: string[] }) => void = () => {}; + window.tsjs = { + whenPermissions: () => + new Promise<{ set: string[] }>((resolve) => { + resolveState = resolve; + }), + } as unknown as NonNullable; + const { resolveEdgeCookie } = await importModule(); + fetchMock.mockClear(); + + const pending = resolveEdgeCookie(); + expect(fetchMock).not.toHaveBeenCalled(); + resolveState({ set: ['necessary.operations.storage'] }); + + await expect(pending).resolves.toBe('an-ec'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('detects the resolved-marker cookie presence', async () => { + const { hasResolvedMarker } = await importModule(); + expect(hasResolvedMarker('a=1; ts-ecr=1; b=2')).toBe(true); + expect(hasResolvedMarker('first-party=1; b=2')).toBe(false); + // The Edge Cookie itself is HttpOnly and never visible here, so its name + // must not satisfy the marker check. + expect(hasResolvedMarker('ts-ec=abc')).toBe(false); + expect(hasResolvedMarker('')).toBe(false); + }); + + it('posts the fixed known word to the resolve endpoint when no marker is present', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + global.fetch = fetchMock as unknown as typeof fetch; + const { resolveEdgeCookie } = await importModule(); + // Ignore the import-time auto-run; assert on an explicit call. + fetchMock.mockClear(); + + const value = await resolveEdgeCookie(); + + expect(value).toBe('an-ec'); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + '/_ts/api/v1/ec/resolve', + expect.objectContaining({ method: 'POST', body: 'an-ec' }) + ); + }); + + it('does not post when the resolved marker is already present', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + global.fetch = fetchMock as unknown as typeof fetch; + document.cookie = 'ts-ecr=1'; + const { resolveEdgeCookie } = await importModule(); + fetchMock.mockClear(); + + const value = await resolveEdgeCookie(); + + expect(value).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 498e2e37a..99c60cdba 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -79,6 +79,7 @@ export default withMermaid( items: [ { text: 'Edge Cookies', link: '/guide/edge-cookies' }, { text: 'EC Setup Guide', link: '/guide/ec-setup-guide' }, + { text: 'Permission Model', link: '/guide/permission-model' }, { text: 'GDPR Compliance', link: '/guide/gdpr-compliance' }, { text: 'Ad Serving', link: '/guide/ad-serving' }, { diff --git a/docs/guide/api-reference.md b/docs/guide/api-reference.md index 184915f0a..63114a397 100644 --- a/docs/guide/api-reference.md +++ b/docs/guide/api-reference.md @@ -188,6 +188,18 @@ Server-to-server batch sync endpoint for writing EC ID to partner UID mappings. --- +### POST /\_ts/api/v1/ec/resolve + +Resolve endpoint for client-side Edge Cookie providers. The page posts a value that the provider verifies and creates the Edge Cookie value. Used only when a client-side provider is selected (for example the `client-fixed` demo). Server-side providers such as HMAC do not use it. + +**Auth:** None, but the request must carry an `Origin` on the publisher's own domain (a foreign or missing `Origin` answers `403`). This is a first-party POST from the page. The provider is responsible for verifying the posted value before trusting it. + +**Request Body:** the provider's value, opaque to the core. For the `client-fixed` demo this is the fixed known word sent as `text/plain`. + +**Behavior:** gated by the [permission model](/guide/permission-model) exactly like organic generation. On success the identifier is written to the identity graph first, then the EC cookie is set on this response (`HttpOnly`, `Secure`, `SameSite=Lax`) together with the `ts-ecr` marker cookie the page script can read, and the status is `200`. When the gate is closed, no client-side provider is configured, no identity graph is available, or the provider produces no identifier, the response is `204` with no cookie. Rejections: `403` for a missing or foreign `Origin`, `415` for a content type other than `text/plain` or `application/json`, `413` for an oversized body, `400` when the created identifier is outside the identifier bounds, `409` when the request already carries a different identity, and `503` when the identity-graph write fails. Every response the handler builds carries `Cache-Control: no-store`. + +--- + ### POST /third-party/ad Client-side auction endpoint for TSJS library. @@ -598,7 +610,7 @@ The examples below use fictional IDs and values only. ### GET /\_ts/admin/ec/`{id}` -Reads an EC identity-graph record for troubleshooting. The explicit route accepts an EC ID in `{64 lowercase hex}.{6 alphanumeric}` format. The bare route uses the request's `ts-ec` cookie. +Reads an EC identity-graph record for troubleshooting. The explicit route accepts an EC ID in `{64 lowercase hex}.{6 alphanumeric}` format, with or without the `hmac~` provider-code prefix a created identifier carries. The bare route uses the request's `ts-ec` cookie. This lookup is implemented only by the Fastly adapter because the identity graph is stored in Fastly KV. Other adapters return `501 Not Implemented`. diff --git a/docs/guide/auction-orchestration.md b/docs/guide/auction-orchestration.md index 26a0d5e3b..ccef14569 100644 --- a/docs/guide/auction-orchestration.md +++ b/docs/guide/auction-orchestration.md @@ -215,20 +215,24 @@ The orchestrator is composed of several modules: ### Provider Auto-Discovery -Providers register themselves at startup via builder functions. The `build_orchestrator()` function in `auction/mod.rs` iterates all registered builders, passes the application settings, and each builder returns zero or more providers depending on whether its config section is present and enabled: +Providers register themselves at startup through `AuctionProviderBuilder`, which names the provider, names the crate it came from, and points at a build function and a validate function. The `build_orchestrator()` function in `auction/mod.rs` walks the built-in builders, passes the application settings to each, and each build function returns zero or more providers depending on whether its config section is present and enabled. ```rust -// Each integration registers its own builder -fn provider_builders() -> &'static [ProviderBuilder] { - &[ +// The built-in auction providers, in registration order. +const BUILT_IN_PROVIDER_BUILDERS: &[AuctionProviderBuilder] = &[ + AuctionProviderBuilder::new( + "prebid", + CORE_SOURCE, prebid::register_auction_provider, - aps::register_providers, - adserver_mock::register_providers, - ] -} + prebid::validate, + ), + // aps and adserver_mock follow in the same shape. +]; ``` -This means you only need to add a config section to `trusted-server.toml` for a provider to be automatically discovered and registered. +This means you only need to add a config section to `trusted-server.toml` for a built-in provider to be discovered and registered. + +A provider can also come from a crate outside core. `build_orchestrator_with_providers(settings, extra)` takes the built-in builders followed by the ones an adapter supplies, and two builders claiming one provider name are refused with a message naming both crates. See [Modules That Live Outside Core](./integration-guide.md#modules-that-live-outside-core). ## Auction Strategies diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index a2b90b240..e3457986f 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -24,6 +24,9 @@ origin_url = "https://origin.publisher.com" proxy_secret = "your-secure-secret-here" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "replace-with-32-plus-byte-random-secret" ``` @@ -37,7 +40,8 @@ read by the deployed application at request time. # Format: TRUSTED_SERVER__SECTION__FIELD export TRUSTED_SERVER__PUBLISHER__DOMAIN=publisher.com export TRUSTED_SERVER__PUBLISHER__ORIGIN_URL=https://origin.publisher.com -export TRUSTED_SERVER__EC__PASSPHRASE=replace-with-32-plus-byte-random-secret +export TRUSTED_SERVER__EC__PROVIDER=hmac +export TRUSTED_SERVER__EC__PROVIDERS__HMAC__PASSPHRASE=replace-with-32-plus-byte-random-secret ts config validate ts config push --adapter fastly @@ -61,6 +65,7 @@ fail and the service will return its startup-error response. | File | Purpose | | --------------------- | ------------------------------- | | `trusted-server.toml` | Main application configuration | +| `permissions.yaml` | Country/region permission rules | | `fastly.toml` | Fastly Compute service settings | | `.env.dev` | Local development overrides | @@ -71,7 +76,10 @@ fail and the service will return its startup-error response. | `[publisher]` | Domain, origin, proxy settings | | `[trusted_client_ip]` | Authenticated client-IP forwarding | | `[ec]` | Edge Cookie (EC) ID generation | +| `[geo]` | Which module resolves location, if any | | `[tester_cookie]` | Optional tester-cookie endpoint | +| `[device]` | Device classification provider selection | +| `[geo]` | Geolocation provider selection | | `[proxy]` | Proxy SSRF allowlist and asset routes | | `[cache]` | Static/rehosted asset cache policy rules | | `[image_optimizer]` | Reusable Image Optimizer profile sets | @@ -89,6 +97,9 @@ origin_url = "https://origin.publisher.com" proxy_secret = "change-me-to-secure-value" [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "replace-with-32-plus-byte-random-secret" [request_signing] @@ -502,14 +513,24 @@ Settings for Edge Cookie identifier generation. The `ec_store` KV store is the o ### `[ec]` -| Field | Type | Required | Description | -| ------------------------- | -------------- | -------- | ----------------------------------------------------------------------- | -| `passphrase` | String | Yes | Publisher passphrase used as HMAC key | -| `ec_store` | String or null | No | Fastly KV store name for EC identity graph and withdrawal state | -| `pull_sync_concurrency` | Integer | No | Maximum concurrent pull-sync requests per organic response | -| `cluster_trust_threshold` | Integer | No | Cluster size threshold for identity trust decisions | -| `cluster_recheck_secs` | Integer | No | Legacy compatibility setting; cluster rechecks no longer use timestamps | -| `partners` | Array | No | Static partner registry entries | +| Field | Type | Required | Description | +| ------------------------- | -------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `provider` | String or null | No | Key of the active Edge Cookie provider: `"hmac"` (built-in), `"host-signals"` (opt-in), or `"none"` (explicitly stateless). Omit to run statelessly with no Edge Cookie. The `"client-fixed"` demo needs the `client-fixed-demo` build feature | +| `ec_store` | String or null | No | Fastly KV store name for EC identity graph and withdrawal state | +| `pull_sync_concurrency` | Integer | No | Maximum concurrent pull-sync requests per organic response | +| `cluster_trust_threshold` | Integer | No | Cluster size threshold for identity trust decisions | +| `cluster_recheck_secs` | Integer | No | Legacy compatibility setting; cluster rechecks no longer use timestamps | +| `partners` | Array | No | Static partner registry entries | + +The selected `provider` must have a matching `[ec.providers.]` block. Selecting a provider with no configured block, or an unknown key, fails at startup. + +### `[ec.providers.hmac]` + +The built-in HMAC-over-client-IP provider, keyed `hmac`. + +| Field | Type | Required | Description | +| ------------ | ------ | --------------------------- | ----------------------------------------- | +| `passphrase` | String | Yes when `hmac` is selected | Publisher passphrase used as the HMAC key | ::: tip Partner keying `source_domain` is the canonical partner key. It matches incoming OpenRTB EID `source` values and is also used as the EC KV `ids` map key. @@ -519,9 +540,12 @@ Settings for Edge Cookie identifier generation. The `ec_store` KV store is the o ```toml [ec] -passphrase = "replace-with-32-plus-byte-random-secret" +provider = "hmac" ec_store = "ec_identity_store" +[ec.providers.hmac] +passphrase = "replace-with-32-plus-byte-random-secret" + [[ec.partners]] name = "Mocktioneer SSP" source_domain = "mocktioneer.example" @@ -532,19 +556,28 @@ bidstream_enabled = true **Environment Override**: ```bash -TRUSTED_SERVER__EC__PASSPHRASE=your-secret +TRUSTED_SERVER__EC__PROVIDER=hmac +TRUSTED_SERVER__EC__PROVIDERS__HMAC__PASSPHRASE=your-secret TRUSTED_SERVER__EC__EC_STORE=ec_identity_store ``` +These `TRUSTED_SERVER__` overrides apply where deployment tooling merges environment values into the published configuration (for example test harnesses building an app-config blob). The running server reads its settings from the platform config store, so provider selection changes take effect when a new configuration is pushed, not per request. + ### Field Details -#### `passphrase` +#### `provider` + +**Purpose**: Names the active Edge Cookie provider by its key. Omit to run statelessly with no Edge Cookie. + +**Validation**: Application startup fails if the selected key has no matching `[ec.providers.]` block, or is unknown. -**Purpose**: Publisher passphrase used as HMAC key for EC ID generation. +#### `providers.hmac.passphrase` + +**Purpose**: Publisher passphrase used as HMAC key for EC ID generation, read when `provider = "hmac"`. **Security**: -- Must be non-empty +- At least 32 characters - Rotate periodically for security - Store securely (environment variable recommended) @@ -558,6 +591,127 @@ openssl rand -hex 32 **Validation**: Application startup fails if: - Empty string +- Shorter than 32 characters + +## Device Configuration + +Selects how a request is classified into the coarse device signals the Edge Cookie bot gate uses, mirroring the Edge Cookie provider selection. These signals serve identifier gating and bot detection, not bid enrichment. + +### `[device]` + +| Field | Type | Required | Description | +| ---------- | -------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `provider` | String or null | No | Key of the device-detection provider. Defaults to `builtin` (User-Agent only, no host-specific call). Set `fastly` to add the host's TLS (JA4) and HTTP/2 probabilistic identifiers | + +The default `builtin` provider classifies from the User-Agent alone and makes no host-specific call, so the default path stays host-neutral. Selecting an unknown provider key fails at startup. + +**Example**: + +```toml +[device] +provider = "builtin" # or "fastly" to add TLS and HTTP/2 evidence +``` + +**Environment Override**: + +```bash +TRUSTED_SERVER__DEVICE__PROVIDER=builtin +``` + +## Geo Configuration + +Selects how a client IP is resolved into geolocation (country, region, coordinates), mirroring the Edge Cookie provider selection. The resolved country also feeds the [permission model](/guide/permission-model). + +### `[geo]` + +| Field | Type | Required | Description | +| ---------------------------- | -------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `provider` | String or null | No | Key of the geo provider. Omit, or set `none`, to resolve no location and make no host geo call. Set `platform` to use the host's own geo lookup. | +| `assume_single_jurisdiction` | Boolean | See description | With no geo provider, every request resolves at the top of the `permissions.yaml` rules tree. A deployment that runs an Edge Cookie provider without a geo provider acknowledges that by setting this to `true`. | + +No provider is the default, so a default deployment is not tied to any host geo service. Selecting an unknown provider key fails at startup. A failed geo lookup at request time does not fall back to the rules tree. It resolves every permission to the requires-signal floor and is logged at error level, so an outage is handled protectively. + +**Example**: + +```toml +[geo] +provider = "platform" +``` + +**Environment Override**: + +```bash +TRUSTED_SERVER__GEO__PROVIDER=platform +``` + +## Provider Permissions + +A provider advertises the technical permissions its data use requires, and Trusted Server runs the provider only when every required permission is set. This separates legal policy from the core, so the deployer brings the policy that decides how permissions are established. See the [Permission Model](/guide/permission-model) for the concept, the permission vocabulary, and how a request resolves. + +### Country and region rules (`permissions.yaml`) + +The country and region permission rules are defined in a human-editable `permissions.yaml` at the repository root, compiled into the build (not loaded at runtime). Edit that file and rebuild to change the policy. There is no `[permissions]` block in `trusted-server.toml`. It defines named **groups** (baselines such as `gdpr-eu` and `us-opt-out`) and a **rules** tree whose nodes map a country, and regions beneath it, to a group, with an optional `permissions` map that overrides single Data Uses (`granted`, `requires_signal`, or `denied`). A request that matches no node resolves at the top of the tree, which also names the `jurisdiction` for consent handling. See the [Permission Model](/guide/permission-model) for the schema and the shipped defaults. + +## Geo Configuration + +Which module resolves a visitor's location, if any. The whole section is +optional. What resolves location feeds the Edge Cookie context and the device +information sent with an auction request, so the selector changes what those +see. + +### `[geo]` + +| Field | Type | Required | Description | +| ---------- | ------ | -------- | ----------------------------------------------------------------------------------------------------------- | +| `provider` | String | No | `none`, or the id of a registered module that supplies a geo provider. Leave it out to keep the host lookup | + +The selector has three states. + +**Unset**, which is the whole section left out or written without `provider`. +The adapter's own host lookup stands, which is what every adapter ships with, +so a deployment that says nothing about geo behaves as it did before the +selector existed. + +**`none`**, which resolves no location at all. Every lookup returns nothing on +every platform, whatever the host could have reported. Choose this when the +deployment must not derive a location from a visitor's address. + +```toml +[geo] +provider = "none" +``` + +**A module id**, which has that module resolve location for every request +instead of the host. The id is the integration id, which is also the name of +the module's own configuration block, so the two names match. + +```toml +[geo] +provider = "example_geo" + +[integrations.example_geo] +enabled = true +``` + +#### When the selector names something that cannot supply a provider + +The application refuses to build and the message names the module and the reason, in three cases. Every adapter treats this as a startup error. Only the Axum dev server builds the application once, before it starts serving, so only there does the failure show once. Fastly, Cloudflare Workers and Spin all build the application inside the per-request entry point, so on those three the failure shows on every request. + +- The module is not registered at all. The message also lists the registered + modules that do supply a geo provider, so a typo is easy to spot. +- The module is registered but not enabled, so its geo provider was never + built. Enable the module in its own `[integrations.]` block, or point + the selector elsewhere. +- The module is enabled but supplies no geo provider. Not every module offers + one, and a module that does not cannot be selected. + +Refusing to build is deliberate, because a deployment that asked for a +specific location source should not quietly fall back to the host's. + +A module that supplies a geo provider the selector does not name is not an +error. The module registers as usual and the registry logs a warning when it is +built, saying the module supplies a geo provider that `[geo] provider` does not +select, so an operator can see a capability that is shipped and unused. ## Response Headers @@ -749,7 +903,7 @@ Startup fails when no handler covers an admin route. The dynamic `/_ts/admin/ec/{id}` route accepts any segment after `/_ts/admin/ec/`, and Basic Auth runs on the raw path before routing, so coverage cannot be inferred from ID-shaped samples: a pattern such as -`^/_ts/admin/ec/[a-f0-9]{64}[.][A-Za-z0-9]{6}$` is rejected. Use a prefix-level +`^/_ts/admin/ec/hmac~[a-f0-9]{64}[.][A-Za-z0-9]{6}$` is rejected. Use a prefix-level matcher (`^/_ts/admin`, or `^/_ts/admin/ec/` alongside the other admin patterns). @@ -1929,8 +2083,9 @@ Configuration is validated at startup: **EC Validation**: -- `passphrase` ≥ 1 character -- `passphrase` ≠ known placeholders (`"secret-key"`, `"secret_key"`, `"trusted-server"` — case-insensitive) +- `provider`, when set, names a provider with a matching `[ec.providers.]` block; an unknown or unconfigured selection fails at startup +- `providers.hmac.passphrase` ≥ 32 characters +- `providers.hmac.passphrase` ≠ known placeholders (`"secret-key"`, `"secret_key"`, `"trusted-server"`, case-insensitive) **Handler Validation**: @@ -1987,7 +2142,7 @@ TRUSTED_SERVER__PUBLISHER__PROXY_SECRET=$(cat /run/secrets/proxy_secret_staging) ```bash # All secrets from environment TRUSTED_SERVER__PUBLISHER__PROXY_SECRET=$(cat /run/secrets/proxy_secret) -TRUSTED_SERVER__EC__PASSPHRASE=$(cat /run/secrets/ec_secret) +TRUSTED_SERVER__EC__PROVIDERS__HMAC__PASSPHRASE=$(cat /run/secrets/ec_secret) TRUSTED_SERVER__HANDLERS__0__PASSWORD=$(cat /run/secrets/admin_password) ``` @@ -2041,7 +2196,7 @@ trusted-server.dev.toml # Development overrides **"Configuration field '...' is set to a known placeholder value"**: -- `ec.passphrase` cannot be `"secret-key"`, `"secret_key"`, or `"trusted-server"` (case-insensitive) +- `ec.providers.hmac.passphrase` cannot be `"secret-key"`, `"secret_key"`, or `"trusted-server"` (case-insensitive) - `publisher.proxy_secret` cannot be `"change-me-proxy-secret"` (case-insensitive) - Must be non-empty - Change to a secure random value (see generation commands above) diff --git a/docs/guide/ec-setup-guide.md b/docs/guide/ec-setup-guide.md index a11a352a8..661a21ad7 100644 --- a/docs/guide/ec-setup-guide.md +++ b/docs/guide/ec-setup-guide.md @@ -23,9 +23,12 @@ Set EC configuration in `trusted-server.toml`: ```toml [ec] -passphrase = "replace-with-32-plus-byte-random-secret" +provider = "hmac" ec_store = "ec_identity_store" +[ec.providers.hmac] +passphrase = "replace-with-32-plus-byte-random-secret" + [[ec.partners]] name = "Mocktioneer SSP" source_domain = "formally-vital-lion.edgecompute.app" @@ -35,6 +38,7 @@ bidstream_enabled = true Required behavior assumptions: +- `provider = "hmac"` selects the built-in HMAC provider; its `passphrase` lives under `[ec.providers.hmac]` - `passphrase` is long-lived HMAC-SHA256 keying material for EC ID derivation; use a high-entropy random value of at least 32 characters - `ec_store` is linked to the active Fastly service version - `ec_store` is the only KV-backed EC lifecycle store; it contains identity graph state, minimal consent metadata, source-domain keyed partner UIDs, and withdrawal tombstones @@ -87,13 +91,13 @@ curl -si "${TS_BASE_URL}/" \ Look for: -- `Set-Cookie: ts-ec=<64hex.6chars>` +- `Set-Cookie: ts-ec=hmac~<64hex.6chars>` (the `hmac~` prefix is the provider code; pre-series cookies without it still resolve) ## 5) Batch Sync (S2S) Endpoint: `POST /_ts/api/v1/batch-sync` -Important: request field is `ec_id` (full `{64hex}.{6alnum}` value). The `timestamp` field remains required for API compatibility, but it no longer orders writes because EC identity entries do not store per-partner sync timestamps. Valid mappings are idempotent last-write-wins: unchanged UIDs are accepted without a write, and different UIDs replace the stored value. +Important: request field is `ec_id` (the full value as issued, `hmac~{64hex}.{6alnum}`; the bare pre-series form is also accepted). The `timestamp` field remains required for API compatibility, but it no longer orders writes because EC identity entries do not store per-partner sync timestamps. Valid mappings are idempotent last-write-wins: unchanged UIDs are accepted without a write, and different UIDs replace the stored value. ```bash BATCH_UID="${PARTNER_UID}-batch" diff --git a/docs/guide/edge-cookies.md b/docs/guide/edge-cookies.md index 2ba9f2638..3d35337a0 100644 --- a/docs/guide/edge-cookies.md +++ b/docs/guide/edge-cookies.md @@ -8,12 +8,9 @@ permit EC use. ## Policy Posture -Trusted Server is technology. It is neutral on policy. The Edge Cookie -gives the deployer a cookie slot and configuration over the surrounding -attributes. The deployer determines the policy posture based on the -laws and contractual arrangements that apply to their deployment. -Privacy outcomes follow from that configuration, not from the cookie -mechanism itself. +Trusted Server is technology. It is neutral on policy. The Edge Cookie gives the deployer a cookie slot and configuration over the surrounding attributes. The deployer determines the policy posture based on the laws and contractual arrangements that apply to their deployment. Privacy outcomes follow from that configuration, not from the cookie mechanism itself. + +An Edge Cookie (EC) is a first-party identifier that the built-in provider derives on a first site visit with an HMAC of the client IP address plus a short random suffix, created only when the permission model allows it. It is passed in requests on subsequent visits and activity. Trusted Server surfaces the current EC ID via response headers and a first-party cookie. For the exact header and cookie names, see the [API Reference](/guide/api-reference). For full operational onboarding (partner configuration, batch sync, identify, and auction verification), use the [EC Setup Guide](/guide/ec-setup-guide). @@ -42,7 +39,7 @@ maps to a stable base. ### Request Lifecycle -Every request passes through four phases. EC generation only happens on organic routes (publisher proxy, integration proxy, auction) — read-only endpoints like `/identify` and `/batch-sync` skip generation entirely. During pre-routing, Trusted Server builds consent from request-local cookies, headers, geolocation, and policy defaults; it does not load consent from a separate KV store. +Every request passes through four phases. EC generation only happens on organic routes (publisher proxy, integration proxy, auction). Read-only endpoints like `/identify` and `/batch-sync` skip generation entirely. During pre-routing, Trusted Server builds the consent context from request-local cookies, headers, geolocation, and policy defaults. It does not load consent from a separate KV store. ```mermaid sequenceDiagram @@ -60,7 +57,7 @@ sequenceDiagram Note over TS: Phase 3: Finalize
Ingest Prebid EID cookies TS-->>B: Response + Set-Cookie: ts-ec=... else Return Visit (EC cookie present) - Note over TS: Phase 2: Routing
EC exists — skip generation + Note over TS: Phase 2: Routing
EC exists, skip generation Note over TS: Phase 3: Finalize
Ingest Prebid EID cookies TS-->>B: Response
(no cookie refresh) end @@ -70,11 +67,11 @@ sequenceDiagram ### Response Finalization -After routing completes, the server evaluates consent state and cookie presence to decide what to do with the EC cookie on the response. +After routing completes, the server evaluates the permission state and cookie presence to decide what to do with the EC cookie on the response. ```mermaid flowchart TD - Start[ec_finalize_response] --> ConsentCheck{Consent
allows EC?} + Start[ec_finalize_response] --> ConsentCheck{Permissions
allow EC?} ConsentCheck -- "No" --> ExplicitWithdrawal{Explicit
withdrawal?} ExplicitWithdrawal -- "Yes" --> CookiePresent{Cookie was
present?} @@ -87,44 +84,71 @@ flowchart TD WasPresent -- "No, just generated" --> NewEc["Ingest Prebid EID cookies
Set ts-ec cookie"] ``` -When consent cannot be verified for the current request — for example, unknown jurisdiction or missing/undecodable consent signals in a regulated region — Trusted Server fails closed for EC use by stripping EC headers, but it does **not** treat that as authoritative revocation of an already-issued EC. +When the required permissions cannot be established for the current request (for example an unknown country with no configured default, or missing or undecodable consent signals), Trusted Server fails closed for EC use by stripping EC headers, but it does **not** treat that as authoritative revocation of an already-issued EC. + +## Permission Gating + +EC creation is gated through the [permission model](/guide/permission-model), not by a jurisdiction rule baked into the core. The Edge Cookie provider advertises the permissions its data use requires, and Trusted Server creates an Edge Cookie only when every required permission is set. The built-in HMAC provider requires `necessary.operations.storage` (TCF Purpose 1), because the `Set-Cookie` operation stores information on the device. + +The Edge Cookie code never reads consent. It checks only whether the required **permission** is set. Consent is one of the sources that _set_ a permission, not something the gate reads directly, so the Edge Cookie logic does not change when a consent framework changes. Two sources combine for each request: + +- **A country and region baseline.** The country, and an optional region such as a US state, that the geo provider returns. A region rule takes precedence over its country, and when no country is identified, or the country/region has no rule, the configured default country applies. +- **Consent and privacy signals.** TCF, GPP, and GPC (`euconsent-v2`, `__gpp` / `__gpp_sid`, `us_privacy`, `Sec-GPC`) decoded from the request and mapped onto permissions as a **grant or a revoke** on top of that baseline. There is no separate consent KV fallback. + +Today only `necessary.operations.storage` is resolved this way: its country and region baseline is adjusted by the incoming TCF signal, and the Edge Cookie is created only when the result is set. With no configured default country, an unknown country sets nothing without a signal, so the cookie is not created unless a signal grants the permission. The core encodes no jurisdiction's law. The deployer brings the policy, and the per-country and per-region rules are configuration rather than core logic. See the [permission model](/guide/permission-model) for the full list of permission sources and the resolution order. + +```mermaid +flowchart TD + Start[Resolve country and region] --> Baseline[Country or region rule,
else the default country] + Baseline --> Signals[Apply consent/privacy signals
as a grant or revoke] + Signals --> Check{Provider's required
permissions all set?} + Check -- "Yes" --> Allow([Create EC]) + Check -- "No" --> Deny([No EC]) +``` + +The `ec_identity_store` KV store is the only EC lifecycle store. It holds identity graph state, source-domain keyed partner UIDs, a minimal consent snapshot used for EC entry metadata, and withdrawal tombstones. Permission resolution for each request is based on the live request signals listed above. + +## Provider Types: Server-Side and Client-Side -## Consent Model +The Edge Cookie identifier comes from a configurable provider, selected by `[ec] provider`. A provider is one of two types, and the permission gate above applies to both. The two reach the **same outcome** (a `ts-ec` cookie set and carried on every later request) by **different routes**. -EC creation is gated by jurisdiction. The server detects jurisdiction from geolocation data attached to the request and applies the corresponding consent rules. Live consent comes from request-local signals (`euconsent-v2`, `__gpp`, `__gpp_sid`, `us_privacy`, `Sec-GPC`) plus geolocation and policy defaults; there is no separate consent KV fallback. +- **Server-side** (for example the built-in HMAC provider, or the built-in `host-signals` provider that derives an identifier from the host's TLS JA4 and HTTP/2 signals on a host that supplies them). The provider derives the identifier at the edge from request data in `generate()`, and the **page response** sets the cookie. Nothing client-side is involved. +- **Client-side** (for example the `client-fixed` demo). The provider cannot derive the identifier at the edge, so `generate()` defers and returns no identifier. The page then runs the provider's own JavaScript in the browser, which does its work and posts the result to the resolve endpoint. The provider derives an identifier from that value in `resolve_from_client()`, and the **resolve response** sets the cookie. ```mermaid flowchart TD - Start[Detect Jurisdiction] --> J{Jurisdiction?} - - J -- "GDPR
(EU/UK)" --> TCF{TCF string
present?} - TCF -- "Yes" --> P1{Purpose 1
granted?} - P1 -- "Yes" --> Allow([Allow EC]) - P1 -- "No" --> Deny([Deny EC]) - TCF -- "No" --> Deny - - J -- "US State" --> GPC{GPC header
set?} - GPC -- "Yes" --> Deny - GPC -- "No" --> USTCF{TCF from CMP
e.g. Didomi?} - USTCF -- "Yes" --> USP1{Purpose 1
granted?} - USP1 -- "Yes" --> Allow - USP1 -- "No" --> Deny - USTCF -- "No" --> USP{US Privacy
string?} - USP -- "Yes" --> OptOut{Opt-out
sale?} - OptOut -- "No" --> Allow - OptOut -- "Yes" --> Deny - USP -- "No" --> Deny - - J -- "Non-regulated" --> Allow - J -- "Unknown
(no geo data)" --> Deny + Start(["Page request, no Edge Cookie"]) --> Type{"Provider type"} + + Type -->|"Server-side (e.g. HMAC)"| SGen["generate() derives at the edge from request data"] + SGen --> SSet["Page response sets ts-ec"] + + Type -->|"Client-side (e.g. client-fixed)"| CDefer["generate() defers, returns no identifier"] + CDefer --> CPage["Page response, no cookie, delivers the provider JS"] + CPage --> CBox[["Provider JS (black box): runs in the browser and does its work"]] + CBox --> CPost["JS posts the result to POST /_ts/api/v1/ec/resolve"] + CPost --> CResolve["resolve_from_client() verifies and derives"] + CResolve --> CSet["Resolve response sets ts-ec"] + + SSet --> Same(["Same outcome: ts-ec set, carried on every later request"]) + CSet --> Same ``` -- **GDPR**: Opt-in required. TCF Purpose 1 (store/access device) must be explicitly consented. -- **US State**: Opt-out model with three-tier fallback — GPC always blocks, then TCF if a CMP uses it, then US Privacy string, then fail-closed. -- **Non-regulated**: EC always allowed. -- **Unknown**: Fail-closed when jurisdiction cannot be determined. +The two types differ only in route and in the methods they use: + +| Feature | Server-side | Client-side | +| -------------------- | ------------------------- | ---------------------------------------------- | +| Example | HMAC (`hmac`) | `client-fixed` (demo) | +| Created in | `generate()`, at the edge | `resolve_from_client()`, from the posted value | +| `generate()` returns | the identifier | no identifier (defers) | +| Client JavaScript | none | the provider JS (black box), which posts back | +| Endpoint | none | `POST /_ts/api/v1/ec/resolve` | +| Cookie set on | the page response | the resolve response | + +The resolve endpoint requires an `Origin` on the publisher's domain and a `text/plain` or `application/json` body, and it answers `409` rather than silently replacing an identity the request already carries. A created identifier is persisted to the identity graph before the cookie is set, so withdrawal reaches a client-set identity the same way it reaches an edge-created one. On success the cookie is set on the endpoint's own first-party `200` response, so the value is live for every subsequent request without a second navigation. The cookie is `HttpOnly`, so the page script never reads it back; a non-`HttpOnly` marker cookie (`ts-ecr=1`, carrying no identity) tells the script a resolve succeeded so it does not post again on every page view. Every resolve response carries `Cache-Control: no-store`. The `client-fixed` demonstration provider is compiled only behind the `client-fixed-demo` cargo feature, so a production build rejects selecting it at startup. + +Because the posted value comes from the browser, **verification is the provider's responsibility**. A client-side provider must verify the payload (for example a signature) before creating an identifier, or a client could forge an Edge Cookie. The endpoint itself is provider-agnostic. It bounds the body, applies the same permission gate as organic generation, calls the provider, and writes the cookie. -The `ec_identity_store` KV store is the only EC lifecycle store. It holds identity graph state, source-domain keyed partner UIDs, a minimal consent snapshot used for EC entry metadata, and withdrawal tombstones. Consent interpretation for each request remains based on the live request signals listed above. +A built-in `client-fixed` provider demonstrates the client-side type end to end with no vendor coupling. Client and server share one fixed, known word. When no Edge Cookie is present, the page script (shipped in the tsjs bundle when that provider is selected) posts that word, the server verifies it matches, and on a match sets it as the Edge Cookie. The value is verifiable because it is a known constant, which is the point of the demo. It is useless in production, because a fixed value is not an identity, so it is for demonstration and testing only. ## Partner Sync Channels @@ -285,7 +309,7 @@ sets `Path=/`, `Secure`, `HttpOnly`, `SameSite=Lax`, and a `Max-Age`. - Returning requests with consent and an existing `ts-ec` do not refresh the EC cookie or KV TTL. - Newly generated ECs receive `Set-Cookie: ts-ec=...`. -- When consent is blocked but not explicitly withdrawn, Trusted Server strips EC response headers for that request but leaves any existing `ts-ec` cookie intact; cookie expiry and tombstones happen only on explicit withdrawal. +- When the permission is not set but nothing was explicitly withdrawn, Trusted Server strips EC response headers for that request but leaves any existing `ts-ec` cookie intact; cookie expiry and tombstones happen only on explicit withdrawal. Withdrawal is deliberately narrow: a TCF record refusing storage in a jurisdiction whose baseline did not grant it. US-style opt-outs (GPC, a GPP sale opt-out, or a US Privacy opt-out) suppress use for the request but never expire the cookie or write a tombstone, so lifting the opt-out restores the identity. - `/_ts/api/v1/identify` is read-oriented and returns identity enrichment for the authenticated partner. It computes `cluster_size` only when the EC entry does not already store one. - `/_ts/api/v1/batch-sync` writes mappings into the EC identity graph. Mapping timestamps are retained for API compatibility but no longer order writes; valid mappings use idempotent last-write-wins semantics. - Pull sync fills missing partner UIDs only. Existing partner UIDs are not periodically refreshed because EC entries no longer store per-partner sync timestamps. diff --git a/docs/guide/error-reference.md b/docs/guide/error-reference.md index b5348ed9f..070c07cb3 100644 --- a/docs/guide/error-reference.md +++ b/docs/guide/error-reference.md @@ -69,7 +69,7 @@ proxy_secret = "change-me-to-random-string" - `publisher.domain` - `publisher.origin_url` - `publisher.proxy_secret` -- `ec.passphrase` +- `ec.providers.hmac.passphrase` (when `ec.provider = "hmac"`) --- @@ -141,17 +141,21 @@ Failed to generate EC ID: HMAC error **Solution:** -1. Ensure `passphrase` is set in `trusted-server.toml`: +1. Ensure the `hmac` provider is selected and its `passphrase` is set in `trusted-server.toml`: ```toml [ec] +provider = "hmac" + +[ec.providers.hmac] passphrase = "replace-with-32-plus-byte-random-secret" ``` 2. Or set via environment variable: ```bash -TRUSTED_SERVER__EC__PASSPHRASE=replace-with-32-plus-byte-random-secret +TRUSTED_SERVER__EC__PROVIDER=hmac +TRUSTED_SERVER__EC__PROVIDERS__HMAC__PASSPHRASE=replace-with-32-plus-byte-random-secret ``` --- diff --git a/docs/guide/fastly.md b/docs/guide/fastly.md index 7ef1e8ae8..b6b8691f7 100644 --- a/docs/guide/fastly.md +++ b/docs/guide/fastly.md @@ -286,8 +286,11 @@ Configure in `trusted-server.toml`: ```toml [ec] -passphrase = "replace-with-32-plus-byte-random-secret" +provider = "hmac" ec_store = "ec_identity_store" + +[ec.providers.hmac] +passphrase = "replace-with-32-plus-byte-random-secret" ``` Verify stores exist: diff --git a/docs/guide/gdpr-compliance.md b/docs/guide/gdpr-compliance.md index 556a7abfd..235825265 100644 --- a/docs/guide/gdpr-compliance.md +++ b/docs/guide/gdpr-compliance.md @@ -7,9 +7,11 @@ Consent signal handling in Trusted Server. Trusted Server reads consent signals from each request, decodes them, and applies built-in enforcement rules to consent-gated activities such as EC creation and EID forwarding. The publisher configures how -signals are interpreted: which countries and US states map to each -jurisdiction's rules, how Global Privacy Control is read, how -conflicting signals are resolved, and when stored signals expire. +signals are interpreted, meaning how Global Privacy Control is read, +how conflicting signals are resolved, and when stored signals expire. +Which countries and US states fall under which jurisdiction's rules is +not set here, because the `permissions.yaml` rules tree states it. See +the [Permission Model](/guide/permission-model). The per-activity gates and their fail-closed defaults are built in. ## Policy Posture @@ -96,12 +98,6 @@ Configure consent handling in the `[consent]` section of mode = "interpreter" # or "proxy" (forward raw strings without decoding) max_consent_age_days = 365 # expiration check for dated signals -[consent.gdpr] -applies_in = ["DE", "FR"] # countries mapped to the GDPR rules - -[consent.us_states] -privacy_states = ["CA", "CO"] # US states mapped to the US state rules - [consent.us_privacy_defaults] gpc_implies_optout = true # how the Sec-GPC header is interpreted @@ -112,6 +108,17 @@ mode = "restrictive" # or "newest" / "permissive" Each field tunes how signals are interpreted. The per-jurisdiction gates and their fail-closed defaults are built in. +Which jurisdiction applies to a visitor is not configured here. The +`[consent.gdpr] applies_in` and `[consent.us_states] privacy_states` +lists are retired, and a `jurisdiction` attribute on the +`permissions.yaml` rules tree does their job. Every node of that tree +may name the jurisdiction for the places it covers, a node that names +none inherits the nearest one above it, and the top of the tree answers +a visitor whose country cannot be resolved. One file therefore carries +the permission baselines and the jurisdiction assignment together. The +[Permission Model](/guide/permission-model) documents the tree, so this +page does not repeat it. + ## Operational Behavior - Consent checks run before consent-gated activities (EC creation, @@ -120,7 +127,8 @@ gates and their fail-closed defaults are built in. Resolution of conflicting signals is configurable (restrictive, newest, or permissive). - Audit logging records the consent decision per gated activity. -- Regional rules are applied per detected jurisdiction. +- Regional rules are applied per detected jurisdiction, which the rules + tree assigns from the visitor's country and region. ## Best Practices @@ -136,6 +144,7 @@ gates and their fail-closed defaults are built in. ## Next Steps +- [Permission Model](/guide/permission-model) - [Configuration Reference](/guide/configuration) - [Edge Cookies](/guide/edge-cookies) - [Architecture](/guide/architecture) diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index 4346fd7e0..9ac60cbe4 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -266,6 +266,101 @@ For unit tests, prefer exposing helper constructors that accept a stub `shim_src By following these steps you can ship independent integration modules that plug into the Trusted Server runtime without modifying the Fastly entrypoint or HTML processor each time. +## Modules That Live Outside Core + +Every step above puts the module inside `trusted-server-core`. A module can instead ship in its own crate that a deployment composes in at startup, so the vendor owns the code, the release cycle and the module's own rules, and core never names the vendor. + +`crates/integrations/seam-probe` is the worked example. It is a test fixture rather than something to deploy, but it exercises every part of the seam from a vendor crate's position, and the round-trip tests in `crates/trusted-server-adapter-axum/tests/seam_probe.rs` drive each part through a real adapter. + +### What the Vendor Crate Provides + +The crate hands out an `IntegrationBuilder`, which names the module and points at the functions that do the work. + +| Part | Type | Purpose | +| ----------------- | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Id | `&'static str` | Names the module. The same string is the key of its `[integrations.]` configuration block and the value `[geo] provider` uses to select it | +| Source | `&'static str` | The crate or package name, reported when two builders claim the same id so an operator can tell which crates collided | +| Build function | `IntegrationBuilderFn` | Reads `Settings` and returns a registration when the module is enabled, or nothing when it is not | +| Validate function | `IntegrationValidateFn` | The module's own deploy-time rules. They run when deploy validation is invoked with this builder, for every builder passed, enabled or not. The registry does not call them and the CLI does not carry them, see the traps below | +| Request preparer | `IntegrationPrepareRequestFn` | Optional. Added with `.with_request_preparer()`, it runs once per request before routing, whether or not the module is enabled | + +```rust +pub fn builder() -> IntegrationBuilder { + IntegrationBuilder::new(EXAMPLE_ID, EXAMPLE_SOURCE, register, validate) + .with_request_preparer(prepare_request) +} +``` + +A crate that bids supplies an `AuctionProviderBuilder` in the same shape, with a provider name, a source, a build function and a validate function. The name it declares is what `[auction] providers` may then list. + +### What a Registration Can Declare + +The build function returns an `IntegrationRegistration`, built with the same builder the in-core modules use. + +| Declaration | What it does | +| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `.with_proxy(...)` | Routes the paths the proxy declares, served under `/integrations//` | +| `.with_head_injector(...)` | Emits markup at the start of `` | +| `.with_attribute_rewriter(...)` | Rewrites attribute values in publisher HTML | +| `.with_script_rewriter(...)` | Rewrites inline script contents | +| `.with_html_post_processor(...)` | Works on the document after rewriting | +| `.with_request_filter(...)` | Inspects a request and can turn it back before it reaches the origin | +| `.with_js_module(CarriedJsModule { source, sha256 })` | Carries the module's own browser script, built outside `trusted-server-js` | +| `.with_deferred_js()` | Serves the script as its own ` + + diff --git a/tools/permissions-inspector/wasm/.gitignore b/tools/permissions-inspector/wasm/.gitignore new file mode 100644 index 000000000..2f7896d1d --- /dev/null +++ b/tools/permissions-inspector/wasm/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/tools/permissions-inspector/wasm/Cargo.lock b/tools/permissions-inspector/wasm/Cargo.lock new file mode 100644 index 000000000..555b4e912 --- /dev/null +++ b/tools/permissions-inspector/wasm/Cargo.lock @@ -0,0 +1,2490 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-compression" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-io", + "pin-project-lite", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "build-print" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8e6738dfb11354886f890621b4a34c0b177f75538023f7100b608ab9adbd66b" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "edgezero-core" +version = "0.1.0" +source = "git+https://github.com/stackpop/edgezero?tag=v0.0.7#5c9886e51d17e6969531356bacdf27f144ac8a2e" +dependencies = [ + "anyhow", + "async-compression", + "async-stream", + "async-trait", + "bytes", + "edgezero-macros", + "futures", + "futures-util", + "http", + "http-body", + "log", + "matchit", + "ryu", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha2", + "thiserror", + "toml", + "tower-service", + "tracing", + "validator", + "web-time", +] + +[[package]] +name = "edgezero-macros" +version = "0.1.0" +source = "git+https://github.com/stackpop/edgezero?tag=v0.0.7#5c9886e51d17e6969531356bacdf27f144ac8a2e" +dependencies = [ + "log", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 3.0.4", + "toml", + "validator", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "error-stack" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b878b3fac9613c3c7f22eb70bc8a3c6ebdc03cc11479ee60fde1692d747fd45f" +dependencies = [ + "anyhow", + "rustc_version", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "iab_gpp" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3be2d0191a3376e0176bb3df53b2754c644ead6edd50d9494ee8fa376a70e02" +dependencies = [ + "bitstream-io", + "fnv", + "iab_gpp_derive", + "num-derive", + "num-iter", + "num-traits", + "prettyplease", + "proc-macro2", + "quote", + "strum_macros", + "syn 2.0.119", + "thiserror", + "walkdir", +] + +[[package]] +name = "iab_gpp_derive" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5acda598b043c6386d20fffe86c600b63c7ca4980ee9a28f7e9aaa15d749747" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jose-b64" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec69375368709666b21c76965ce67549f2d2db7605f1f8707d17c9656801b56" +dependencies = [ + "base64ct", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "jose-jwa" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ab78e053fe886a351d67cf0d194c000f9d0dcb92906eb34d853d7e758a4b3a7" +dependencies = [ + "serde", +] + +[[package]] +name = "jose-jwk" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "280fa263807fe0782ecb6f2baadc28dffc04e00558a58e33bfdb801d11fd58e7" +dependencies = [ + "jose-b64", + "jose-jwa", + "p256", + "p384", + "rsa", + "serde", + "zeroize", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lol_html" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00aad58f6ec3990e795943872f13651e7a5fa59dca2c8f31a74faf8a0e0fb652" +dependencies = [ + "bitflags", + "cfg-if", + "cssparser", + "encoding_rs", + "foldhash", + "hashbrown", + "memchr", + "mime", + "precomputed-hash", + "selectors", + "thiserror", +] + +[[package]] +name = "matchit" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "elliptic-curve", + "primeorder", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "elliptic-curve", + "primeorder", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "permissions-inspector-wasm" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "trusted-server-core", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-error-attr3" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e564d14133360e1ae169ffde5da25881b5fa47261665b8e5713c212c27799da" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error3" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f0d4471b3436c22106b21913b1dda531558918ae9b7ec55d58aa84b43552233" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "subtle", + "zeroize", +] + +[[package]] +name = "selectors" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cfaaa6035167f0e604e42723c7650d59ee269ef220d7bbe0565602c8a0173b9" +dependencies = [ + "bitflags", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "toml" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "trusted-server-core" +version = "0.1.0" +dependencies = [ + "async-stream", + "async-trait", + "base64", + "brotli", + "bytes", + "chacha20poly1305", + "chrono", + "cookie", + "derive_more", + "ed25519-dalek", + "edgezero-core", + "error-stack", + "flate2", + "futures", + "getrandom 0.2.17", + "glob", + "hex", + "hmac", + "http", + "httpdate", + "iab_gpp", + "jose-jwk", + "log", + "lol_html", + "matchit", + "mime", + "rand", + "regex", + "serde", + "serde_json", + "serde_yaml_ng", + "sha2", + "subtle", + "toml", + "trusted-server-js", + "trusted-server-openrtb", + "url", + "urlencoding", + "uuid", + "validator", + "web-time", +] + +[[package]] +name = "trusted-server-js" +version = "0.1.0" +dependencies = [ + "build-print", + "hex", + "sha2", + "which", +] + +[[package]] +name = "trusted-server-openrtb" +version = "0.1.0" +dependencies = [ + "log", + "serde", + "serde_json", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240e4b81c20a1d6d50d1d7265c658dfbd204e8b9ac4d80f3c931f39462196335" +dependencies = [ + "darling", + "proc-macro-error3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "which" +version = "8.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" +dependencies = [ + "libc", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "serde", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/tools/permissions-inspector/wasm/Cargo.toml b/tools/permissions-inspector/wasm/Cargo.toml new file mode 100644 index 000000000..f212c89db --- /dev/null +++ b/tools/permissions-inspector/wasm/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "permissions-inspector-wasm" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +trusted-server-core = { path = "../../../crates/trusted-server-core" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +opt-level = "z" +lto = true +strip = true +codegen-units = 1 + +# Deliberately its own workspace, like the integration-tests crate, so the +# main workspace's wasm32-wasip1 default target does not apply to it. +[workspace] diff --git a/tools/permissions-inspector/wasm/build.rs b/tools/permissions-inspector/wasm/build.rs new file mode 100644 index 000000000..65abf8bda --- /dev/null +++ b/tools/permissions-inspector/wasm/build.rs @@ -0,0 +1,37 @@ +use std::process::Command; + +fn git(args: &[&str]) -> String { + Command::new("git") + .args(args) + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .unwrap_or_default() +} + +/// Reads the workspace version from the repository root manifest, so the page +/// reports the same version as the trusted-server crates it runs. +fn workspace_version() -> String { + let root = std::fs::read_to_string("../../../Cargo.toml").unwrap_or_default(); + let mut in_package = false; + for line in root.lines() { + let line = line.trim(); + if line.starts_with('[') { + in_package = line == "[workspace.package]"; + } else if in_package && line.starts_with("version") { + if let Some(version) = line.split('"').nth(1) { + return version.to_string(); + } + } + } + String::from("unknown") +} + +fn main() { + println!("cargo:rustc-env=TS_CORE_VERSION={}", workspace_version()); + println!("cargo:rustc-env=TS_CORE_COMMIT={}", git(&["rev-parse", "--short=9", "HEAD"])); + println!("cargo:rustc-env=TS_CORE_DATE={}", git(&["show", "-s", "--format=%cs", "HEAD"])); + println!("cargo:rustc-env=TS_CORE_BRANCH={}", git(&["rev-parse", "--abbrev-ref", "HEAD"])); + println!("cargo:rerun-if-changed=../../../Cargo.toml"); +} diff --git a/tools/permissions-inspector/wasm/src/lib.rs b/tools/permissions-inspector/wasm/src/lib.rs new file mode 100644 index 000000000..928df80b1 --- /dev/null +++ b/tools/permissions-inspector/wasm/src/lib.rs @@ -0,0 +1,151 @@ +//! The permissions bit of Trusted Server, compiled to WebAssembly for the +//! inspector page. Inputs in, resulting permissions out, through the same +//! functions the server runs: `build_context_from_signals` decodes the raw +//! consent signals and `assemble_permissions` resolves the policy. + +use serde::Deserialize; +use serde_json::json; +use trusted_server_core::consent::build_context_from_signals; +use trusted_server_core::consent::types::RawConsentSignals; +use trusted_server_core::ec::consent::{GeoStatus, assemble_permissions}; +use trusted_server_core::permissions::{Permission, PermissionMaps}; +use trusted_server_core::platform::GeoInfo; + +/// The inspector's evaluation request. +#[derive(Deserialize)] +struct EvalInput { + /// `located`, `none`, or `failed`. + geo: String, + country: Option, + region: Option, + tc: Option, + gpp: Option, + us_privacy: Option, + #[serde(default)] + gpc: bool, +} + +fn eval_json(input: &str) -> String { + let input: EvalInput = match serde_json::from_str(input) { + Ok(input) => input, + Err(e) => return json!({"ok": false, "error": e.to_string()}).to_string(), + }; + let signals = RawConsentSignals { + raw_tc_string: input.tc.filter(|s| !s.is_empty()), + raw_gpp_string: input.gpp.filter(|s| !s.is_empty()), + raw_gpp_sid: None, + raw_us_privacy: input.us_privacy.filter(|s| !s.is_empty()), + gpc: input.gpc, + }; + let ctx = build_context_from_signals(&signals); + let maps = PermissionMaps::standard(); + let (state, jurisdiction) = match input.geo.as_str() { + "failed" => { + let state = assemble_permissions(&ctx, GeoStatus::Failed); + (state, "unknown".to_string()) + } + "none" => { + let state = assemble_permissions(&ctx, GeoStatus::NoLocation); + (state, jurisdiction_name(maps.default_jurisdiction())) + } + _ => { + let info = GeoInfo { + city: String::new(), + country: input.country.clone().unwrap_or_default(), + continent: String::new(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: input.region.clone().filter(|r| !r.is_empty()), + asn: None, + }; + let state = assemble_permissions(&ctx, GeoStatus::Located(&info)); + let jurisdiction = jurisdiction_name( + maps.jurisdiction_for(input.country.as_deref(), input.region.as_deref()), + ); + (state, jurisdiction) + } + }; + let set: Vec<&'static str> = Permission::all() + .filter(|p| state.is_set(*p)) + .map(Permission::as_str) + .collect(); + json!({ + "ok": true, + "jurisdiction": jurisdiction, + "set": set, + "tcf_decoded": ctx.tcf.is_some(), + "malformed_record": ctx.has_malformed_record(), + }) + .to_string() +} + +fn jurisdiction_name(j: trusted_server_core::consent::jurisdiction::Jurisdiction) -> String { + let name = format!("{j:?}").to_lowercase(); + let name = name.split('(').next().unwrap_or(&name).to_string(); + name.replace("usstate", "us-state").replace("nonregulated", "non-regulated") +} + +fn validate_json(yaml: &str) -> String { + match PermissionMaps::from_yaml(yaml) { + Ok(_) => json!({"ok": true}).to_string(), + Err(e) => json!({"ok": false, "error": e.to_string()}).to_string(), + } +} + +fn meta_json() -> String { + json!({ + "version": env!("TS_CORE_VERSION"), + "commit": env!("TS_CORE_COMMIT"), + "date": env!("TS_CORE_DATE"), + "branch": env!("TS_CORE_BRANCH"), + }) + .to_string() +} + +/// Leaks a length-prefixed buffer the host reads and then frees. +fn out(s: String) -> *mut u8 { + let bytes = s.into_bytes(); + let mut buf = Vec::with_capacity(4 + bytes.len()); + buf.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); + buf.extend_from_slice(&bytes); + let ptr = buf.as_mut_ptr(); + core::mem::forget(buf); + ptr +} + +#[unsafe(no_mangle)] +pub extern "C" fn ts_alloc(len: usize) -> *mut u8 { + let mut buf = vec![0u8; len]; + let ptr = buf.as_mut_ptr(); + core::mem::forget(buf); + ptr +} + +/// # Safety +/// `ptr` must come from `ts_alloc` or an `out` buffer with capacity `len`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ts_free(ptr: *mut u8, len: usize) { + unsafe { drop(Vec::from_raw_parts(ptr, len, len)) }; +} + +/// # Safety +/// `ptr`/`len` must describe a valid UTF-8 JSON buffer from `ts_alloc`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ts_eval(ptr: *const u8, len: usize) -> *mut u8 { + let input = unsafe { core::slice::from_raw_parts(ptr, len) }; + out(eval_json(core::str::from_utf8(input).unwrap_or("{}"))) +} + +/// # Safety +/// `ptr`/`len` must describe a valid UTF-8 YAML buffer from `ts_alloc`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ts_validate(ptr: *const u8, len: usize) -> *mut u8 { + let input = unsafe { core::slice::from_raw_parts(ptr, len) }; + out(validate_json(core::str::from_utf8(input).unwrap_or(""))) +} + +#[unsafe(no_mangle)] +pub extern "C" fn ts_meta() -> *mut u8 { + out(meta_json()) +} diff --git a/trusted-server.example.toml b/trusted-server.example.toml index b0e359cb4..93aa29e86 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -6,10 +6,13 @@ # `trusted-server.toml`. Copy it (`ts config init`), fill in the required # values, and push it (`ts config push`) as an EdgeZero app-config blob. # -# Only three sections are REQUIRED for the server to start and pass validation: +# Only two sections are REQUIRED for the server to start and pass validation: # 1. [[handlers]] covering /_ts/admin (admin authentication) # 2. [publisher] (domain + origin) -# 3. [ec] passphrase (Edge Cookie identity secret) +# +# Edge Cookie identity is optional and stays off until an [ec] provider is +# selected, so no identity secret is needed to start. See the Edge Cookie +# section below. # # Everything below those is OPTIONAL. Most optional blocks are commented out — # uncomment and edit one to enable it — but a few integrations are kept as active @@ -70,22 +73,51 @@ proxy_secret = "change-me-proxy-secret" # ----------------------------------------------------------------------------- -# REQUIRED — Edge Cookie (EC) identity +# OPTIONAL — Edge Cookie (EC) identity # ----------------------------------------------------------------------------- [ec] -# Secret used to derive EC identifiers. Must be >= 32 chars and non-placeholder -# in production (deploy validation rejects known placeholders). -passphrase = "trusted-server-placeholder-secret" +# Edge Cookie identity is OFF by default: with no provider selected, Trusted +# Server runs statelessly and generates no Edge Cookie. Activate one by +# uncommenting the selector AND its [ec.providers.] block together (a +# block with no selector is rejected at startup). Deployment tooling can merge +# a TRUSTED_SERVER__EC__PROVIDER environment value into the published +# configuration before it is loaded; the running server itself reads its +# settings from the platform config store, not the environment. The built-in +# hmac provider is host-neutral, and a vendor provider ships in its own crate +# that the adapter composes in. +# provider = "hmac" # KV store that persists EC identity state. This is the physical store name # bound per adapter (e.g. `ec_identity_store` in fastly.toml); edgezero.toml's # logical KV id is `trusted_server_kv`. ec_store = "ec_identity_store" +# Extra origins allowed to POST the client resolve endpoint. The endpoint +# always accepts https:// and nothing else by default. List +# further origins here if the pages that resolve identity are served from +# another origin, www being the common case. Each entry is a serialized origin +# (RFC 6454) compared by the same-origin test, so the scheme, host and port all +# have to match, and a subdomain is never accepted just for being a subdomain. +# resolve_allowed_origins = ["https://www.example.com"] # Max concurrent partner pull-sync requests. pull_sync_concurrency = 3 # Optional cluster-heuristic tuning (defaults shown): # cluster_trust_threshold = 10 # entries with cluster_size <= this are individual users # cluster_recheck_secs = 3600 # re-evaluate cluster_size after this many seconds +# Built-in HMAC provider block. Uncomment it together with the +# `provider = "hmac"` selector above. The secret used to derive EC identifiers +# must be >= 32 bytes and non-placeholder in production (deploy validation +# rejects known placeholders). +# [ec.providers.hmac] +# passphrase = "replace-with-32-plus-byte-random-secret" + +# Built-in host-signal provider block. Creates the identifier from the host's TLS +# JA4 and HTTP/2 signals instead of the client IP, so it needs a host that +# supplies those signals (Fastly does). Uncomment it together with +# `provider = "host-signals"` above, and give it its own secret on the same +# terms as the hmac block. +# [ec.providers.host-signals] +# passphrase = "replace-with-32-plus-byte-random-secret" + # Optional identity partners (SSP/DSP/identity vendors). Each needs a real, # non-placeholder api_token (>= 32 bytes) at deploy. Configure real partners via # private config, not this template. @@ -101,11 +133,46 @@ pull_sync_concurrency = 3 # batch_rate_limit = 60 # max batch-sync requests/min (default 60) # pull_sync_enabled = false # default false +# Which module resolves location. Leave the whole section out to keep the +# host's own lookup, which is what every adapter ships with today. +# [geo] +# `none` resolves no location at all, or name a registered module that declares +# a geo provider to have that module resolve location instead of the host. +# provider = "none" + # ============================================================================= # OPTIONAL — Core features (disabled/omitted by default) # ============================================================================= +# Device-detection provider. Selects how a request is classified into device +# signals (browser/bot gating). Default "builtin" classifies from the User-Agent +# alone and makes no host call, so a default deployment stays host-neutral. The +# opt-in "fastly" provider adds the host's TLS/H2 signals to the gate. +# Override at deployment with TRUSTED_SERVER__device__provider. +# [device] +# provider = "builtin" # or "fastly" to add TLS/H2 signal evidence + +[geo] +# Geo / IP intelligence provider. No provider is the default: Trusted Server +# resolves no geolocation and makes no host geo call, so a default deployment is +# not tied to any host geo service. Opt into the host lookup with +# provider = "platform". Override with TRUSTED_SERVER__geo__provider. +# provider = "platform" +# +# The permission baseline for a request the geo provider leaves unmatched +# (and, with no geo provider, for every request) is the top of the rules tree +# in the permissions.yaml compiled into the build (the repository sample is +# config/permissions/vanilla.yaml). Edit that file to +# change it. +# +# With no geo provider, every request is treated as that top node, so a +# visitor from another jurisdiction receives its permission rules. A +# deployment that runs an Edge Cookie provider without a geo provider must +# acknowledge that by uncommenting the line below, or select a geo provider +# instead. +# assume_single_jurisdiction = true + # Custom headers added to every response (e.g. X-Robots-Tag: noindex). # [response_headers] # X-Robots-Tag = "noindex" @@ -147,11 +214,10 @@ pull_sync_concurrency = 3 # check_expiration = true # check TCF consent freshness # max_consent_age_days = 395 # max age before consent is treated as expired (~13 months) # -# [consent.gdpr] -# applies_in = ["AT","BE","BG","HR","CY","CZ","DK","EE","FI","FR","DE","GR","HU","IE","IT","LV","LT","LU","MT","NL","PL","PT","RO","SK","SI","ES","SE","IS","LI","NO","GB"] -# -# [consent.us_states] -# privacy_states = ["CA","VA","CO","CT","UT","MT","OR","TX","FL","DE","IA","NE","NH","NJ","TN","MN","MD","IN","KY","RI"] +# Which places fall under GDPR, and which US states have a comprehensive +# privacy law, are no longer listed here. They are the `jurisdiction` values in +# the permissions.yaml rules tree, so one file states the policy for both the +# permission baseline and the consent handling. # # [consent.us_privacy_defaults] # notice_given = true # has the publisher shown CCPA notice?