diff --git a/Cargo.lock b/Cargo.lock index 186cfecd..4ae4308f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1166,8 +1166,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -2638,50 +2640,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "tokio" -version = "1.49.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" -dependencies = [ - "bytes", - "pin-project-lite", - "tokio-macros", -] - -[[package]] -name = "tokio-macros" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - -[[package]] -name = "tokio-stream" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-test" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" -dependencies = [ - "futures-core", - "tokio", - "tokio-stream", -] - [[package]] name = "toml" version = "0.9.8" @@ -2798,6 +2756,7 @@ dependencies = [ "serde_json", "trusted-server-core", "trusted-server-js", + "url", "urlencoding", ] @@ -2818,9 +2777,9 @@ dependencies = [ "ed25519-dalek", "edgezero-core", "error-stack", - "fastly", "flate2", "futures", + "getrandom 0.2.16", "handlebars", "hex", "hmac", @@ -2839,8 +2798,6 @@ dependencies = [ "sha2 0.10.9", "subtle", "temp-env", - "tokio", - "tokio-test", "toml 1.1.2+spec-1.1.0", "trusted-server-js", "trusted-server-openrtb", diff --git a/crates/trusted-server-adapter-fastly/Cargo.toml b/crates/trusted-server-adapter-fastly/Cargo.toml index 2d1191cb..bdf3d88a 100644 --- a/crates/trusted-server-adapter-fastly/Cargo.toml +++ b/crates/trusted-server-adapter-fastly/Cargo.toml @@ -20,6 +20,7 @@ log-fastly = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } trusted-server-core = { path = "../trusted-server-core" } +url = { workspace = true } urlencoding = { workspace = true } trusted-server-js = { path = "../js" } diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 9150583f..46603818 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -50,8 +50,8 @@ use trusted_server_core::settings_data::get_settings; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; use crate::platform::open_kv_store; use crate::platform::{ - FastlyPlatformBackend, FastlyPlatformConfigStore, FastlyPlatformGeo, FastlyPlatformHttpClient, - FastlyPlatformSecretStore, UnavailableKvStore, + FastlyConsentKvStore, FastlyPlatformBackend, FastlyPlatformConfigStore, FastlyPlatformGeo, + FastlyPlatformHttpClient, FastlyPlatformSecretStore, UnavailableKvStore, }; // --------------------------------------------------------------------------- @@ -128,6 +128,16 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime .build() } +/// Open the consent KV store named in `config`, returning `None` when not configured or unavailable. +pub(crate) fn open_consent_kv( + config: &trusted_server_core::consent_config::ConsentConfig, +) -> Option { + config + .consent_store + .as_deref() + .and_then(FastlyConsentKvStore::open) +} + // --------------------------------------------------------------------------- // Error helper // --------------------------------------------------------------------------- @@ -268,9 +278,18 @@ impl Hooks for TrustedServerApp { async move { let services = build_per_request_services(&s, &ctx); let req = ctx.into_request(); - Ok(handle_auction(&s.settings, &s.orchestrator, &services, req) - .await - .unwrap_or_else(|e| http_error(&e))) + let consent_kv = open_consent_kv(&s.settings.consent); + Ok(handle_auction( + &s.settings, + &s.orchestrator, + &services, + consent_kv + .as_ref() + .map(|kv| kv as &dyn trusted_server_core::consent::kv::ConsentKvOps), + req, + ) + .await + .unwrap_or_else(|e| http_error(&e))) } }; @@ -363,7 +382,17 @@ impl Hooks for TrustedServerApp { })) }) } else { - handle_publisher_request(&s.settings, &s.registry, &services, req).await + let consent_kv = open_consent_kv(&s.settings.consent); + handle_publisher_request( + &s.settings, + &s.registry, + &services, + consent_kv + .as_ref() + .map(|kv| kv as &dyn trusted_server_core::consent::kv::ConsentKvOps), + req, + ) + .await }; Ok(result.unwrap_or_else(|e| http_error(&e))) @@ -390,7 +419,17 @@ impl Hooks for TrustedServerApp { })) }) } else { - handle_publisher_request(&s.settings, &s.registry, &services, req).await + let consent_kv = open_consent_kv(&s.settings.consent); + handle_publisher_request( + &s.settings, + &s.registry, + &services, + consent_kv + .as_ref() + .map(|kv| kv as &dyn trusted_server_core::consent::kv::ConsentKvOps), + req, + ) + .await }; Ok(result.unwrap_or_else(|e| http_error(&e))) diff --git a/crates/trusted-server-core/src/backend.rs b/crates/trusted-server-adapter-fastly/src/backend.rs similarity index 91% rename from crates/trusted-server-core/src/backend.rs rename to crates/trusted-server-adapter-fastly/src/backend.rs index 3df84a7a..88457a14 100644 --- a/crates/trusted-server-core/src/backend.rs +++ b/crates/trusted-server-adapter-fastly/src/backend.rs @@ -4,7 +4,7 @@ use error_stack::{Report, ResultExt}; use fastly::backend::Backend; use url::Url; -use crate::error::TrustedServerError; +use trusted_server_core::error::TrustedServerError; /// Returns the default port for the given scheme (443 for HTTPS, 80 for HTTP). #[inline] @@ -217,10 +217,9 @@ impl<'a> BackendConfig<'a> { /// Parse an origin URL into its (scheme, host, port) components. /// - /// Centralises URL parsing so that [`from_url`](Self::from_url), - /// [`from_url_with_first_byte_timeout`](Self::from_url_with_first_byte_timeout), - /// and [`backend_name_for_url`](Self::backend_name_for_url) share one - /// code-path. + /// Centralises URL parsing so that [`from_url`](Self::from_url) and + /// [`from_url_with_first_byte_timeout`](Self::from_url_with_first_byte_timeout) + /// share one code-path. fn parse_origin( origin_url: &str, ) -> Result<(String, String, Option), Report> { @@ -287,37 +286,6 @@ impl<'a> BackendConfig<'a> { .first_byte_timeout(first_byte_timeout) .ensure() } - - /// Compute the backend name that - /// [`from_url_with_first_byte_timeout`](Self::from_url_with_first_byte_timeout) - /// would produce for the given URL and timeout, **without** registering a - /// backend. - /// - /// This is useful when callers need the name for mapping purposes (e.g. the - /// auction orchestrator correlating responses to providers) but want the - /// actual registration to happen later with specific settings. - /// - /// The `first_byte_timeout` must match the value that will be used at - /// registration time so that the predicted name is correct. - /// - /// # Errors - /// - /// Returns an error if the URL cannot be parsed or lacks a host. - pub fn backend_name_for_url( - origin_url: &str, - certificate_check: bool, - first_byte_timeout: Duration, - ) -> Result> { - let (scheme, host, port) = Self::parse_origin(origin_url)?; - - let (name, _) = BackendConfig::new(&scheme, &host) - .port(port) - .certificate_check(certificate_check) - .first_byte_timeout(first_byte_timeout) - .compute_name()?; - - Ok(name) - } } #[cfg(test)] diff --git a/crates/trusted-server-adapter-fastly/src/compat.rs b/crates/trusted-server-adapter-fastly/src/compat.rs new file mode 100644 index 00000000..64a7f3b1 --- /dev/null +++ b/crates/trusted-server-adapter-fastly/src/compat.rs @@ -0,0 +1,82 @@ +//! Compatibility bridge between `fastly` SDK types and `http` crate types. +//! +//! Contains only the three functions used by the legacy `main()` entry point. +//! Relocated from `trusted-server-core` in PR 15 as part of removing all +//! `fastly` crate imports from the core library. + +use edgezero_core::body::Body as EdgeBody; +use edgezero_core::http::{Request as HttpRequest, RequestBuilder, Response as HttpResponse, Uri}; + +/// Forwarded headers that clients can inject to spoof request context. +/// +/// Inlined from `trusted_server_core::http_util` which is `pub(crate)`. +const SPOOFABLE_FORWARDED_HEADERS: &[&str] = &[ + "forwarded", + "x-forwarded-host", + "x-forwarded-proto", + "fastly-ssl", +]; + +fn build_http_request(req: &fastly::Request, body: EdgeBody) -> HttpRequest { + let uri: Uri = req + .get_url_str() + .parse() + .expect("should parse fastly request URL as URI"); + + let mut builder: RequestBuilder = edgezero_core::http::request_builder() + .method(req.get_method().clone()) + .uri(uri); + + for (name, value) in req.get_headers() { + builder = builder.header(name.as_str(), value.as_bytes()); + } + + builder + .body(body) + .expect("should build http request from fastly request") +} + +/// Convert an owned `fastly::Request` into an [`HttpRequest`]. +/// +/// # Panics +/// +/// Panics if the Fastly request URL cannot be parsed as an `http::Uri`. +pub(crate) fn from_fastly_request(mut req: fastly::Request) -> HttpRequest { + let body = EdgeBody::from(req.take_body_bytes()); + build_http_request(&req, body) +} + +/// Convert an [`HttpResponse`] into a `fastly::Response`. +pub(crate) fn to_fastly_response(resp: HttpResponse) -> fastly::Response { + let (parts, body) = resp.into_parts(); + let mut fastly_resp = fastly::Response::from_status(parts.status.as_u16()); + for (name, value) in &parts.headers { + fastly_resp.append_header(name.as_str(), value.as_bytes()); + } + + match body { + EdgeBody::Once(bytes) => { + if !bytes.is_empty() { + fastly_resp.set_body(bytes.to_vec()); + } + } + EdgeBody::Stream(_) => { + log::warn!("streaming body in compat::to_fastly_response; body will be empty"); + } + } + + fastly_resp +} + +/// Sanitize forwarded headers on a `fastly::Request`. +/// +/// Strips headers that clients can spoof before any request-derived context +/// is built or the request is converted to core HTTP types. +pub(crate) fn sanitize_fastly_forwarded_headers(req: &mut fastly::Request) { + for &name in SPOOFABLE_FORWARDED_HEADERS { + if req.get_header(name).is_some() { + log::debug!("Stripped spoofable header: {name}"); + req.remove_header(name); + } + } +} diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 0819ee0e..e7fb4339 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -9,7 +9,6 @@ use fastly::{Error, Request as FastlyRequest, Response as FastlyResponse}; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{build_orchestrator, AuctionOrchestrator}; use trusted_server_core::auth::enforce_basic_auth; -use trusted_server_core::compat; use trusted_server_core::constants::{ ENV_FASTLY_IS_STAGING, ENV_FASTLY_SERVICE_VERSION, HEADER_X_GEO_INFO_AVAILABLE, HEADER_X_TS_ENV, HEADER_X_TS_VERSION, @@ -31,13 +30,15 @@ use trusted_server_core::settings::Settings; use trusted_server_core::settings_data::get_settings; mod app; +mod backend; +mod compat; mod error; mod logging; mod management_api; mod middleware; mod platform; -use crate::app::TrustedServerApp; +use crate::app::{open_consent_kv, TrustedServerApp}; use crate::error::to_error_response; use crate::platform::{build_runtime_services, open_kv_store, UnavailableKvStore}; use edgezero_core::app::Hooks as _; @@ -236,7 +237,17 @@ async fn route_request( // Unified auction endpoint (returns creative HTML inline) (Method::POST, "/auction") => { - handle_auction(settings, orchestrator, runtime_services, req).await + let consent_kv = open_consent_kv(&settings.consent); + handle_auction( + settings, + orchestrator, + runtime_services, + consent_kv + .as_ref() + .map(|kv| kv as &dyn trusted_server_core::consent::kv::ConsentKvOps), + req, + ) + .await } // tsjs endpoints @@ -268,7 +279,21 @@ async fn route_request( path ); - handle_publisher_request(settings, integration_registry, runtime_services, req).await + let consent_kv = settings + .consent + .consent_store + .as_deref() + .and_then(crate::platform::FastlyConsentKvStore::open); + handle_publisher_request( + settings, + integration_registry, + runtime_services, + consent_kv + .as_ref() + .map(|kv| kv as &dyn trusted_server_core::consent::kv::ConsentKvOps), + req, + ) + .await } } } diff --git a/crates/trusted-server-adapter-fastly/src/management_api.rs b/crates/trusted-server-adapter-fastly/src/management_api.rs index a436430b..92e37e05 100644 --- a/crates/trusted-server-adapter-fastly/src/management_api.rs +++ b/crates/trusted-server-adapter-fastly/src/management_api.rs @@ -23,6 +23,7 @@ use fastly::http::StatusCode; use fastly::{Request, Response}; use trusted_server_core::platform::{PlatformError, PlatformSecretStore, StoreName}; +use crate::backend::BackendConfig; use crate::platform::FastlyPlatformSecretStore; const FASTLY_API_HOST: &str = "https://api.fastly.com"; @@ -52,8 +53,6 @@ impl FastlyManagementApiClient { /// be registered, or [`PlatformError::SecretStore`] if the API key cannot /// be read. pub(crate) fn new() -> Result> { - use trusted_server_core::backend::BackendConfig; - let backend_name = BackendConfig::from_url(FASTLY_API_HOST, true) .change_context(PlatformError::Backend) .attach("failed to register Fastly management API backend")?; diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index 8d25098f..8575ba53 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -11,11 +11,10 @@ use std::sync::Arc; use edgezero_adapter_fastly::key_value_store::FastlyKvStore; use edgezero_core::key_value_store::KvError; use error_stack::{Report, ResultExt}; -use fastly::geo::geo_lookup; +use fastly::geo::{geo_lookup, Geo}; use fastly::{ConfigStore, Request, SecretStore}; -use trusted_server_core::backend::BackendConfig; -use trusted_server_core::geo::geo_from_fastly; +use crate::backend::BackendConfig; pub(crate) use trusted_server_core::platform::UnavailableKvStore; use trusted_server_core::platform::{ ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, @@ -32,7 +31,7 @@ use trusted_server_core::platform::{ /// /// Stateless — the store name is supplied per call, matching the trait /// signature. This replaces the store-name-at-construction pattern of -/// [`trusted_server_core::storage::FastlyConfigStore`]. +/// the legacy `FastlyConfigStore` (removed). /// /// # Write cost /// @@ -82,8 +81,8 @@ impl PlatformConfigStore for FastlyPlatformConfigStore { /// Fastly [`SecretStore`]-backed implementation of [`PlatformSecretStore`]. /// /// Stateless — the store name is supplied per call. This replaces the -/// store-name-at-construction pattern of -/// [`trusted_server_core::storage::FastlySecretStore`]. +/// store-name-at-construction pattern of the legacy `FastlySecretStore` +/// (removed). /// /// # Write cost /// @@ -349,10 +348,23 @@ impl PlatformHttpClient for FastlyPlatformHttpClient { // FastlyPlatformGeo // --------------------------------------------------------------------------- -/// Fastly geo-lookup implementation of [`PlatformGeo`]. +/// Convert a Fastly [`Geo`] value into a platform-neutral [`GeoInfo`]. /// -/// Uses [`geo_from_fastly`] from `trusted_server_core::geo` to avoid -/// duplicating the field-mapping logic present in `GeoInfo::from_request`. +/// Shared by `FastlyPlatformGeo::lookup` in `trusted-server-adapter-fastly` so +/// that field mapping is never duplicated. +fn geo_from_fastly(geo: &Geo) -> GeoInfo { + GeoInfo { + city: geo.city().to_string(), + country: geo.country_code().to_string(), + continent: format!("{:?}", geo.continent()), + latitude: geo.latitude(), + longitude: geo.longitude(), + metro_code: geo.metro_code(), + region: geo.region().map(str::to_string), + } +} + +/// Fastly geo-lookup implementation of [`PlatformGeo`]. pub struct FastlyPlatformGeo; impl PlatformGeo for FastlyPlatformGeo { @@ -363,6 +375,89 @@ impl PlatformGeo for FastlyPlatformGeo { } } +// --------------------------------------------------------------------------- +// FastlyConsentKvStore +// --------------------------------------------------------------------------- + +/// Fastly KV Store–backed implementation of [`trusted_server_core::consent::kv::ConsentKvOps`]. +/// +/// Uses the synchronous Fastly KV Store API so it is compatible with the +/// non-async consent pipeline ([`trusted_server_core::consent::build_consent_context`]). +pub struct FastlyConsentKvStore { + store: fastly::kv_store::KVStore, +} + +impl FastlyConsentKvStore { + /// Open a Fastly KV Store by name for consent persistence. + /// + /// Returns `None` when the store does not exist or cannot be opened; + /// a warning is logged in that case. Callers should treat `None` as "KV + /// disabled" and pass `kv_ops: None` to the consent pipeline. + #[must_use] + pub fn open(store_name: &str) -> Option { + match fastly::kv_store::KVStore::open(store_name) { + Ok(Some(store)) => Some(Self { store }), + Ok(None) => { + log::warn!("Consent KV store '{store_name}' not found"); + None + } + Err(e) => { + log::warn!("Failed to open consent KV store '{store_name}': {e}"); + None + } + } + } +} + +impl trusted_server_core::consent::kv::ConsentKvOps for FastlyConsentKvStore { + fn load_entry(&self, key: &str) -> Option { + let mut response = match self.store.lookup(key) { + Ok(resp) => resp, + Err(fastly::kv_store::KVStoreError::ItemNotFound) => return None, + Err(e) => { + log::debug!("Consent KV lookup miss for '{key}': {e}"); + return None; + } + }; + let bytes = response.take_body_bytes(); + match serde_json::from_slice(&bytes) { + Ok(entry) => Some(entry), + Err(e) => { + log::warn!("Failed to deserialize consent KV entry for '{key}': {e}"); + None + } + } + } + + fn save_entry_with_ttl( + &self, + key: &str, + entry: &trusted_server_core::consent::kv::KvConsentEntry, + ttl: std::time::Duration, + ) { + let Ok(body) = serde_json::to_string(entry) else { + log::warn!("Failed to serialize consent entry for '{key}'"); + return; + }; + match self + .store + .build_insert() + .time_to_live(ttl) + .execute(key, body) + { + Ok(()) => log::info!("Saved consent to KV store for '{key}'"), + Err(e) => log::warn!("Failed to write consent to KV store for '{key}': {e}"), + } + } + + fn delete_entry(&self, key: &str) { + match self.store.delete(key) { + Ok(()) => log::info!("Deleted consent KV entry for '{key}' (consent revoked)"), + Err(e) => log::warn!("Failed to delete consent KV entry for '{key}': {e}"), + } + } +} + // --------------------------------------------------------------------------- // Entry-point helper // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-core/Cargo.toml b/crates/trusted-server-core/Cargo.toml index a85261d7..e91a7ecf 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -22,7 +22,6 @@ config = { workspace = true } cookie = { workspace = true } derive_more = { workspace = true } error-stack = { workspace = true } -fastly = { workspace = true } flate2 = { workspace = true } futures = { workspace = true } handlebars = { workspace = true } @@ -42,7 +41,6 @@ serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } subtle = { workspace = true } -tokio = { workspace = true } toml = { workspace = true } trusted-server-js = { path = "../js" } trusted-server-openrtb = { path = "../openrtb" } @@ -53,6 +51,13 @@ validator = { workspace = true } ed25519-dalek = { workspace = true } edgezero-core = { workspace = true } +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] +# Enable JS-backed RNG for `wasm32-unknown-unknown` targets (e.g. Cloudflare Workers). +# The Fastly adapter uses `wasm32-wasip1`, which has native POSIX RNG and does not +# need these — this block is intentionally scoped to `target_os = "unknown"` only. +getrandom = { version = "0.2", features = ["js"] } +uuid = { workspace = true, features = ["js"] } + [build-dependencies] config = { workspace = true } derive_more = { workspace = true } @@ -73,7 +78,6 @@ default = [] criterion = { workspace = true } edgezero-core = { workspace = true, features = ["test-utils"] } temp-env = { workspace = true } -tokio-test = { workspace = true } [[bench]] name = "consent_decode" diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 28723da9..e30f3b8f 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -6,6 +6,7 @@ use http::{Request, Response}; use crate::auction::formats::AdRequest; use crate::consent; +use crate::consent::kv::ConsentKvOps; use crate::cookies::handle_request_cookies; use crate::error::TrustedServerError; use crate::integrations::{collect_body_bounded, INTEGRATION_MAX_BODY_BYTES}; @@ -34,6 +35,7 @@ pub async fn handle_auction( settings: &Settings, orchestrator: &AuctionOrchestrator, services: &RuntimeServices, + kv_ops: Option<&dyn ConsentKvOps>, req: Request, ) -> Result, Report> { let (parts, body) = req.into_parts(); @@ -78,6 +80,7 @@ pub async fn handle_auction( config: &settings.consent, geo: geo.as_ref(), synthetic_id: Some(synthetic_id.as_str()), + kv_ops, }); // Convert tsjs request format to auction request @@ -119,3 +122,158 @@ pub async fn handle_auction( // Convert to OpenRTB response format with inline creative HTML convert_to_openrtb_response(&result, settings, &auction_request) } + +#[cfg(test)] +mod tests { + use std::collections::{HashMap, HashSet}; + use std::sync::Mutex; + use std::time::Duration; + + use edgezero_core::body::Body as EdgeBody; + use http::header; + use http::Request; + use serde_json::json; + + use super::handle_auction; + use crate::auction::AuctionOrchestrator; + use crate::auction_config_types::AuctionConfig; + use crate::consent::kv::{ConsentKvOps, KvConsentEntry}; + use crate::platform::test_support::noop_services; + use crate::test_support::tests::create_test_settings; + + #[derive(Default)] + struct StubConsentKvOps { + loads: Mutex>, + saves: Mutex>, + } + + impl StubConsentKvOps { + fn load_keys(&self) -> Vec { + self.loads.lock().expect("should lock load keys").clone() + } + + fn saved_entries(&self) -> HashMap { + self.saves + .lock() + .expect("should lock saved entries") + .clone() + } + } + + impl ConsentKvOps for StubConsentKvOps { + fn load_entry(&self, key: &str) -> Option { + self.loads + .lock() + .expect("should lock load keys") + .push(key.to_string()); + None + } + + fn save_entry_with_ttl(&self, key: &str, entry: &KvConsentEntry, _ttl: Duration) { + self.saves + .lock() + .expect("should lock saved entries") + .insert(key.to_string(), entry.clone()); + } + + fn delete_entry(&self, _key: &str) {} + } + + fn no_providers_orchestrator() -> AuctionOrchestrator { + AuctionOrchestrator::new(AuctionConfig { + enabled: true, + providers: Vec::new(), + mediator: None, + timeout_ms: 50, + creative_store: "creative_store".to_string(), + allowed_context_keys: HashSet::new(), + }) + } + + fn build_auction_request(cookie_header: Option<&str>) -> Request { + let mut req = Request::builder() + .method("POST") + .uri("https://publisher.example/auction") + .header(header::CONTENT_TYPE, "application/json") + .body(EdgeBody::from( + serde_json::to_vec(&json!({ + "adUnits": [{ + "code": "slot-1", + "mediaTypes": { + "banner": { + "sizes": [[300, 250]] + } + } + }] + })) + .expect("should serialize auction request body"), + )) + .expect("should build auction request"); + + if let Some(cookie_header) = cookie_header { + req.headers_mut().insert( + header::COOKIE, + header::HeaderValue::from_str(cookie_header).expect("should build cookie header"), + ); + } + + req + } + + #[test] + fn handle_auction_attempts_kv_fallback_when_cookie_signals_are_absent() { + let settings = create_test_settings(); + let orchestrator = no_providers_orchestrator(); + let kv = StubConsentKvOps::default(); + + let err = futures::executor::block_on(handle_auction( + &settings, + &orchestrator, + &noop_services(), + Some(&kv), + build_auction_request(None), + )) + .expect_err("should fail later because no providers are configured"); + + let _ = err; + assert_eq!( + kv.load_keys().len(), + 1, + "should try loading consent from KV when request has no cookie signals" + ); + } + + #[test] + fn handle_auction_persists_cookie_consent_to_kv() { + let settings = create_test_settings(); + let orchestrator = no_providers_orchestrator(); + let kv = StubConsentKvOps::default(); + + let err = futures::executor::block_on(handle_auction( + &settings, + &orchestrator, + &noop_services(), + Some(&kv), + build_auction_request(Some("euconsent-v2=CPXxGfAPXxGfA")), + )) + .expect_err("should fail later because no providers are configured"); + + let _ = err; + + let saved_entries = kv.saved_entries(); + assert_eq!( + saved_entries.len(), + 1, + "should persist cookie-sourced consent to KV before auction execution" + ); + let entry = saved_entries + .values() + .next() + .expect("should have a saved consent entry"); + assert_eq!( + entry.raw_tc_string.as_deref(), + Some("CPXxGfAPXxGfA"), + "should write the raw TC string from cookies into the KV entry" + ); + } +} diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 4cd50f0c..e1d27b30 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -767,43 +767,45 @@ mod tests { // of requiring real platform backends. An `#[ignore]` integration test // exercising the full path via Viceroy would also catch regressions. - #[tokio::test] - async fn test_no_providers_configured() { - let config = AuctionConfig { - enabled: true, - providers: vec![], - mediator: None, - timeout_ms: 2000, - creative_store: "creative_store".to_string(), - allowed_context_keys: HashSet::from(["permutive_segments".to_string()]), - }; - - let orchestrator = AuctionOrchestrator::new(config); + #[test] + fn test_no_providers_configured() { + futures::executor::block_on(async { + let config = AuctionConfig { + enabled: true, + providers: vec![], + mediator: None, + timeout_ms: 2000, + creative_store: "creative_store".to_string(), + allowed_context_keys: HashSet::from(["permutive_segments".to_string()]), + }; - let request = create_test_auction_request(); - let settings = create_test_settings(); - let req = http::Request::builder() - .method(http::Method::GET) - .uri("https://test.com/test") - .body(edgezero_core::body::Body::empty()) - .expect("should build request"); - let context = create_test_context( - &settings, - &req, - &crate::platform::ClientInfo { - client_ip: None, - tls_protocol: None, - tls_cipher: None, - }, - ); + let orchestrator = AuctionOrchestrator::new(config); + + let request = create_test_auction_request(); + let settings = create_test_settings(); + let req = http::Request::builder() + .method(http::Method::GET) + .uri("https://test.com/test") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let context = create_test_context( + &settings, + &req, + &crate::platform::ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }, + ); - let result = orchestrator - .run_auction(&request, &context, &noop_services()) - .await; + let result = orchestrator + .run_auction(&request, &context, &noop_services()) + .await; - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(format!("{}", err).contains("No providers configured")); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(format!("{}", err).contains("No providers configured")); + }); } #[test] diff --git a/crates/trusted-server-core/src/auction/provider.rs b/crates/trusted-server-core/src/auction/provider.rs index 5f4b8234..d0ca86a3 100644 --- a/crates/trusted-server-core/src/auction/provider.rs +++ b/crates/trusted-server-core/src/auction/provider.rs @@ -67,8 +67,8 @@ pub trait AuctionProvider: Send + Sync { /// /// `timeout_ms` is the effective timeout that will be used when the backend /// is registered in [`request_bids`](Self::request_bids). It must be - /// forwarded to [`crate::backend::BackendConfig::backend_name_for_url`] so the predicted - /// name matches the actual registration (the timeout is part of the name). + /// forwarded to `predict_backend_name_for_url` so the predicted name matches + /// the actual registration (the timeout is part of the name). fn backend_name(&self, _timeout_ms: u32) -> Option { None } diff --git a/crates/trusted-server-core/src/compat.rs b/crates/trusted-server-core/src/compat.rs deleted file mode 100644 index e249e936..00000000 --- a/crates/trusted-server-core/src/compat.rs +++ /dev/null @@ -1,630 +0,0 @@ -//! Compatibility bridge between `fastly` SDK types and `http` crate types. -//! -//! All items in this module are temporary scaffolding created in PR 11 and -//! scheduled for deletion in PR 15. Do not add new callers after PR 13. -//! -//! # PR 15 removal target - -use edgezero_core::body::Body as EdgeBody; -use fastly::http::header; - -use crate::constants::INTERNAL_HEADERS; -use crate::http_util::SPOOFABLE_FORWARDED_HEADERS; - -fn build_http_request(req: &fastly::Request, body: EdgeBody) -> http::Request { - let uri: http::Uri = req - .get_url_str() - .parse() - .expect("should parse fastly request URL as URI"); - - let mut builder = http::Request::builder() - .method(req.get_method().clone()) - .uri(uri); - - for (name, value) in req.get_headers() { - builder = builder.header(name.as_str(), value.as_bytes()); - } - - builder - .body(body) - .expect("should build http request from fastly request") -} - -/// Convert an owned `fastly::Request` into an `http::Request`. -/// -/// # PR 15 removal target -/// -/// # Panics -/// -/// Panics if the Fastly request URL cannot be parsed as an `http::Uri`. -pub fn from_fastly_request(mut req: fastly::Request) -> http::Request { - let body = EdgeBody::from(req.take_body_bytes()); - build_http_request(&req, body) -} - -/// Convert a borrowed `fastly::Request` into an `http::Request` for reading. -/// -/// Headers are copied; the body is empty. -/// -/// # PR 15 removal target -/// -/// # Panics -/// -/// Panics if the Fastly request URL cannot be parsed as an `http::Uri`. -pub fn from_fastly_headers_ref(req: &fastly::Request) -> http::Request { - build_http_request(req, EdgeBody::empty()) -} - -/// Convert an `http::Request` into a `fastly::Request`. -/// -/// # PR 15 removal target -pub fn to_fastly_request(req: http::Request) -> fastly::Request { - let (parts, body) = req.into_parts(); - let mut fastly_req = fastly::Request::new(parts.method, parts.uri.to_string()); - for (name, value) in &parts.headers { - fastly_req.append_header(name.as_str(), value.as_bytes()); - } - - match body { - EdgeBody::Once(bytes) => { - if !bytes.is_empty() { - fastly_req.set_body(bytes.to_vec()); - } - } - EdgeBody::Stream(_) => { - log::warn!("streaming body in compat::to_fastly_request; body will be empty"); - } - } - - fastly_req -} - -/// Convert a borrowed `http::Request` into a `fastly::Request`. -/// -/// Headers, method, and URI are copied; the body is empty. -/// -/// # PR 15 removal target -pub fn to_fastly_request_ref(req: &http::Request) -> fastly::Request { - let mut fastly_req = fastly::Request::new(req.method().clone(), req.uri().to_string()); - for (name, value) in req.headers() { - fastly_req.set_header(name.as_str(), value.as_bytes()); - } - - fastly_req -} - -/// Convert a `fastly::Response` into an `http::Response`. -/// -/// # PR 15 removal target -/// -/// # Panics -/// -/// Panics if the copied Fastly response parts cannot form a valid -/// `http::Response`. -pub fn from_fastly_response(mut resp: fastly::Response) -> http::Response { - let status = resp.get_status(); - let mut builder = http::Response::builder().status(status); - for (name, value) in resp.get_headers() { - builder = builder.header(name.as_str(), value.as_bytes()); - } - - builder - .body(EdgeBody::from(resp.take_body_bytes())) - .expect("should build http response from fastly response") -} - -/// Convert an `http::Response` into a `fastly::Response`. -/// -/// # PR 15 removal target -pub fn to_fastly_response(resp: http::Response) -> fastly::Response { - let (parts, body) = resp.into_parts(); - let mut fastly_resp = fastly::Response::from_status(parts.status.as_u16()); - for (name, value) in &parts.headers { - fastly_resp.append_header(name.as_str(), value.as_bytes()); - } - - match body { - EdgeBody::Once(bytes) => { - if !bytes.is_empty() { - fastly_resp.set_body(bytes.to_vec()); - } - } - EdgeBody::Stream(_) => { - log::warn!("streaming body in compat::to_fastly_response; body will be empty"); - } - } - - fastly_resp -} - -/// Sanitize forwarded headers on a `fastly::Request`. -/// -/// # PR 15 removal target -pub fn sanitize_fastly_forwarded_headers(req: &mut fastly::Request) { - for &name in SPOOFABLE_FORWARDED_HEADERS { - if req.get_header(name).is_some() { - log::debug!("Stripped spoofable header: {name}"); - req.remove_header(name); - } - } -} - -/// Copy `X-*` custom headers between two `fastly::Request` values. -/// -/// # PR 15 removal target -pub fn copy_fastly_custom_headers(from: &fastly::Request, to: &mut fastly::Request) { - for (name, value) in from.get_headers() { - let name_str = name.as_str(); - if name_str.starts_with("x-") && !INTERNAL_HEADERS.contains(&name_str) { - to.append_header(name_str, value); - } - } -} - -/// Forward the `Cookie` header from one `fastly::Request` to another. -/// -/// # PR 15 removal target -pub fn forward_fastly_cookie_header( - from: &fastly::Request, - to: &mut fastly::Request, - strip_consent: bool, -) { - use crate::cookies::{strip_cookies, CONSENT_COOKIE_NAMES}; - - let Some(cookie_value) = from.get_header(header::COOKIE) else { - return; - }; - - if !strip_consent { - to.set_header(header::COOKIE, cookie_value); - return; - } - - match cookie_value.to_str() { - Ok(value) => { - let stripped = strip_cookies(value, CONSENT_COOKIE_NAMES); - if !stripped.is_empty() { - to.set_header(header::COOKIE, &stripped); - } - } - Err(_) => { - to.set_header(header::COOKIE, cookie_value); - } - } -} - -/// Set the synthetic ID cookie on a `fastly::Response`. -/// -/// # PR 15 removal target -pub fn set_fastly_synthetic_cookie( - settings: &crate::settings::Settings, - response: &mut fastly::Response, - synthetic_id: &str, -) { - if !crate::cookies::synthetic_id_cookie_value_is_safe(synthetic_id) { - log::warn!( - "Rejecting synthetic_id for Set-Cookie: value of {} bytes contains characters illegal in a cookie value", - synthetic_id.len() - ); - return; - } - - response.append_header( - header::SET_COOKIE, - crate::cookies::create_synthetic_cookie(settings, synthetic_id), - ); -} - -/// Expire the synthetic ID cookie on a `fastly::Response`. -/// -/// # PR 15 removal target -pub fn expire_fastly_synthetic_cookie( - settings: &crate::settings::Settings, - response: &mut fastly::Response, -) { - response.append_header( - header::SET_COOKIE, - crate::cookies::create_synthetic_id_expiry_cookie(settings), - ); -} - -#[cfg(test)] -mod tests { - use super::*; - - fn assert_once_body_eq(body: EdgeBody, expected: &[u8]) { - match body { - EdgeBody::Once(bytes) => assert_eq!(bytes.as_ref(), expected, "should copy body bytes"), - EdgeBody::Stream(_) => panic!("expected non-streaming body"), - } - } - - #[test] - fn from_fastly_request_copies_body() { - let mut fastly_req = - fastly::Request::new(fastly::http::Method::POST, "https://example.com/path"); - fastly_req.set_header("content-type", "application/json"); - fastly_req.set_body(r#"{"ok":true}"#); - - let http_req = from_fastly_request(fastly_req); - let (parts, body) = http_req.into_parts(); - - assert_eq!(parts.method, http::Method::POST, "should copy method"); - assert_eq!(parts.uri.path(), "/path", "should copy uri path"); - assert_eq!( - parts - .headers - .get("content-type") - .and_then(|v| v.to_str().ok()), - Some("application/json"), - "should copy headers" - ); - assert_once_body_eq(body, br#"{"ok":true}"#); - } - - #[test] - fn from_fastly_headers_ref_copies_headers() { - let mut fastly_req = - fastly::Request::new(fastly::http::Method::GET, "https://example.com/path"); - fastly_req.set_header("x-custom", "value"); - - let http_req = from_fastly_headers_ref(&fastly_req); - - assert_eq!(http_req.uri().path(), "/path", "should copy path"); - assert_eq!( - http_req - .headers() - .get("x-custom") - .and_then(|v| v.to_str().ok()), - Some("value"), - "should copy custom header" - ); - } - - #[test] - fn from_fastly_headers_ref_preserves_duplicate_headers() { - let mut fastly_req = - fastly::Request::new(fastly::http::Method::GET, "https://example.com/path"); - fastly_req.append_header("x-custom", "first"); - fastly_req.append_header("x-custom", "second"); - - let http_req = from_fastly_headers_ref(&fastly_req); - let values: Vec<_> = http_req - .headers() - .get_all("x-custom") - .iter() - .map(|value| value.to_str().expect("should be valid utf8")) - .collect(); - - assert_eq!( - values, - vec!["first", "second"], - "should preserve duplicates" - ); - } - - #[test] - fn from_fastly_headers_ref_body_is_empty() { - let fastly_req = fastly::Request::new(fastly::http::Method::POST, "https://example.com/"); - - let http_req = from_fastly_headers_ref(&fastly_req); - - assert_eq!(http_req.method(), http::Method::POST, "should copy method"); - assert_once_body_eq(http_req.into_body(), b""); - } - - #[test] - fn to_fastly_request_copies_headers_and_body() { - let http_req = http::Request::builder() - .method(http::Method::POST) - .uri("https://example.com/submit") - .header("x-custom", "value") - .body(EdgeBody::from(b"payload".as_ref())) - .expect("should build request"); - - let mut fastly_req = to_fastly_request(http_req); - - assert_eq!( - fastly_req.get_method(), - &fastly::http::Method::POST, - "should copy method" - ); - assert_eq!( - fastly_req - .get_header("x-custom") - .and_then(|v| v.to_str().ok()), - Some("value"), - "should copy headers" - ); - assert_eq!( - fastly_req.take_body_bytes().as_slice(), - b"payload", - "should copy body bytes" - ); - } - - #[test] - fn to_fastly_request_preserves_duplicate_headers() { - let http_req = http::Request::builder() - .method(http::Method::GET) - .uri("https://example.com/") - .header("x-custom", "first") - .header("x-custom", "second") - .body(EdgeBody::empty()) - .expect("should build request"); - - let fastly_req = to_fastly_request(http_req); - - let values: Vec<_> = fastly_req - .get_headers() - .filter(|(name, _)| name.as_str() == "x-custom") - .map(|(_, value)| value.to_str().expect("should be valid utf8")) - .collect(); - assert_eq!( - values, - vec!["first", "second"], - "should preserve duplicate headers" - ); - } - - #[test] - fn from_fastly_response_copies_status_headers_and_body() { - let mut fastly_resp = fastly::Response::from_status(202); - fastly_resp.set_header("content-type", "application/json"); - fastly_resp.set_body(r#"{"ok":true}"#); - - let http_resp = from_fastly_response(fastly_resp); - let (parts, body) = http_resp.into_parts(); - - assert_eq!(parts.status.as_u16(), 202, "should copy status"); - assert_eq!( - parts - .headers - .get("content-type") - .and_then(|v| v.to_str().ok()), - Some("application/json"), - "should copy headers" - ); - assert_once_body_eq(body, br#"{"ok":true}"#); - } - - #[test] - fn to_fastly_response_copies_status_and_headers() { - let http_resp = http::Response::builder() - .status(201) - .header("content-type", "application/json") - .body(EdgeBody::from(b"{}".as_ref())) - .expect("should build response"); - - let fastly_resp = to_fastly_response(http_resp); - - assert_eq!(fastly_resp.get_status().as_u16(), 201, "should copy status"); - assert!( - fastly_resp.get_header("content-type").is_some(), - "should copy content-type header" - ); - } - - #[test] - fn to_fastly_request_ref_copies_method_uri_and_headers_without_body() { - let http_req = http::Request::builder() - .method(http::Method::POST) - .uri("https://example.com/path?q=1") - .header("x-custom", "value") - .body(EdgeBody::from(b"payload".as_ref())) - .expect("should build request"); - - let mut fastly_req = to_fastly_request_ref(&http_req); - - assert_eq!( - fastly_req.get_method(), - &fastly::http::Method::POST, - "should copy method" - ); - assert_eq!( - fastly_req.get_url_str(), - "https://example.com/path?q=1", - "should copy URI" - ); - assert_eq!( - fastly_req - .get_header("x-custom") - .and_then(|v| v.to_str().ok()), - Some("value"), - "should copy headers" - ); - assert!( - fastly_req.take_body_bytes().is_empty(), - "borrowed conversion should not copy body bytes" - ); - } - - #[test] - fn sanitize_fastly_forwarded_headers_strips_spoofable() { - let mut req = fastly::Request::new(fastly::http::Method::GET, "https://example.com"); - req.set_header("forwarded", "host=evil.com"); - req.set_header("x-forwarded-host", "evil.com"); - req.set_header("x-forwarded-proto", "https"); - req.set_header("fastly-ssl", "1"); - req.set_header("host", "legit.example.com"); - - sanitize_fastly_forwarded_headers(&mut req); - - assert!( - req.get_header("forwarded").is_none(), - "should strip Forwarded" - ); - assert!( - req.get_header("x-forwarded-host").is_none(), - "should strip X-Forwarded-Host" - ); - assert!( - req.get_header("x-forwarded-proto").is_none(), - "should strip X-Forwarded-Proto" - ); - assert!( - req.get_header("fastly-ssl").is_none(), - "should strip Fastly-SSL" - ); - assert_eq!( - req.get_header("host").and_then(|v| v.to_str().ok()), - Some("legit.example.com"), - "should preserve Host" - ); - } - - #[test] - fn forward_fastly_cookie_header_strips_consent() { - let mut from_req = fastly::Request::new(fastly::http::Method::GET, "https://example.com"); - from_req.set_header(header::COOKIE, "euconsent-v2=BOE; session=abc"); - let mut to_req = fastly::Request::new(fastly::http::Method::GET, "https://partner.com"); - - forward_fastly_cookie_header(&from_req, &mut to_req, true); - - let forwarded = to_req - .get_header(header::COOKIE) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - assert!( - !forwarded.contains("euconsent-v2"), - "should strip consent cookie" - ); - assert!( - forwarded.contains("session=abc"), - "should keep non-consent cookie" - ); - } - - #[test] - fn copy_fastly_custom_headers_filters_internal() { - let mut from_req = fastly::Request::new(fastly::http::Method::GET, "https://example.com"); - from_req.set_header("x-custom-data", "present"); - from_req.set_header("x-synthetic-id", "should-not-copy"); - let mut to_req = fastly::Request::new(fastly::http::Method::GET, "https://partner.com"); - - copy_fastly_custom_headers(&from_req, &mut to_req); - - assert_eq!( - to_req - .get_header("x-custom-data") - .and_then(|v| v.to_str().ok()), - Some("present"), - "should copy arbitrary x-header" - ); - assert!( - to_req.get_header("x-synthetic-id").is_none(), - "should not copy internal header" - ); - } - - #[test] - fn copy_fastly_custom_headers_preserves_duplicate_values() { - let mut from_req = fastly::Request::new(fastly::http::Method::GET, "https://example.com"); - from_req.append_header("x-custom-data", "first"); - from_req.append_header("x-custom-data", "second"); - let mut to_req = fastly::Request::new(fastly::http::Method::GET, "https://partner.com"); - - copy_fastly_custom_headers(&from_req, &mut to_req); - - let values: Vec<_> = to_req - .get_headers() - .filter(|(name, _)| name.as_str() == "x-custom-data") - .map(|(_, value)| value.to_str().expect("should be valid utf8")) - .collect(); - assert_eq!( - values, - vec!["first", "second"], - "should preserve duplicates" - ); - } - - #[test] - fn set_fastly_synthetic_cookie_sets_cookie_header() { - let settings = crate::test_support::tests::create_test_settings(); - let mut response = fastly::Response::new(); - - set_fastly_synthetic_cookie(&settings, &mut response, "abc123.XyZ789"); - - let cookie = response - .get_header(header::SET_COOKIE) - .and_then(|value| value.to_str().ok()) - .map(str::to_owned); - assert_eq!( - cookie, - Some(format!( - "synthetic_id=abc123.XyZ789; Domain={}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=31536000", - settings.publisher.cookie_domain - )), - "should set expected synthetic cookie" - ); - } - - #[test] - fn expire_fastly_synthetic_cookie_sets_expiry_cookie() { - let settings = crate::test_support::tests::create_test_settings(); - let mut response = fastly::Response::new(); - - expire_fastly_synthetic_cookie(&settings, &mut response); - - let cookie = response - .get_header(header::SET_COOKIE) - .and_then(|value| value.to_str().ok()) - .map(str::to_owned); - assert_eq!( - cookie, - Some(format!( - "synthetic_id=; Domain={}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0", - settings.publisher.cookie_domain - )), - "should set expected expiry cookie" - ); - } - - #[test] - fn to_fastly_request_with_streaming_body_produces_empty_body() { - // Stream bodies cannot cross the compat boundary: the Fastly SDK has no - // streaming body API, so the shim drops the stream and logs a warning. - // This test pins that silent-drop behaviour so it cannot become - // accidentally load-bearing. (Removal target: PR 15.) - let body = EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::from_static( - b"data", - )])); - let http_req = http::Request::builder() - .method(http::Method::POST) - .uri("https://example.com/") - .body(body) - .expect("should build request"); - - let mut fastly_req = to_fastly_request(http_req); - - assert!( - fastly_req.take_body_bytes().is_empty(), - "streaming body should be silently dropped; compat shim produces empty body" - ); - } - - #[test] - fn to_fastly_response_with_streaming_body_produces_empty_body() { - // Same constraint as to_fastly_request: streaming bodies are dropped at - // the compat boundary. (Removal target: PR 15.) - let body = EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::from_static( - b"data", - )])); - let http_resp = http::Response::builder() - .status(200) - .body(body) - .expect("should build response"); - - let mut fastly_resp = to_fastly_response(http_resp); - - assert_eq!( - fastly_resp.get_status().as_u16(), - 200, - "should copy status code" - ); - assert!( - fastly_resp.take_body_bytes().is_empty(), - "streaming body should be silently dropped; compat shim produces empty body" - ); - } -} diff --git a/crates/trusted-server-core/src/consent/kv.rs b/crates/trusted-server-core/src/consent/kv.rs index 47323af1..af22ef89 100644 --- a/crates/trusted-server-core/src/consent/kv.rs +++ b/crates/trusted-server-core/src/consent/kv.rs @@ -1,21 +1,19 @@ //! KV Store consent persistence. //! -//! Stores and retrieves consent data from a Fastly KV Store, keyed by -//! Synthetic ID. This provides consent continuity for returning users +//! Stores and retrieves consent data from a platform-neutral KV Store, keyed +//! by Synthetic ID. This provides consent continuity for returning users //! whose browsers may not have consent cookies on every request. //! //! # Storage layout //! -//! Each entry uses: -//! - **Body** ([`KvConsentEntry`]) — JSON with raw consent strings and context. -//! - **Metadata** ([`ConsentKvMetadata`]) — compact JSON summary for fast -//! consent status checks and change detection (max 2000 bytes). +//! Each entry is a single JSON body ([`KvConsentEntry`]) containing raw consent +//! strings, context flags, and a compact fingerprint for change detection. //! //! # Change detection //! //! Writes only occur when consent signals have actually changed. //! [`consent_fingerprint`] hashes the raw strings into a compact fingerprint -//! stored in metadata. On the next request, the existing fingerprint is +//! stored inside the body. On the next request, the existing fingerprint is //! compared before writing. use serde::{Deserialize, Serialize}; @@ -30,7 +28,8 @@ use super::types::{ConsentContext, ConsentSource}; /// Consent data stored in the KV Store body. /// -/// Contains the raw consent strings needed to reconstruct a [`ConsentContext`]. +/// Contains the raw consent strings needed to reconstruct a [`ConsentContext`], +/// plus a compact fingerprint used for write-on-change detection. /// Decoded data (TCF, GPP, US Privacy) is not stored — it is re-decoded on /// read to avoid stale decoded state. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -60,31 +59,44 @@ pub struct KvConsentEntry { /// When this entry was stored (deciseconds since Unix epoch). pub stored_at_ds: u64, + + /// SHA-256 fingerprint (first 16 hex chars) of all raw consent signals. + /// + /// Used for write-on-change detection. If the fingerprint of the stored + /// entry equals the fingerprint of the current request's consent signals, + /// no write is needed. + #[serde(skip_serializing_if = "Option::is_none")] + pub fp: Option, } // --------------------------------------------------------------------------- -// KV metadata (compact JSON, max 2000 bytes) +// Platform-neutral KV operations trait // --------------------------------------------------------------------------- -/// Compact consent summary stored in KV Store metadata. +/// Synchronous KV operations required for consent persistence. /// -/// Used for fast consent status checks without reading the full body, -/// and for change detection via the `fingerprint` field. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConsentKvMetadata { - /// SHA-256 fingerprint (first 16 hex chars) of all raw consent strings. +/// Implemented by the platform adapter (e.g., Fastly KV store). Synchronous +/// to remain compatible with the non-async [`super::build_consent_context`] +/// pipeline. +pub trait ConsentKvOps: Send + Sync { + /// Load a consent entry from the KV store. /// - /// Used for write-on-change detection. If the fingerprint matches the - /// current request's consent signals, no write is needed. - pub fp: String, - /// Whether GDPR applies. - pub gdpr: bool, - /// Whether GPC is set. - pub gpc: bool, - /// Whether a US Privacy string is present. - pub usp: bool, - /// Whether a TCF string is present. - pub tcf: bool, + /// Returns `None` on a cache miss or deserialization failure. Errors are + /// logged internally and never propagated — KV failures must not break + /// the request pipeline. + fn load_entry(&self, key: &str) -> Option; + + /// Save a consent entry with a time-to-live. + /// + /// Errors are logged internally and never propagated. + fn save_entry_with_ttl(&self, key: &str, entry: &KvConsentEntry, ttl: std::time::Duration); + + /// Delete a consent entry. + /// + /// Called when consent is revoked (SSC cookie expiry). Errors are logged + /// internally and never propagated — KV failures must not break the + /// request pipeline. + fn delete_entry(&self, key: &str); } // --------------------------------------------------------------------------- @@ -94,7 +106,8 @@ pub struct ConsentKvMetadata { /// Builds a [`KvConsentEntry`] from a [`ConsentContext`]. /// /// Captures only the raw strings and contextual flags. Decoded data is -/// intentionally omitted — it will be re-decoded on read. +/// intentionally omitted — it will be re-decoded on read. The entry includes +/// a fingerprint for write-on-change detection on subsequent requests. #[must_use] pub fn entry_from_context(ctx: &ConsentContext, now_ds: u64) -> KvConsentEntry { KvConsentEntry { @@ -107,18 +120,7 @@ pub fn entry_from_context(ctx: &ConsentContext, now_ds: u64) -> KvConsentEntry { gpc: ctx.gpc, jurisdiction: ctx.jurisdiction.to_string(), stored_at_ds: now_ds, - } -} - -/// Builds a [`ConsentKvMetadata`] from a [`ConsentContext`]. -#[must_use] -pub fn metadata_from_context(ctx: &ConsentContext) -> ConsentKvMetadata { - ConsentKvMetadata { - fp: consent_fingerprint(ctx), - gdpr: ctx.gdpr_applies, - gpc: ctx.gpc, - usp: ctx.raw_us_privacy.is_some(), - tcf: ctx.raw_tc_string.is_some(), + fp: Some(consent_fingerprint(ctx)), } } @@ -220,50 +222,10 @@ fn parse_jurisdiction(s: &str) -> Jurisdiction { } // --------------------------------------------------------------------------- -// KV Store operations +// KV Store operations (platform-neutral) // --------------------------------------------------------------------------- -/// Opens a Fastly KV Store by name, logging a warning on failure. -/// -/// Returns [`None`] if the store does not exist or cannot be opened. -fn open_store(store_name: &str) -> Option { - match fastly::kv_store::KVStore::open(store_name) { - Ok(Some(store)) => Some(store), - Ok(None) => { - log::warn!("Consent KV store '{store_name}' not found"); - None - } - Err(e) => { - log::warn!("Failed to open consent KV store '{store_name}': {e}"); - None - } - } -} - -/// Checks whether the stored consent fingerprint matches the current one. -/// -/// Returns `true` when the stored metadata fingerprint equals `new_fp`, -/// meaning no write is needed. -/// -/// Entries written by older code versions may lack metadata, in which case -/// this returns `false` and the entry will be unconditionally re-written -/// with the current fingerprint (self-healing migration). -fn fingerprint_unchanged( - store: &fastly::kv_store::KVStore, - synthetic_id: &str, - new_fp: &str, -) -> bool { - let stored_fp = store - .lookup(synthetic_id) - .ok() - .and_then(|resp| resp.metadata()) - .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) - .map(|meta| meta.fp); - - stored_fp.as_deref() == Some(new_fp) -} - -/// Loads consent data from the KV Store for a given Synthetic ID. +/// Loads consent data from the KV Store for a given key. /// /// Returns `Some(ConsentContext)` if a valid entry is found, [`None`] if the /// key does not exist or deserialization fails. Errors are logged but never @@ -271,124 +233,46 @@ fn fingerprint_unchanged( /// /// # Arguments /// -/// * `store_name` — The KV Store name (from `consent.consent_store` config). -/// * `synthetic_id` — The Synthetic ID used as the KV Store key. +/// * `kv` — Platform KV implementation for consent operations. +/// * `key` — The Synthetic ID used as the KV Store key. #[must_use] -pub fn load_consent_from_kv(store_name: &str, synthetic_id: &str) -> Option { - let store = open_store(store_name)?; - - let mut response = match store.lookup(synthetic_id) { - Ok(resp) => resp, - Err(e) => { - log::debug!("Consent KV lookup miss for '{synthetic_id}': {e}"); - return None; - } - }; - - let body_bytes = response.take_body_bytes(); - match serde_json::from_slice::(&body_bytes) { - Ok(entry) => { - log::info!( - "Loaded consent from KV store for '{synthetic_id}' (stored_at_ds={})", - entry.stored_at_ds - ); - Some(context_from_entry(&entry)) - } - Err(e) => { - log::warn!("Failed to deserialize consent KV entry for '{synthetic_id}': {e}"); - None - } - } +pub fn load_consent(kv: &dyn ConsentKvOps, key: &str) -> Option { + let entry = kv.load_entry(key)?; + log::info!( + "Loaded consent from KV store for '{key}' (stored_at_ds={})", + entry.stored_at_ds + ); + Some(context_from_entry(&entry)) } /// Saves consent data to the KV Store, writing only when signals have changed. /// -/// Compares the fingerprint of the current consent signals against the -/// stored metadata. If they match, the write is skipped. Otherwise, the -/// entry is written with the configured TTL. +/// Compares the fingerprint of the current consent signals against the stored +/// body. If they match, the write is skipped. Otherwise, the entry is written +/// with the configured TTL. /// /// # Arguments /// -/// * `store_name` — The KV Store name (from `consent.consent_store` config). -/// * `synthetic_id` — The Synthetic ID used as the KV Store key. +/// * `kv` — Platform KV implementation for consent operations. +/// * `key` — The Synthetic ID used as the KV Store key. /// * `ctx` — The current request's consent context. /// * `max_age_days` — TTL for the entry, matching `max_consent_age_days`. -pub fn save_consent_to_kv( - store_name: &str, - synthetic_id: &str, - ctx: &ConsentContext, - max_age_days: u32, -) { +pub fn save_consent(kv: &dyn ConsentKvOps, key: &str, ctx: &ConsentContext, max_age_days: u32) { if ctx.is_empty() { log::debug!("Skipping consent KV write: consent is empty"); return; } - - let Some(store) = open_store(store_name) else { - return; - }; - - let metadata = metadata_from_context(ctx); - - if fingerprint_unchanged(&store, synthetic_id, &metadata.fp) { - log::debug!( - "Consent unchanged for '{synthetic_id}' (fp={}), skipping write", - metadata.fp - ); + let new_fp = consent_fingerprint(ctx); + // Load existing entry once; check fp to skip write when unchanged. + let existing_fp = kv.load_entry(key).and_then(|e| e.fp); + if existing_fp.as_deref() == Some(new_fp.as_str()) { + log::debug!("Consent unchanged for '{key}' (fp={new_fp}), skipping write"); return; } - let entry = entry_from_context(ctx, super::now_deciseconds()); - - let Ok(body) = serde_json::to_string(&entry) else { - log::warn!("Failed to serialize consent entry for '{synthetic_id}'"); - return; - }; - let Ok(meta_str) = serde_json::to_string(&metadata) else { - log::warn!("Failed to serialize consent metadata for '{synthetic_id}'"); - return; - }; - let ttl = std::time::Duration::from_secs(u64::from(max_age_days) * 86_400); - - match store - .build_insert() - .metadata(&meta_str) - .time_to_live(ttl) - .execute(synthetic_id, body) - { - Ok(()) => { - log::info!( - "Saved consent to KV store for '{synthetic_id}' (fp={}, ttl={max_age_days}d)", - metadata.fp - ); - } - Err(e) => { - log::warn!("Failed to write consent to KV store for '{synthetic_id}': {e}"); - } - } -} - -/// Deletes a consent entry from the KV Store for a given Synthetic ID. -/// -/// Used when a user revokes consent — the existing SSC cookie is being -/// expired, so the persisted consent data must also be removed. -/// -/// Errors are logged but never propagated — KV Store failures must not -/// break the request pipeline. -pub fn delete_consent_from_kv(store_name: &str, synthetic_id: &str) { - let Some(store) = open_store(store_name) else { - return; - }; - - match store.delete(synthetic_id) { - Ok(()) => { - log::info!("Deleted consent KV entry for '{synthetic_id}' (consent revoked)"); - } - Err(e) => { - log::warn!("Failed to delete consent KV entry for '{synthetic_id}': {e}"); - } - } + kv.save_entry_with_ttl(key, &entry, ttl); + log::info!("Saved consent to KV store for '{key}' (fp={new_fp}, ttl={max_age_days}d)"); } // --------------------------------------------------------------------------- @@ -436,32 +320,6 @@ mod tests { assert_eq!(restored.stored_at_ds, 1_000_000); } - #[test] - fn metadata_roundtrip() { - let ctx = make_test_context(); - let meta = metadata_from_context(&ctx); - let json = serde_json::to_string(&meta).expect("should serialize"); - let restored: ConsentKvMetadata = serde_json::from_str(&json).expect("should deserialize"); - - assert_eq!(restored.fp, meta.fp); - assert!(restored.gdpr); - assert!(!restored.gpc); - assert!(restored.usp); - assert!(restored.tcf); - } - - #[test] - fn metadata_fits_in_2000_bytes() { - let ctx = make_test_context(); - let meta = metadata_from_context(&ctx); - let json = serde_json::to_string(&meta).expect("should serialize"); - assert!( - json.len() <= 2000, - "metadata JSON must fit in 2000 bytes, was {} bytes", - json.len() - ); - } - #[test] fn context_roundtrip_via_entry() { let original = make_test_context(); @@ -585,4 +443,133 @@ mod tests { "AC string should survive roundtrip" ); } + + // --- ConsentKvOps integration tests using a stub --- + + struct StubKvOps { + stored: std::sync::Mutex>, + } + + impl StubKvOps { + fn new() -> Self { + Self { + stored: std::sync::Mutex::new(std::collections::HashMap::new()), + } + } + } + + impl ConsentKvOps for StubKvOps { + fn load_entry(&self, key: &str) -> Option { + self.stored + .lock() + .expect("should lock stub KV store") + .get(key) + .cloned() + } + + fn save_entry_with_ttl( + &self, + key: &str, + entry: &KvConsentEntry, + _ttl: std::time::Duration, + ) { + self.stored + .lock() + .expect("should lock stub KV store") + .insert(key.to_owned(), entry.clone()); + } + + fn delete_entry(&self, key: &str) { + self.stored + .lock() + .expect("should lock stub KV store") + .remove(key); + } + } + + #[test] + fn load_consent_returns_none_on_miss() { + let kv = StubKvOps::new(); + let result = load_consent(&kv, "missing-key"); + assert!(result.is_none(), "should return None on cache miss"); + } + + #[test] + fn save_and_load_consent_roundtrip() { + let kv = StubKvOps::new(); + let ctx = make_test_context(); + save_consent(&kv, "user-1", &ctx, 30); + let loaded = load_consent(&kv, "user-1").expect("should load saved consent"); + assert_eq!( + loaded.raw_tc_string, ctx.raw_tc_string, + "should restore raw TC string" + ); + } + + #[test] + fn save_consent_skips_write_when_fingerprint_unchanged() { + let kv = StubKvOps::new(); + let ctx = make_test_context(); + + // First write. + save_consent(&kv, "user-1", &ctx, 30); + assert_eq!( + kv.stored.lock().expect("should lock").len(), + 1, + "should have one entry" + ); + + // Track the stored timestamp to verify no new write happens. + let stored_ts = kv + .stored + .lock() + .expect("should lock") + .get("user-1") + .map(|e| e.stored_at_ds) + .expect("should find entry after first write"); + + // Second write with same context — fingerprint unchanged. + save_consent(&kv, "user-1", &ctx, 30); + let ts_after = kv + .stored + .lock() + .expect("should lock") + .get("user-1") + .map(|e| e.stored_at_ds) + .expect("should find entry after second write"); + + assert_eq!( + stored_ts, ts_after, + "should not overwrite when fingerprint is unchanged" + ); + } + + #[test] + fn save_consent_writes_when_fingerprint_changes() { + let kv = StubKvOps::new(); + let ctx1 = make_test_context(); + save_consent(&kv, "user-1", &ctx1, 30); + + let mut ctx2 = make_test_context(); + ctx2.raw_tc_string = Some("DIFFERENT".to_owned()); + save_consent(&kv, "user-1", &ctx2, 30); + + let loaded = load_consent(&kv, "user-1").expect("should load updated entry"); + assert_eq!( + loaded.raw_tc_string, + Some("DIFFERENT".to_owned()), + "should reflect updated TC string" + ); + } + + #[test] + fn save_consent_skips_empty_consent() { + let kv = StubKvOps::new(); + let ctx = ConsentContext::default(); + save_consent(&kv, "user-1", &ctx, 30); + assert!( + kv.stored.lock().expect("should lock").is_empty(), + "should not write empty consent" + ); + } } diff --git a/crates/trusted-server-core/src/consent/mod.rs b/crates/trusted-server-core/src/consent/mod.rs index f0c6807d..21b65256 100644 --- a/crates/trusted-server-core/src/consent/mod.rs +++ b/crates/trusted-server-core/src/consent/mod.rs @@ -23,6 +23,7 @@ //! config: &settings.consent, //! geo: geo.as_ref(), //! synthetic_id: Some("sid_abc123"), +//! kv_ops: None, //! }); //! ``` @@ -69,10 +70,14 @@ pub struct ConsentPipelineInput<'a> { pub geo: Option<&'a GeoInfo>, /// Synthetic ID for KV Store consent persistence. /// - /// When set along with `config.consent_store`, enables: + /// When set along with `kv_ops`, enables: /// - **Read fallback**: loads consent from KV when cookies are absent. /// - **Write-on-change**: persists cookie-sourced consent to KV. pub synthetic_id: Option<&'a str>, + /// Platform KV store for consent persistence (read/write). + /// + /// When `None`, KV fallback and KV writes are skipped silently. + pub kv_ops: Option<&'a dyn kv::ConsentKvOps>, } /// Extracts, decodes, and normalizes consent signals from a request. @@ -520,11 +525,11 @@ fn should_try_kv_fallback(signals: &RawConsentSignals) -> bool { /// `None` otherwise. Requires both `consent_store` and `synthetic_id` to /// be configured. fn try_kv_fallback(input: &ConsentPipelineInput<'_>) -> Option { - let store_name = input.config.consent_store.as_deref()?; + let kv = input.kv_ops?; let synthetic_id = input.synthetic_id?; log::debug!("No cookie consent signals, trying KV fallback for '{synthetic_id}'"); - let mut ctx = kv::load_consent_from_kv(store_name, synthetic_id)?; + let mut ctx = kv::load_consent(kv, synthetic_id)?; // Re-detect jurisdiction from current geo (may differ from stored value). ctx.jurisdiction = jurisdiction::detect_jurisdiction(input.geo, input.config); @@ -540,19 +545,14 @@ fn try_kv_fallback(input: &ConsentPipelineInput<'_>) -> Option { /// Only writes when consent signals are non-empty and have changed since /// the last write (fingerprint comparison). fn try_kv_write(input: &ConsentPipelineInput<'_>, ctx: &ConsentContext) { - let Some(store_name) = input.config.consent_store.as_deref() else { + let Some(kv) = input.kv_ops else { return; }; let Some(synthetic_id) = input.synthetic_id else { return; }; - kv::save_consent_to_kv( - store_name, - synthetic_id, - ctx, - input.config.max_consent_age_days, - ); + kv::save_consent(kv, synthetic_id, ctx, input.config.max_consent_age_days); } // --------------------------------------------------------------------------- @@ -761,6 +761,7 @@ mod tests { config: &config, geo: None, synthetic_id: None, + kv_ops: None, }); assert!( @@ -790,6 +791,7 @@ mod tests { config: &config, geo: None, synthetic_id: None, + kv_ops: None, }); assert!( diff --git a/crates/trusted-server-core/src/geo.rs b/crates/trusted-server-core/src/geo.rs index cf0d8851..d4eb3a2f 100644 --- a/crates/trusted-server-core/src/geo.rs +++ b/crates/trusted-server-core/src/geo.rs @@ -1,14 +1,13 @@ //! Geographic location utilities for the trusted server. //! -//! This module provides a Fastly-to-core geo mapping helper and response-header -//! injection for the platform-neutral [`GeoInfo`] type. +//! This module provides response-header injection for the platform-neutral +//! [`GeoInfo`] type. //! //! The [`GeoInfo`] data type is defined in [`crate::platform`] as platform- //! neutral data; this module re-exports it and adds helper methods for HTTP //! response header injection. use edgezero_core::body::Body as EdgeBody; -use fastly::geo::Geo; use http::{HeaderValue, Response}; pub use crate::platform::GeoInfo; @@ -18,22 +17,6 @@ use crate::constants::{ HEADER_X_GEO_INFO_AVAILABLE, HEADER_X_GEO_METRO_CODE, HEADER_X_GEO_REGION, }; -/// Convert a Fastly [`Geo`] value into a platform-neutral [`GeoInfo`]. -/// -/// Shared by `FastlyPlatformGeo::lookup` in `trusted-server-adapter-fastly` so -/// that field mapping is never duplicated. -pub fn geo_from_fastly(geo: &Geo) -> GeoInfo { - GeoInfo { - city: geo.city().to_string(), - country: geo.country_code().to_string(), - continent: format!("{:?}", geo.continent()), - latitude: geo.latitude(), - longitude: geo.longitude(), - metro_code: geo.metro_code(), - region: geo.region().map(str::to_string), - } -} - impl GeoInfo { /// Sets geo information headers on the response. /// diff --git a/crates/trusted-server-core/src/integrations/adserver_mock.rs b/crates/trusted-server-core/src/integrations/adserver_mock.rs index f3a72d73..5c06aa9a 100644 --- a/crates/trusted-server-core/src/integrations/adserver_mock.rs +++ b/crates/trusted-server-core/src/integrations/adserver_mock.rs @@ -20,9 +20,10 @@ use crate::auction::provider::AuctionProvider; use crate::auction::types::{ AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStatus, MediaType, }; -use crate::backend::BackendConfig; use crate::error::TrustedServerError; -use crate::integrations::collect_body; +use crate::integrations::{ + collect_body, ensure_integration_backend_with_timeout, predict_backend_name_for_url, +}; use crate::platform::{PlatformHttpRequest, PlatformPendingRequest, PlatformResponse}; use crate::settings::{IntegrationConfig, Settings}; @@ -335,9 +336,10 @@ impl AuctionProvider for AdServerMockProvider { } // Send async with auction-scoped timeout - let backend_name = BackendConfig::from_url_with_first_byte_timeout( + let backend_name = ensure_integration_backend_with_timeout( + context.services, &self.config.endpoint, - true, + "adserver_mock", Duration::from_millis(u64::from(context.timeout_ms)), ) .change_context(TrustedServerError::Auction { @@ -408,18 +410,18 @@ impl AuctionProvider for AdServerMockProvider { } fn backend_name(&self, timeout_ms: u32) -> Option { - BackendConfig::backend_name_for_url( + let name = predict_backend_name_for_url( &self.config.endpoint, true, Duration::from_millis(u64::from(timeout_ms)), - ) - .inspect_err(|e| { + ); + if name.is_none() { log::error!( - "Failed to create backend for AdServer Mock endpoint '{}': {e:?}", + "Failed to predict backend name for AdServer Mock endpoint '{}'", self.config.endpoint ); - }) - .ok() + } + name } } diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 4d322bed..206409ec 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -14,9 +14,10 @@ use validator::Validate; use crate::auction::provider::AuctionProvider; use crate::auction::types::{AuctionContext, AuctionRequest, AuctionResponse, Bid, MediaType}; -use crate::backend::BackendConfig; use crate::error::TrustedServerError; -use crate::integrations::collect_body; +use crate::integrations::{ + collect_body, ensure_integration_backend_with_timeout, predict_backend_name_for_url, +}; use crate::platform::{PlatformHttpRequest, PlatformPendingRequest, PlatformResponse}; use crate::settings::IntegrationConfig; @@ -514,9 +515,10 @@ impl AuctionProvider for ApsAuctionProvider { })?; // Send request asynchronously with auction-scoped timeout - let backend_name = BackendConfig::from_url_with_first_byte_timeout( + let backend_name = ensure_integration_backend_with_timeout( + context.services, &self.config.endpoint, - true, + "aps", Duration::from_millis(u64::from(context.timeout_ms)), ) .change_context(TrustedServerError::Auction { @@ -590,18 +592,18 @@ impl AuctionProvider for ApsAuctionProvider { } fn backend_name(&self, timeout_ms: u32) -> Option { - BackendConfig::backend_name_for_url( + let name = predict_backend_name_for_url( &self.config.endpoint, true, Duration::from_millis(u64::from(timeout_ms)), - ) - .inspect_err(|e| { + ); + if name.is_none() { log::error!( - "Failed to create backend for APS endpoint '{}': {e:?}", + "Failed to predict backend name for APS endpoint '{}'", self.config.endpoint ); - }) - .ok() + } + name } } 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 41fd44e1..8a42c6fc 100644 --- a/crates/trusted-server-core/src/integrations/google_tag_manager.rs +++ b/crates/trusted-server-core/src/integrations/google_tag_manager.rs @@ -984,380 +984,398 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= .any(|r| r.path == "/integrations/google_tag_manager/g/collect")); } - #[tokio::test] - async fn test_post_collect_proxy_config_includes_payload() { - let config = GoogleTagManagerConfig { - enabled: true, - container_id: "GTM-TEST1234".to_string(), - upstream_url: default_upstream(), - cache_max_age: default_cache_max_age(), - max_beacon_body_size: default_max_beacon_body_size(), - }; - let integration = GoogleTagManagerIntegration::new(config); + #[test] + fn test_post_collect_proxy_config_includes_payload() { + futures::executor::block_on(async { + let config = GoogleTagManagerConfig { + enabled: true, + container_id: "GTM-TEST1234".to_string(), + upstream_url: default_upstream(), + cache_max_age: default_cache_max_age(), + max_beacon_body_size: default_max_beacon_body_size(), + }; + let integration = GoogleTagManagerIntegration::new(config); - let payload = b"v=2&tid=G-TEST&cid=123&en=page_view".to_vec(); - let mut req = build_http_request( - Method::POST, - "https://edge.example.com/integrations/google_tag_manager/g/collect?v=2&tid=G-TEST", - EdgeBody::from(payload.clone()), - ); + let payload = b"v=2&tid=G-TEST&cid=123&en=page_view".to_vec(); + let mut req = build_http_request( + Method::POST, + "https://edge.example.com/integrations/google_tag_manager/g/collect?v=2&tid=G-TEST", + EdgeBody::from(payload.clone()), + ); - let path = req.uri().path().to_string(); - let target_url = integration - .build_target_url(&req, &path) - .expect("should resolve collect target URL"); - let proxy_config = integration - .build_proxy_config(&path, &mut req, &target_url) - .await - .expect("should build proxy config"); + let path = req.uri().path().to_string(); + let target_url = integration + .build_target_url(&req, &path) + .expect("should resolve collect target URL"); + let proxy_config = integration + .build_proxy_config(&path, &mut req, &target_url) + .await + .expect("should build proxy config"); - assert_eq!( - proxy_config.body.as_deref(), - Some(payload.as_slice()), - "collect POST should forward payload body" - ); + assert_eq!( + proxy_config.body.as_deref(), + Some(payload.as_slice()), + "collect POST should forward payload body" + ); + }); } - #[tokio::test] - async fn test_oversized_post_body_rejected() { - let max_size = default_max_beacon_body_size(); - let config = GoogleTagManagerConfig { - enabled: true, - container_id: "GTM-TEST1234".to_string(), - upstream_url: default_upstream(), - cache_max_age: default_cache_max_age(), - max_beacon_body_size: max_size, - }; - let integration = GoogleTagManagerIntegration::new(config); - - // Create a payload larger than the configured max size (64KB by default) - let oversized_payload = vec![b'X'; max_size + 1]; - let mut req = build_http_request( - Method::POST, - "https://edge.example.com/integrations/google_tag_manager/collect", - EdgeBody::from(oversized_payload.clone()), - ); + #[test] + fn test_oversized_post_body_rejected() { + futures::executor::block_on(async { + let max_size = default_max_beacon_body_size(); + let config = GoogleTagManagerConfig { + enabled: true, + container_id: "GTM-TEST1234".to_string(), + upstream_url: default_upstream(), + cache_max_age: default_cache_max_age(), + max_beacon_body_size: max_size, + }; + let integration = GoogleTagManagerIntegration::new(config); + + // Create a payload larger than the configured max size (64KB by default) + let oversized_payload = vec![b'X'; max_size + 1]; + let mut req = build_http_request( + Method::POST, + "https://edge.example.com/integrations/google_tag_manager/collect", + EdgeBody::from(oversized_payload.clone()), + ); - let path = req.uri().path().to_string(); - let target_url = integration - .build_target_url(&req, &path) - .expect("should resolve collect target URL"); + let path = req.uri().path().to_string(); + let target_url = integration + .build_target_url(&req, &path) + .expect("should resolve collect target URL"); - // Attempt to build proxy config should fail due to oversized body - let result = integration - .build_proxy_config(&path, &mut req, &target_url) - .await; + // Attempt to build proxy config should fail due to oversized body + let result = integration + .build_proxy_config(&path, &mut req, &target_url) + .await; - assert!(result.is_err(), "Oversized POST body should be rejected"); + assert!(result.is_err(), "Oversized POST body should be rejected"); - if let Err(PayloadSizeError::TooLarge { actual, max }) = result { - assert_eq!(actual, max_size + 1, "Should report actual size"); - assert_eq!(max, max_size, "Should report max size"); - } else { - panic!("Expected PayloadSizeError::TooLarge"); - } + if let Err(PayloadSizeError::TooLarge { actual, max }) = result { + assert_eq!(actual, max_size + 1, "Should report actual size"); + assert_eq!(max, max_size, "Should report max size"); + } else { + panic!("Expected PayloadSizeError::TooLarge"); + } + }); } - #[tokio::test] - async fn test_custom_max_beacon_body_size() { - // Test with a custom smaller limit - let custom_max_size = 1024; // 1KB - let config = GoogleTagManagerConfig { - enabled: true, - container_id: "GTM-TEST1234".to_string(), - upstream_url: default_upstream(), - cache_max_age: default_cache_max_age(), - max_beacon_body_size: custom_max_size, - }; - let integration = GoogleTagManagerIntegration::new(config); - - // Payload just under the custom limit should succeed - let acceptable_payload = vec![b'X'; custom_max_size - 1]; - let mut req1 = build_http_request( - Method::POST, - "https://edge.example.com/integrations/google_tag_manager/collect", - EdgeBody::from(acceptable_payload.clone()), - ); + #[test] + fn test_custom_max_beacon_body_size() { + futures::executor::block_on(async { + // Test with a custom smaller limit + let custom_max_size = 1024; // 1KB + let config = GoogleTagManagerConfig { + enabled: true, + container_id: "GTM-TEST1234".to_string(), + upstream_url: default_upstream(), + cache_max_age: default_cache_max_age(), + max_beacon_body_size: custom_max_size, + }; + let integration = GoogleTagManagerIntegration::new(config); + + // Payload just under the custom limit should succeed + let acceptable_payload = vec![b'X'; custom_max_size - 1]; + let mut req1 = build_http_request( + Method::POST, + "https://edge.example.com/integrations/google_tag_manager/collect", + EdgeBody::from(acceptable_payload.clone()), + ); - let path = req1.uri().path().to_string(); - let target_url = integration - .build_target_url(&req1, &path) - .expect("should resolve collect target URL"); - - let result = integration - .build_proxy_config(&path, &mut req1, &target_url) - .await; - assert!(result.is_ok(), "Payload under custom limit should succeed"); - - // Payload over the custom limit should fail - let oversized_payload = vec![b'X'; custom_max_size + 1]; - let mut req2 = build_http_request( - Method::POST, - "https://edge.example.com/integrations/google_tag_manager/collect", - EdgeBody::from(oversized_payload), - ); + let path = req1.uri().path().to_string(); + let target_url = integration + .build_target_url(&req1, &path) + .expect("should resolve collect target URL"); + + let result = integration + .build_proxy_config(&path, &mut req1, &target_url) + .await; + assert!(result.is_ok(), "Payload under custom limit should succeed"); + + // Payload over the custom limit should fail + let oversized_payload = vec![b'X'; custom_max_size + 1]; + let mut req2 = build_http_request( + Method::POST, + "https://edge.example.com/integrations/google_tag_manager/collect", + EdgeBody::from(oversized_payload), + ); - let target_url2 = integration - .build_target_url(&req2, &path) - .expect("should resolve collect target URL"); + let target_url2 = integration + .build_target_url(&req2, &path) + .expect("should resolve collect target URL"); - let result2 = integration - .build_proxy_config(&path, &mut req2, &target_url2) - .await; - assert!( - result2.is_err(), - "Payload over custom limit should be rejected" - ); + let result2 = integration + .build_proxy_config(&path, &mut req2, &target_url2) + .await; + assert!( + result2.is_err(), + "Payload over custom limit should be rejected" + ); + }); } - #[tokio::test] - async fn test_incorrect_content_length_returns_413() { - // Verify that when Content-Length is incorrect (smaller than actual body), - // we still catch it and return 413 (not 502) - let max_size = default_max_beacon_body_size(); - let config = GoogleTagManagerConfig { - enabled: true, - container_id: "GTM-TEST1234".to_string(), - upstream_url: default_upstream(), - cache_max_age: default_cache_max_age(), - max_beacon_body_size: max_size, - }; - let integration = GoogleTagManagerIntegration::new(config); - - // Create oversized payload but with incorrect (small) Content-Length - let oversized_payload = vec![b'X'; max_size + 1]; - let mut req = build_http_request( - Method::POST, - "https://edge.example.com/integrations/google_tag_manager/collect", - EdgeBody::from(oversized_payload.clone()), - ); - // Set Content-Length to a small value (incorrect) - req.headers_mut().insert( - http::header::CONTENT_LENGTH, - http::HeaderValue::from_str(&(max_size / 2).to_string()) - .expect("should build Content-Length header"), - ); + #[test] + fn test_incorrect_content_length_returns_413() { + futures::executor::block_on(async { + // Verify that when Content-Length is incorrect (smaller than actual body), + // we still catch it and return 413 (not 502) + let max_size = default_max_beacon_body_size(); + let config = GoogleTagManagerConfig { + enabled: true, + container_id: "GTM-TEST1234".to_string(), + upstream_url: default_upstream(), + cache_max_age: default_cache_max_age(), + max_beacon_body_size: max_size, + }; + let integration = GoogleTagManagerIntegration::new(config); + + // Create oversized payload but with incorrect (small) Content-Length + let oversized_payload = vec![b'X'; max_size + 1]; + let mut req = build_http_request( + Method::POST, + "https://edge.example.com/integrations/google_tag_manager/collect", + EdgeBody::from(oversized_payload.clone()), + ); + // Set Content-Length to a small value (incorrect) + req.headers_mut().insert( + http::header::CONTENT_LENGTH, + http::HeaderValue::from_str(&(max_size / 2).to_string()) + .expect("should build Content-Length header"), + ); - let path = req.uri().path().to_string(); - let target_url = integration - .build_target_url(&req, &path) - .expect("should resolve collect target URL"); + let path = req.uri().path().to_string(); + let target_url = integration + .build_target_url(&req, &path) + .expect("should resolve collect target URL"); - // build_proxy_config should detect the mismatch and return PayloadSizeError - let result = integration - .build_proxy_config(&path, &mut req, &target_url) - .await; + // build_proxy_config should detect the mismatch and return PayloadSizeError + let result = integration + .build_proxy_config(&path, &mut req, &target_url) + .await; - assert!( - result.is_err(), - "Should reject when actual body exceeds max despite low Content-Length" - ); + assert!( + result.is_err(), + "Should reject when actual body exceeds max despite low Content-Length" + ); - // Verify it's a PayloadSizeError::TooLarge - if let Err(PayloadSizeError::TooLarge { actual, max }) = result { - assert_eq!(actual, oversized_payload.len(), "Should report actual size"); - assert_eq!(max, max_size, "Should report max size"); - } else { - panic!("Expected PayloadSizeError::TooLarge"); - } + // Verify it's a PayloadSizeError::TooLarge + if let Err(PayloadSizeError::TooLarge { actual, max }) = result { + assert_eq!(actual, oversized_payload.len(), "Should report actual size"); + assert_eq!(max, max_size, "Should report max size"); + } else { + panic!("Expected PayloadSizeError::TooLarge"); + } + }); } - #[tokio::test] - async fn test_handle_returns_413_for_oversized_post() { - // Verify that handle() actually returns 413 status code for oversized POST - let max_size = 1024; // Use small size for testing - let config = GoogleTagManagerConfig { - enabled: true, - container_id: "GTM-TEST1234".to_string(), - upstream_url: default_upstream(), - cache_max_age: default_cache_max_age(), - max_beacon_body_size: max_size, - }; - let integration = GoogleTagManagerIntegration::new(config); - - // Create oversized payload with correct Content-Length - let oversized_payload = vec![b'X'; max_size + 1]; - let mut req = http::Request::builder() - .method(Method::POST) - .uri("https://edge.example.com/integrations/google_tag_manager/collect") - .body(EdgeBody::from(oversized_payload.clone())) - .expect("should build oversized request"); - req.headers_mut().insert( - http::header::CONTENT_LENGTH, - http::HeaderValue::from_str(&oversized_payload.len().to_string()) - .expect("should build Content-Length header"), - ); + #[test] + fn test_handle_returns_413_for_oversized_post() { + futures::executor::block_on(async { + // Verify that handle() actually returns 413 status code for oversized POST + let max_size = 1024; // Use small size for testing + let config = GoogleTagManagerConfig { + enabled: true, + container_id: "GTM-TEST1234".to_string(), + upstream_url: default_upstream(), + cache_max_age: default_cache_max_age(), + max_beacon_body_size: max_size, + }; + let integration = GoogleTagManagerIntegration::new(config); + + // Create oversized payload with correct Content-Length + let oversized_payload = vec![b'X'; max_size + 1]; + let mut req = http::Request::builder() + .method(Method::POST) + .uri("https://edge.example.com/integrations/google_tag_manager/collect") + .body(EdgeBody::from(oversized_payload.clone())) + .expect("should build oversized request"); + req.headers_mut().insert( + http::header::CONTENT_LENGTH, + http::HeaderValue::from_str(&oversized_payload.len().to_string()) + .expect("should build Content-Length header"), + ); - let settings = make_settings(); - let response = integration - .handle(&settings, &noop_services(), req) - .await - .expect("handle should not return error"); + let settings = make_settings(); + let response = integration + .handle(&settings, &noop_services(), req) + .await + .expect("handle should not return error"); - // Verify we get 413 Payload Too Large, not 502 Bad Gateway - assert_eq!( - response.status(), - StatusCode::PAYLOAD_TOO_LARGE, - "Should return 413 for oversized POST body" - ); + // Verify we get 413 Payload Too Large, not 502 Bad Gateway + assert_eq!( + response.status(), + StatusCode::PAYLOAD_TOO_LARGE, + "Should return 413 for oversized POST body" + ); + }); } - #[tokio::test] - async fn test_handle_returns_400_for_invalid_content_length() { - // Verify that handle() returns 400 Bad Request for malformed Content-Length - let config = GoogleTagManagerConfig { - enabled: true, - container_id: "GTM-TEST1234".to_string(), - upstream_url: default_upstream(), - cache_max_age: default_cache_max_age(), - max_beacon_body_size: default_max_beacon_body_size(), - }; - let integration = GoogleTagManagerIntegration::new(config); - - // Create POST request with invalid Content-Length header - let payload = b"v=2&tid=G-TEST&cid=123".to_vec(); - let mut req = http::Request::builder() - .method(Method::POST) - .uri("https://edge.example.com/integrations/google_tag_manager/collect") - .body(EdgeBody::from(payload)) - .expect("should build malformed request"); - req.headers_mut().insert( - http::header::CONTENT_LENGTH, - http::HeaderValue::from_static("not-a-number"), - ); + #[test] + fn test_handle_returns_400_for_invalid_content_length() { + futures::executor::block_on(async { + // Verify that handle() returns 400 Bad Request for malformed Content-Length + let config = GoogleTagManagerConfig { + enabled: true, + container_id: "GTM-TEST1234".to_string(), + upstream_url: default_upstream(), + cache_max_age: default_cache_max_age(), + max_beacon_body_size: default_max_beacon_body_size(), + }; + let integration = GoogleTagManagerIntegration::new(config); + + // Create POST request with invalid Content-Length header + let payload = b"v=2&tid=G-TEST&cid=123".to_vec(); + let mut req = http::Request::builder() + .method(Method::POST) + .uri("https://edge.example.com/integrations/google_tag_manager/collect") + .body(EdgeBody::from(payload)) + .expect("should build malformed request"); + req.headers_mut().insert( + http::header::CONTENT_LENGTH, + http::HeaderValue::from_static("not-a-number"), + ); - let settings = make_settings(); - let response = integration - .handle(&settings, &noop_services(), req) - .await - .expect("handle should not return error"); + let settings = make_settings(); + let response = integration + .handle(&settings, &noop_services(), req) + .await + .expect("handle should not return error"); - // Verify we get 400 Bad Request for malformed Content-Length - assert_eq!( - response.status(), - StatusCode::BAD_REQUEST, - "Should return 400 for malformed Content-Length" - ); + // Verify we get 400 Bad Request for malformed Content-Length + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "Should return 400 for malformed Content-Length" + ); + }); } - #[tokio::test] - async fn test_handle_accepts_post_without_content_length() { - // Verify that POST without Content-Length is accepted (for HTTP/2 compatibility) - // but still checked against max size after read - let max_size = default_max_beacon_body_size(); - let config = GoogleTagManagerConfig { - enabled: true, - container_id: "GTM-TEST1234".to_string(), - upstream_url: default_upstream(), - cache_max_age: default_cache_max_age(), - max_beacon_body_size: max_size, - }; - let integration = GoogleTagManagerIntegration::new(config); - - // Create small POST request without Content-Length header - let small_payload = b"v=2&tid=G-TEST&cid=123".to_vec(); - let mut req = build_http_request( - Method::POST, - "https://edge.example.com/integrations/google_tag_manager/collect", - EdgeBody::from(small_payload), - ); - // Intentionally NOT setting Content-Length header (HTTP/2 scenario) + #[test] + fn test_handle_accepts_post_without_content_length() { + futures::executor::block_on(async { + // Verify that POST without Content-Length is accepted (for HTTP/2 compatibility) + // but still checked against max size after read + let max_size = default_max_beacon_body_size(); + let config = GoogleTagManagerConfig { + enabled: true, + container_id: "GTM-TEST1234".to_string(), + upstream_url: default_upstream(), + cache_max_age: default_cache_max_age(), + max_beacon_body_size: max_size, + }; + let integration = GoogleTagManagerIntegration::new(config); + + // Create small POST request without Content-Length header + let small_payload = b"v=2&tid=G-TEST&cid=123".to_vec(); + let mut req = build_http_request( + Method::POST, + "https://edge.example.com/integrations/google_tag_manager/collect", + EdgeBody::from(small_payload), + ); + // Intentionally NOT setting Content-Length header (HTTP/2 scenario) - let path = req.uri().path().to_string(); - let target_url = integration - .build_target_url(&req, &path) - .expect("should resolve collect target URL"); + let path = req.uri().path().to_string(); + let target_url = integration + .build_target_url(&req, &path) + .expect("should resolve collect target URL"); - // build_proxy_config should accept small payloads even without Content-Length - let result = integration - .build_proxy_config(&path, &mut req, &target_url) - .await; + // build_proxy_config should accept small payloads even without Content-Length + let result = integration + .build_proxy_config(&path, &mut req, &target_url) + .await; - assert!( - result.is_ok(), - "Should accept small POST without Content-Length (HTTP/2 compat)" - ); + assert!( + result.is_ok(), + "Should accept small POST without Content-Length (HTTP/2 compat)" + ); + }); } - #[tokio::test] - async fn test_collect_proxy_config_strips_client_ip_forwarding() { - let config = GoogleTagManagerConfig { - enabled: true, - container_id: "GTM-TEST1234".to_string(), - upstream_url: default_upstream(), - cache_max_age: default_cache_max_age(), - max_beacon_body_size: default_max_beacon_body_size(), - }; - let integration = GoogleTagManagerIntegration::new(config); + #[test] + fn test_collect_proxy_config_strips_client_ip_forwarding() { + futures::executor::block_on(async { + let config = GoogleTagManagerConfig { + enabled: true, + container_id: "GTM-TEST1234".to_string(), + upstream_url: default_upstream(), + cache_max_age: default_cache_max_age(), + max_beacon_body_size: default_max_beacon_body_size(), + }; + let integration = GoogleTagManagerIntegration::new(config); - let mut req = build_http_request( - Method::GET, - "https://edge.example.com/integrations/google_tag_manager/collect?v=2", - EdgeBody::empty(), - ); - req.headers_mut().insert( - crate::constants::HEADER_X_FORWARDED_FOR, - http::HeaderValue::from_static("198.51.100.42"), - ); + let mut req = build_http_request( + Method::GET, + "https://edge.example.com/integrations/google_tag_manager/collect?v=2", + EdgeBody::empty(), + ); + req.headers_mut().insert( + crate::constants::HEADER_X_FORWARDED_FOR, + http::HeaderValue::from_static("198.51.100.42"), + ); - let path = req.uri().path().to_string(); - let target_url = integration - .build_target_url(&req, &path) - .expect("should resolve collect target URL"); - let proxy_config = integration - .build_proxy_config(&path, &mut req, &target_url) - .await - .expect("should build proxy config"); - - // We check if X-Forwarded-For is explicitly overridden with an empty string, - // which effectively strips it during proxy forwarding due to header override logic. - let has_header_override = proxy_config.headers.iter().any(|(name, value)| { - name.as_str() - .eq_ignore_ascii_case(crate::constants::HEADER_X_FORWARDED_FOR.as_str()) - && value.is_empty() - }); + let path = req.uri().path().to_string(); + let target_url = integration + .build_target_url(&req, &path) + .expect("should resolve collect target URL"); + let proxy_config = integration + .build_proxy_config(&path, &mut req, &target_url) + .await + .expect("should build proxy config"); + + // We check if X-Forwarded-For is explicitly overridden with an empty string, + // which effectively strips it during proxy forwarding due to header override logic. + let has_header_override = proxy_config.headers.iter().any(|(name, value)| { + name.as_str() + .eq_ignore_ascii_case(crate::constants::HEADER_X_FORWARDED_FOR.as_str()) + && value.is_empty() + }); - assert!( + assert!( has_header_override, "collect routes should strip client IP by overriding X-Forwarded-For with empty string" ); + }); } - #[tokio::test] - async fn test_gtag_proxy_config_requests_identity_encoding() { - let config = GoogleTagManagerConfig { - enabled: true, - container_id: "GT-123".to_string(), - upstream_url: default_upstream(), - cache_max_age: default_cache_max_age(), - max_beacon_body_size: default_max_beacon_body_size(), - }; - let integration = GoogleTagManagerIntegration::new(config); + #[test] + fn test_gtag_proxy_config_requests_identity_encoding() { + futures::executor::block_on(async { + let config = GoogleTagManagerConfig { + enabled: true, + container_id: "GT-123".to_string(), + upstream_url: default_upstream(), + cache_max_age: default_cache_max_age(), + max_beacon_body_size: default_max_beacon_body_size(), + }; + let integration = GoogleTagManagerIntegration::new(config); - let mut req = build_http_request( - Method::GET, - "https://edge.example.com/integrations/google_tag_manager/gtag/js?id=G-123", - EdgeBody::empty(), - ); + let mut req = build_http_request( + Method::GET, + "https://edge.example.com/integrations/google_tag_manager/gtag/js?id=G-123", + EdgeBody::empty(), + ); - let path = req.uri().path().to_string(); - let target_url = integration - .build_target_url(&req, &path) - .expect("should resolve gtag target URL"); - let proxy_config = integration - .build_proxy_config(&path, &mut req, &target_url) - .await - .expect("should build proxy config"); + let path = req.uri().path().to_string(); + let target_url = integration + .build_target_url(&req, &path) + .expect("should resolve gtag target URL"); + let proxy_config = integration + .build_proxy_config(&path, &mut req, &target_url) + .await + .expect("should build proxy config"); - let has_identity = proxy_config - .headers - .iter() - .any(|(name, value)| name == http::header::ACCEPT_ENCODING && value == "identity"); + let has_identity = proxy_config + .headers + .iter() + .any(|(name, value)| name == http::header::ACCEPT_ENCODING && value == "identity"); - assert!( - has_identity, - "gtag/js requests should force Accept-Encoding: identity for rewriting" - ); + assert!( + has_identity, + "gtag/js requests should force Accept-Encoding: identity for rewriting" + ); + }); } #[test] diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index 25f410a2..a22b2fdb 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -1,5 +1,7 @@ //! Integration module registry and sample implementations. +use std::time::Duration; + use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; use futures::StreamExt as _; @@ -73,6 +75,85 @@ pub(crate) fn ensure_integration_backend( }) } +/// Registers or retrieves a platform backend for the given URL with a custom +/// first-byte timeout. +/// +/// Parses `url`, builds a [`PlatformBackendSpec`] with TLS enabled and the +/// given `first_byte_timeout`, and delegates to +/// [`crate::platform::PlatformBackend::ensure`]. +/// +/// # Errors +/// +/// Returns an error when `url` cannot be parsed, is missing a host, or the +/// backend registration fails. +pub(crate) fn ensure_integration_backend_with_timeout( + services: &RuntimeServices, + url: &str, + integration: &'static str, + first_byte_timeout: Duration, +) -> Result> { + let parsed = Url::parse(url).change_context(TrustedServerError::Integration { + integration: integration.to_string(), + message: "Invalid upstream URL".to_string(), + })?; + + services + .backend() + .ensure(&PlatformBackendSpec { + scheme: parsed.scheme().to_string(), + host: parsed + .host_str() + .ok_or_else(|| { + Report::new(TrustedServerError::Integration { + integration: integration.to_string(), + message: "Upstream URL missing host".to_string(), + }) + })? + .to_string(), + port: parsed.port(), + certificate_check: true, + first_byte_timeout, + }) + .change_context(TrustedServerError::Integration { + integration: integration.to_string(), + message: "Failed to register backend".to_string(), + }) +} + +/// Compute the deterministic backend name for a URL without registering a backend. +/// +/// Uses the same naming convention as [`crate::platform::PlatformBackend::predict_name`]: +/// `backend_{scheme}_{host}_{port}{cert_suffix}_t{timeout_ms}` with `.` and `:` +/// replaced by `_`. +/// +/// Returns `None` when the URL cannot be parsed or is missing a host. +pub(crate) fn predict_backend_name_for_url( + url: &str, + certificate_check: bool, + first_byte_timeout: Duration, +) -> Option { + let parsed = Url::parse(url).ok()?; + let scheme = parsed.scheme(); + let host = parsed.host_str()?; + + let default_port = if scheme.eq_ignore_ascii_case("https") { + 443u16 + } else { + 80u16 + }; + let port = parsed.port().unwrap_or(default_port); + + let name_base = format!("{}_{}_{}", scheme, host, port); + let cert_suffix = if certificate_check { "" } else { "_nocert" }; + let timeout_ms = first_byte_timeout.as_millis(); + Some(format!( + "backend_{}{}_t{}", + name_base.replace(['.', ':'], "_"), + cert_suffix, + timeout_ms + )) +} + /// Maximum body size accepted by integration proxy endpoints (256 KiB). pub(crate) const INTEGRATION_MAX_BODY_BYTES: usize = 256 * 1024; diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 46894d5d..9ab07fbe 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -16,15 +16,15 @@ use crate::auction::provider::AuctionProvider; use crate::auction::types::{ AuctionContext, AuctionRequest, AuctionResponse, Bid as AuctionBid, MediaType, }; -use crate::backend::BackendConfig; use crate::consent_config::ConsentForwardingMode; use crate::cookies::{strip_cookies, CONSENT_COOKIE_NAMES}; use crate::error::TrustedServerError; use crate::http_util::RequestInfo; use crate::integrations::{ - collect_body, AttributeRewriteAction, IntegrationAttributeContext, - IntegrationAttributeRewriter, IntegrationEndpoint, IntegrationHeadInjector, - IntegrationHtmlContext, IntegrationProxy, IntegrationRegistration, + collect_body, ensure_integration_backend_with_timeout, predict_backend_name_for_url, + AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, + IntegrationEndpoint, IntegrationHeadInjector, IntegrationHtmlContext, IntegrationProxy, + IntegrationRegistration, }; use crate::openrtb::{ to_openrtb_i32, Banner, ConsentedProvidersSettings, Device, Format, Geo, Imp, ImpExt, @@ -1128,9 +1128,10 @@ impl AuctionProvider for PrebidAuctionProvider { *pbs_req.body_mut() = EdgeBody::from(pbs_body); // Send request asynchronously with auction-scoped timeout - let backend_name = BackendConfig::from_url_with_first_byte_timeout( + let backend_name = ensure_integration_backend_with_timeout( + context.services, &self.config.server_url, - true, + "prebid", Duration::from_millis(u64::from(context.timeout_ms)), )?; let pending = context @@ -1213,18 +1214,18 @@ impl AuctionProvider for PrebidAuctionProvider { } fn backend_name(&self, timeout_ms: u32) -> Option { - BackendConfig::backend_name_for_url( + let name = predict_backend_name_for_url( &self.config.server_url, true, Duration::from_millis(u64::from(timeout_ms)), - ) - .inspect_err(|e| { + ); + if name.is_none() { log::error!( - "Failed to create backend for Prebid server URL '{}': {e:?}", + "Failed to predict backend name for Prebid server URL '{}'", self.config.server_url ); - }) - .ok() + } + name } } diff --git a/crates/trusted-server-core/src/integrations/testlight.rs b/crates/trusted-server-core/src/integrations/testlight.rs index 9196165d..ce30ca62 100644 --- a/crates/trusted-server-core/src/integrations/testlight.rs +++ b/crates/trusted-server-core/src/integrations/testlight.rs @@ -424,52 +424,54 @@ mod tests { ); } - #[tokio::test] - async fn handle_uses_platform_http_client_with_http_request() { - let stub = Arc::new(StubHttpClient::new()); - stub.push_response(200, br#"{"ok":true}"#.to_vec()); - let services = build_services_with_http_client( - Arc::clone(&stub) as Arc - ); - let settings = create_test_settings(); - let integration = TestlightIntegration::new(TestlightConfig { - enabled: true, - endpoint: "https://example.com/openrtb".to_string(), - timeout_ms: 1000, - shim_src: tsjs::tsjs_unified_script_src(), - rewrite_scripts: true, - }); - let mut req = http::Request::builder() - .method(Method::POST) - .uri("https://edge.example.com/integrations/testlight/auction") - .body(EdgeBody::from(br#"{"imp":[{"id":"slot-1"}]}"#.to_vec())) - .expect("should build request"); - req.headers_mut().insert( - crate::constants::HEADER_X_SYNTHETIC_ID.clone(), - http::HeaderValue::from_static(VALID_SYNTHETIC_ID), - ); + #[test] + fn handle_uses_platform_http_client_with_http_request() { + futures::executor::block_on(async { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, br#"{"ok":true}"#.to_vec()); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let settings = create_test_settings(); + let integration = TestlightIntegration::new(TestlightConfig { + enabled: true, + endpoint: "https://example.com/openrtb".to_string(), + timeout_ms: 1000, + shim_src: tsjs::tsjs_unified_script_src(), + rewrite_scripts: true, + }); + let mut req = http::Request::builder() + .method(Method::POST) + .uri("https://edge.example.com/integrations/testlight/auction") + .body(EdgeBody::from(br#"{"imp":[{"id":"slot-1"}]}"#.to_vec())) + .expect("should build request"); + req.headers_mut().insert( + crate::constants::HEADER_X_SYNTHETIC_ID.clone(), + http::HeaderValue::from_static(VALID_SYNTHETIC_ID), + ); - let response = integration - .handle(&settings, &services, req) - .await - .expect("should proxy Testlight request"); + let response = integration + .handle(&settings, &services, req) + .await + .expect("should proxy Testlight request"); - assert_eq!( - response.status(), - http::StatusCode::OK, - "should return stubbed upstream status" - ); - assert_eq!( - stub.recorded_backend_names(), - vec!["stub-backend".to_string()], - "should route outbound request through PlatformHttpClient" - ); - let response_json: serde_json::Value = - serde_json::from_slice(&response.into_body().into_bytes()) - .expect("should parse JSON response"); - assert_eq!( - response_json["ok"], true, - "should preserve the upstream JSON response body" - ); + assert_eq!( + response.status(), + http::StatusCode::OK, + "should return stubbed upstream status" + ); + assert_eq!( + stub.recorded_backend_names(), + vec!["stub-backend".to_string()], + "should route outbound request through PlatformHttpClient" + ); + let response_json: serde_json::Value = + serde_json::from_slice(&response.into_body().into_bytes()) + .expect("should parse JSON response"); + assert_eq!( + response_json["ok"], true, + "should preserve the upstream JSON response body" + ); + }); } } diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index b4757ee9..3c0e0141 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -35,8 +35,6 @@ pub mod auction; pub mod auction_config_types; pub mod auth; -pub mod backend; -pub mod compat; pub mod consent; pub mod consent_config; pub mod constants; @@ -58,7 +56,6 @@ pub mod request_signing; pub mod rsc_flight; pub mod settings; pub mod settings_data; -pub mod storage; pub mod streaming_processor; pub mod streaming_replacer; pub mod synthetic; diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index 6aaedf05..474aabbb 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -24,15 +24,13 @@ //! `streaming_replacer`, and `rsc_flight` modules use only standard Rust //! (`std::io::Read`/`Write`, `lol_html`, `flate2`, `brotli`). The pipeline //! is accessed via `StreamingPipeline::process` which -//! accepts any reader, including `fastly::Body` (which implements +//! accepts any reader including platform body types (which implement //! `std::io::Read`). //! //! The `publisher.rs` handler module is platform-coupled at its handler -//! layer — it accepts and returns `fastly::Body` in function signatures -//! such as `process_response_streaming`. This is an HTTP-type coupling -//! that will be addressed in Phase 2 (PR 11) alongside all other -//! `fastly::Request`/`Response`/`Body` migrations. It is not a -//! content-rewriting concern. +//! layer — it accepts and returns `EdgeBody` in function signatures. +//! This is an HTTP-type coupling that will be addressed in future PRs. +//! It is not a content-rewriting concern. //! //! No `PlatformContentRewriter` trait exists or is needed. //! diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index ee67e0a0..a1568556 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -1378,85 +1378,95 @@ mod tests { .and_then(|value| value.to_str().ok()) } - #[tokio::test] - async fn proxy_missing_param_returns_400() { - let settings = create_test_settings(); - let req = build_http_request(Method::GET, "https://example.com/first-party/proxy"); - let err: Report = - handle_first_party_proxy(&settings, &noop_services(), req) - .await - .expect_err("expected error"); - assert_eq!(err.current_context().status_code(), StatusCode::BAD_GATEWAY); + #[test] + fn proxy_missing_param_returns_400() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let req = build_http_request(Method::GET, "https://example.com/first-party/proxy"); + let err: Report = + handle_first_party_proxy(&settings, &noop_services(), req) + .await + .expect_err("expected error"); + assert_eq!(err.current_context().status_code(), StatusCode::BAD_GATEWAY); + }); } - #[tokio::test] - async fn proxy_missing_or_invalid_token_returns_400() { - let settings = create_test_settings(); - // missing tstoken should 400 - let req = build_http_request( - Method::GET, - "https://example.com/first-party/proxy?tsurl=https%3A%2F%2Fcdn.example%2Fa.png", - ); - let err: Report = - handle_first_party_proxy(&settings, &noop_services(), req) - .await - .expect_err("expected error"); - assert_eq!(err.current_context().status_code(), StatusCode::BAD_GATEWAY); + #[test] + fn proxy_missing_or_invalid_token_returns_400() { + futures::executor::block_on(async { + let settings = create_test_settings(); + // missing tstoken should 400 + let req = build_http_request( + Method::GET, + "https://example.com/first-party/proxy?tsurl=https%3A%2F%2Fcdn.example%2Fa.png", + ); + let err: Report = + handle_first_party_proxy(&settings, &noop_services(), req) + .await + .expect_err("expected error"); + assert_eq!(err.current_context().status_code(), StatusCode::BAD_GATEWAY); + }); } - #[tokio::test] - async fn proxy_sign_returns_signed_url() { - let settings = create_test_settings(); - let body = serde_json::json!({ - "url": "https://cdn.example/asset.js?c=3&b=2", + #[test] + fn proxy_sign_returns_signed_url() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let body = serde_json::json!({ + "url": "https://cdn.example/asset.js?c=3&b=2", + }); + let req = build_http_post_json_request("https://edge.example/first-party/sign", &body); + let resp = handle_first_party_proxy_sign(&settings, &noop_services(), req) + .await + .expect("sign ok"); + assert_eq!(resp.status(), StatusCode::OK); + let json = response_body_string(resp); + assert!(json.contains("/first-party/proxy?tsurl="), "{}", json); + assert!(json.contains("tsexp"), "{}", json); + assert!( + json.contains("\"base\":\"https://cdn.example/asset.js\""), + "{}", + json + ); }); - let req = build_http_post_json_request("https://edge.example/first-party/sign", &body); - let resp = handle_first_party_proxy_sign(&settings, &noop_services(), req) - .await - .expect("sign ok"); - assert_eq!(resp.status(), StatusCode::OK); - let json = response_body_string(resp); - assert!(json.contains("/first-party/proxy?tsurl="), "{}", json); - assert!(json.contains("tsexp"), "{}", json); - assert!( - json.contains("\"base\":\"https://cdn.example/asset.js\""), - "{}", - json - ); } - #[tokio::test] - async fn proxy_sign_rejects_invalid_url() { - let settings = create_test_settings(); - let body = serde_json::json!({ - "url": "data:image/png;base64,AAAA", + #[test] + fn proxy_sign_rejects_invalid_url() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let body = serde_json::json!({ + "url": "data:image/png;base64,AAAA", + }); + let req = build_http_post_json_request("https://edge.example/first-party/sign", &body); + let err: Report = + handle_first_party_proxy_sign(&settings, &noop_services(), req) + .await + .expect_err("expected error"); + assert_eq!(err.current_context().status_code(), StatusCode::BAD_GATEWAY); }); - let req = build_http_post_json_request("https://edge.example/first-party/sign", &body); - let err: Report = - handle_first_party_proxy_sign(&settings, &noop_services(), req) - .await - .expect_err("expected error"); - assert_eq!(err.current_context().status_code(), StatusCode::BAD_GATEWAY); } - #[tokio::test] - async fn proxy_sign_preserves_non_standard_port() { - let settings = create_test_settings(); - let body = serde_json::json!({ - "url": "https://cdn.example.com:9443/img/300x250.svg", + #[test] + fn proxy_sign_preserves_non_standard_port() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let body = serde_json::json!({ + "url": "https://cdn.example.com:9443/img/300x250.svg", + }); + let req = build_http_post_json_request("https://edge.example/first-party/sign", &body); + let resp = handle_first_party_proxy_sign(&settings, &noop_services(), req) + .await + .expect("should sign URL with non-standard port"); + assert_eq!(resp.status(), StatusCode::OK); + let json = response_body_string(resp); + // Port 9443 should be preserved (URL-encoded as %3A9443) + assert!( + json.contains("%3A9443"), + "Port should be preserved in signed URL: {}", + json + ); }); - let req = build_http_post_json_request("https://edge.example/first-party/sign", &body); - let resp = handle_first_party_proxy_sign(&settings, &noop_services(), req) - .await - .expect("should sign URL with non-standard port"); - assert_eq!(resp.status(), StatusCode::OK); - let json = response_body_string(resp); - // Port 9443 should be preserved (URL-encoded as %3A9443) - assert!( - json.contains("%3A9443"), - "Port should be preserved in signed URL: {}", - json - ); } #[test] @@ -1498,171 +1508,183 @@ mod tests { ); } - #[tokio::test] - async fn reconstruct_rejects_expired_tsexp() { - use std::time::{Duration, SystemTime, UNIX_EPOCH}; - - let settings = create_test_settings(); - let tsurl = "https://cdn.example/asset.js"; - let expired = SystemTime::now() - .checked_sub(Duration::from_secs(60)) - .unwrap_or(UNIX_EPOCH) - .duration_since(UNIX_EPOCH) - .unwrap_or_else(|_| Duration::from_secs(0)) - .as_secs(); - let canonical = format!("{}?tsexp={}", tsurl, expired); - let sig = crate::http_util::compute_encrypted_sha256_token(&settings, &canonical); - let tsurl_encoded = - url::form_urlencoded::byte_serialize(tsurl.as_bytes()).collect::(); - let url = format!( - "https://edge.example/first-party/proxy?tsurl={}&tsexp={}&tstoken={}", - tsurl_encoded, expired, sig - ); + #[test] + fn reconstruct_rejects_expired_tsexp() { + futures::executor::block_on(async { + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + let settings = create_test_settings(); + let tsurl = "https://cdn.example/asset.js"; + let expired = SystemTime::now() + .checked_sub(Duration::from_secs(60)) + .unwrap_or(UNIX_EPOCH) + .duration_since(UNIX_EPOCH) + .unwrap_or_else(|_| Duration::from_secs(0)) + .as_secs(); + let canonical = format!("{}?tsexp={}", tsurl, expired); + let sig = crate::http_util::compute_encrypted_sha256_token(&settings, &canonical); + let tsurl_encoded = + url::form_urlencoded::byte_serialize(tsurl.as_bytes()).collect::(); + let url = format!( + "https://edge.example/first-party/proxy?tsurl={}&tsexp={}&tstoken={}", + tsurl_encoded, expired, sig + ); - let err: Report = - reconstruct_and_validate_signed_target(&settings, &url) - .expect_err("expected expiration failure"); - assert_eq!(err.current_context().status_code(), StatusCode::BAD_GATEWAY); + let err: Report = + reconstruct_and_validate_signed_target(&settings, &url) + .expect_err("expected expiration failure"); + assert_eq!(err.current_context().status_code(), StatusCode::BAD_GATEWAY); + }); } - #[tokio::test] - async fn reconstruct_rejects_tampered_tstoken() { - let settings = create_test_settings(); - let tsurl = "https://cdn.example/asset.js"; - let tsurl_encoded = - url::form_urlencoded::byte_serialize(tsurl.as_bytes()).collect::(); - // Syntactically valid base64url token of the right length, but not the correct signature - let bad_token = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; - let url = format!( - "https://edge.example/first-party/proxy?tsurl={}&tstoken={}", - tsurl_encoded, bad_token - ); + #[test] + fn reconstruct_rejects_tampered_tstoken() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let tsurl = "https://cdn.example/asset.js"; + let tsurl_encoded = + url::form_urlencoded::byte_serialize(tsurl.as_bytes()).collect::(); + // Syntactically valid base64url token of the right length, but not the correct signature + let bad_token = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + let url = format!( + "https://edge.example/first-party/proxy?tsurl={}&tstoken={}", + tsurl_encoded, bad_token + ); - let err: Report = - reconstruct_and_validate_signed_target(&settings, &url) - .expect_err("should reject tampered token"); - assert_eq!( - err.current_context().status_code(), - StatusCode::FORBIDDEN, - "should return 403 for invalid tstoken" - ); + let err: Report = + reconstruct_and_validate_signed_target(&settings, &url) + .expect_err("should reject tampered token"); + assert_eq!( + err.current_context().status_code(), + StatusCode::FORBIDDEN, + "should return 403 for invalid tstoken" + ); + }); } - #[tokio::test] - async fn click_missing_params_returns_400() { - let settings = create_test_settings(); - let req = build_http_request(Method::GET, "https://edge.example/first-party/click"); - let err: Report = - handle_first_party_click(&settings, &noop_services(), req) - .await - .expect_err("expected error"); - assert_eq!(err.current_context().status_code(), StatusCode::BAD_GATEWAY); + #[test] + fn click_missing_params_returns_400() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let req = build_http_request(Method::GET, "https://edge.example/first-party/click"); + let err: Report = + handle_first_party_click(&settings, &noop_services(), req) + .await + .expect_err("expected error"); + assert_eq!(err.current_context().status_code(), StatusCode::BAD_GATEWAY); + }); } - #[tokio::test] - async fn click_valid_token_redirects() { - let settings = create_test_settings(); - let tsurl = "https://cdn.example/a.png"; - let params = "foo=1&bar=2"; - let full = format!("{}?{}", tsurl, params); - let sig = crate::http_util::compute_encrypted_sha256_token(&settings, &full); - let req = build_http_request( - Method::GET, - format!( - "https://edge.example/first-party/click?tsurl={}&{}&tstoken={}", - url::form_urlencoded::byte_serialize(tsurl.as_bytes()).collect::(), - params, - sig - ), - ); - let resp = handle_first_party_click(&settings, &noop_services(), req) - .await - .expect("should redirect"); - assert_eq!(resp.status(), StatusCode::FOUND); - let loc = resp - .headers() - .get(http::header::LOCATION) - .and_then(|h| h.to_str().ok()) - .unwrap_or(""); - assert_eq!(loc, full); + #[test] + fn click_valid_token_redirects() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let tsurl = "https://cdn.example/a.png"; + let params = "foo=1&bar=2"; + let full = format!("{}?{}", tsurl, params); + let sig = crate::http_util::compute_encrypted_sha256_token(&settings, &full); + let req = build_http_request( + Method::GET, + format!( + "https://edge.example/first-party/click?tsurl={}&{}&tstoken={}", + url::form_urlencoded::byte_serialize(tsurl.as_bytes()).collect::(), + params, + sig + ), + ); + let resp = handle_first_party_click(&settings, &noop_services(), req) + .await + .expect("should redirect"); + assert_eq!(resp.status(), StatusCode::FOUND); + let loc = resp + .headers() + .get(http::header::LOCATION) + .and_then(|h| h.to_str().ok()) + .unwrap_or(""); + assert_eq!(loc, full); + }); } - #[tokio::test] - async fn click_appends_synthetic_id_when_present() { - let settings = create_test_settings(); - let tsurl = "https://cdn.example/a.png"; - let params = "foo=1"; - let full = format!("{}?{}", tsurl, params); - let sig = crate::http_util::compute_encrypted_sha256_token(&settings, &full); - let mut req = build_http_request( - Method::GET, - format!( - "https://edge.example/first-party/click?tsurl={}&{}&tstoken={}", - url::form_urlencoded::byte_serialize(tsurl.as_bytes()).collect::(), - params, - sig - ), - ); - let valid_synthetic_id = crate::test_support::tests::VALID_SYNTHETIC_ID; - req.headers_mut().insert( - crate::constants::HEADER_X_SYNTHETIC_ID, - HeaderValue::from_static(valid_synthetic_id), - ); + #[test] + fn click_appends_synthetic_id_when_present() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let tsurl = "https://cdn.example/a.png"; + let params = "foo=1"; + let full = format!("{}?{}", tsurl, params); + let sig = crate::http_util::compute_encrypted_sha256_token(&settings, &full); + let mut req = build_http_request( + Method::GET, + format!( + "https://edge.example/first-party/click?tsurl={}&{}&tstoken={}", + url::form_urlencoded::byte_serialize(tsurl.as_bytes()).collect::(), + params, + sig + ), + ); + let valid_synthetic_id = crate::test_support::tests::VALID_SYNTHETIC_ID; + req.headers_mut().insert( + crate::constants::HEADER_X_SYNTHETIC_ID, + HeaderValue::from_static(valid_synthetic_id), + ); - let resp = handle_first_party_click(&settings, &noop_services(), req) - .await - .expect("should redirect"); + let resp = handle_first_party_click(&settings, &noop_services(), req) + .await + .expect("should redirect"); - let loc = resp - .headers() - .get(header::LOCATION) - .and_then(|h| h.to_str().ok()) - .expect("Location header should be present and valid"); - let parsed = url::Url::parse(loc).expect("Location should be a valid URL"); - let mut pairs: std::collections::HashMap = parsed - .query_pairs() - .map(|(k, v)| (k.into_owned(), v.into_owned())) - .collect(); - assert_eq!(pairs.remove("foo").as_deref(), Some("1")); - assert_eq!( - pairs.remove("synthetic_id").as_deref(), - Some(valid_synthetic_id) - ); - assert!(pairs.is_empty()); + let loc = resp + .headers() + .get(header::LOCATION) + .and_then(|h| h.to_str().ok()) + .expect("Location header should be present and valid"); + let parsed = url::Url::parse(loc).expect("Location should be a valid URL"); + let mut pairs: std::collections::HashMap = parsed + .query_pairs() + .map(|(k, v)| (k.into_owned(), v.into_owned())) + .collect(); + assert_eq!(pairs.remove("foo").as_deref(), Some("1")); + assert_eq!( + pairs.remove("synthetic_id").as_deref(), + Some(valid_synthetic_id) + ); + assert!(pairs.is_empty()); + }); } - #[tokio::test] - async fn proxy_rebuild_adds_and_removes_params() { - let settings = create_test_settings(); - // Original canonical (no token) - let tsclick = "/first-party/click?tsurl=https%3A%2F%2Fcdn.example%2Flanding.html&x=1"; - let body = serde_json::json!({ - "tsclick": tsclick, - "add": {"y": "2"}, - "del": ["x"], + #[test] + fn proxy_rebuild_adds_and_removes_params() { + futures::executor::block_on(async { + let settings = create_test_settings(); + // Original canonical (no token) + let tsclick = "/first-party/click?tsurl=https%3A%2F%2Fcdn.example%2Flanding.html&x=1"; + let body = serde_json::json!({ + "tsclick": tsclick, + "add": {"y": "2"}, + "del": ["x"], + }); + let req = HttpRequest::builder() + .method(Method::POST) + .uri("https://edge.example/first-party/proxy-rebuild") + .body(EdgeBody::from( + serde_json::to_string(&body).expect("test JSON should serialize"), + )) + .expect("should build proxy rebuild request"); + let resp = handle_first_party_proxy_rebuild(&settings, &noop_services(), req) + .await + .expect("rebuild ok"); + assert_eq!(resp.status(), StatusCode::OK); + let json = response_body_string(resp); + assert!(json.contains("/first-party/click?tsurl=")); + assert!(json.contains("tstoken")); + // Diagnostics + assert!( + json.contains("\"base\":\"https://cdn.example/landing.html\""), + "{}", + json + ); + assert!(json.contains("\"added\":{\"y\":\"2\"}"), "{}", json); + assert!(json.contains("\"removed\":[\"x\"]"), "{}", json); }); - let req = HttpRequest::builder() - .method(Method::POST) - .uri("https://edge.example/first-party/proxy-rebuild") - .body(EdgeBody::from( - serde_json::to_string(&body).expect("test JSON should serialize"), - )) - .expect("should build proxy rebuild request"); - let resp = handle_first_party_proxy_rebuild(&settings, &noop_services(), req) - .await - .expect("rebuild ok"); - assert_eq!(resp.status(), StatusCode::OK); - let json = response_body_string(resp); - assert!(json.contains("/first-party/click?tsurl=")); - assert!(json.contains("tstoken")); - // Diagnostics - assert!( - json.contains("\"base\":\"https://cdn.example/landing.html\""), - "{}", - json - ); - assert!(json.contains("\"added\":{\"y\":\"2\"}"), "{}", json); - assert!(json.contains("\"removed\":[\"x\"]"), "{}", json); } // --- Additional tests covering helper + edge cases --- @@ -1687,86 +1709,98 @@ mod tests { } } - #[tokio::test] - async fn reconstruct_valid_with_params_preserves_order() { - let settings = create_test_settings(); - let clear = "https://cdn.example/asset.js?c=3&b=2&a=1"; - // Simulate creative-generated first-party URL - let first_party = creative::build_proxy_url(&settings, clear); - // Reconstruct and validate (need absolute URL for parsing) - let st = reconstruct_and_validate_signed_target( - &settings, - &format!("https://edge.example{}", first_party), - ) - .expect("reconstruct ok"); - assert_eq!(st.tsurl, "https://cdn.example/asset.js"); - assert!(st.had_params); - assert_eq!(st.target_url, canonical_clear_url(clear)); + #[test] + fn reconstruct_valid_with_params_preserves_order() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let clear = "https://cdn.example/asset.js?c=3&b=2&a=1"; + // Simulate creative-generated first-party URL + let first_party = creative::build_proxy_url(&settings, clear); + // Reconstruct and validate (need absolute URL for parsing) + let st = reconstruct_and_validate_signed_target( + &settings, + &format!("https://edge.example{}", first_party), + ) + .expect("reconstruct ok"); + assert_eq!(st.tsurl, "https://cdn.example/asset.js"); + assert!(st.had_params); + assert_eq!(st.target_url, canonical_clear_url(clear)); + }); } - #[tokio::test] - async fn reconstruct_valid_without_params() { - let settings = create_test_settings(); - let clear = "https://cdn.example/asset.js"; - let first_party = creative::build_proxy_url(&settings, clear); - let st = reconstruct_and_validate_signed_target( - &settings, - &format!("https://edge.example{}", first_party), - ) - .expect("reconstruct ok"); - assert_eq!(st.tsurl, clear); - assert!(!st.had_params); - assert_eq!(st.target_url, clear); + #[test] + fn reconstruct_valid_without_params() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let clear = "https://cdn.example/asset.js"; + let first_party = creative::build_proxy_url(&settings, clear); + let st = reconstruct_and_validate_signed_target( + &settings, + &format!("https://edge.example{}", first_party), + ) + .expect("reconstruct ok"); + assert_eq!(st.tsurl, clear); + assert!(!st.had_params); + assert_eq!(st.target_url, clear); + }); } - #[tokio::test] - async fn proxy_rejects_unsupported_scheme() { - let settings = create_test_settings(); - let clear = "ftp://cdn.example/file.gif"; - // Build a first-party proxy URL with a token for the unsupported scheme - let first_party = creative::build_proxy_url(&settings, clear); - let req = build_http_request(Method::GET, format!("https://edge.example{}", first_party)); - let err: Report = - handle_first_party_proxy(&settings, &noop_services(), req) - .await - .expect_err("expected error"); - assert_eq!(err.current_context().status_code(), StatusCode::BAD_GATEWAY); + #[test] + fn proxy_rejects_unsupported_scheme() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let clear = "ftp://cdn.example/file.gif"; + // Build a first-party proxy URL with a token for the unsupported scheme + let first_party = creative::build_proxy_url(&settings, clear); + let req = + build_http_request(Method::GET, format!("https://edge.example{}", first_party)); + let err: Report = + handle_first_party_proxy(&settings, &noop_services(), req) + .await + .expect_err("expected error"); + assert_eq!(err.current_context().status_code(), StatusCode::BAD_GATEWAY); + }); } - #[tokio::test] - async fn proxy_invalid_target_url_errors() { - let settings = create_test_settings(); - // Intentionally malformed target (host missing) but signed consistently - let tsurl = "https://"; // invalid URL - // Manually construct first-party URL matching creative's format - let full_for_token = tsurl.to_string(); - let sig = crate::http_util::compute_encrypted_sha256_token(&settings, &full_for_token); - let url = format!( - "https://edge.example/first-party/proxy?tsurl={}&tstoken={}", - url::form_urlencoded::byte_serialize(tsurl.as_bytes()).collect::(), - sig - ); - let req = build_http_request(Method::GET, &url); - let err: Report = - handle_first_party_proxy(&settings, &noop_services(), req) - .await - .expect_err("expected error"); - assert_eq!(err.current_context().status_code(), StatusCode::BAD_GATEWAY); + #[test] + fn proxy_invalid_target_url_errors() { + futures::executor::block_on(async { + let settings = create_test_settings(); + // Intentionally malformed target (host missing) but signed consistently + let tsurl = "https://"; // invalid URL + // Manually construct first-party URL matching creative's format + let full_for_token = tsurl.to_string(); + let sig = crate::http_util::compute_encrypted_sha256_token(&settings, &full_for_token); + let url = format!( + "https://edge.example/first-party/proxy?tsurl={}&tstoken={}", + url::form_urlencoded::byte_serialize(tsurl.as_bytes()).collect::(), + sig + ); + let req = build_http_request(Method::GET, &url); + let err: Report = + handle_first_party_proxy(&settings, &noop_services(), req) + .await + .expect_err("expected error"); + assert_eq!(err.current_context().status_code(), StatusCode::BAD_GATEWAY); + }); } - #[tokio::test] - async fn click_sets_cache_control_no_store_private() { - let settings = create_test_settings(); - let clear = "https://cdn.example/landing.html?x=1"; - let first_party = creative::build_click_url(&settings, clear); - let req = build_http_request(Method::GET, format!("https://edge.example{}", first_party)); - let resp = handle_first_party_click(&settings, &noop_services(), req) - .await - .expect("should redirect"); - assert_eq!(resp.status(), StatusCode::FOUND); - let cc = response_header(&resp, header::CACHE_CONTROL).unwrap_or(""); - assert!(cc.contains("no-store")); - assert!(cc.contains("private")); + #[test] + fn click_sets_cache_control_no_store_private() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let clear = "https://cdn.example/landing.html?x=1"; + let first_party = creative::build_click_url(&settings, clear); + let req = + build_http_request(Method::GET, format!("https://edge.example{}", first_party)); + let resp = handle_first_party_click(&settings, &noop_services(), req) + .await + .expect("should redirect"); + assert_eq!(resp.status(), StatusCode::FOUND); + let cc = response_header(&resp, header::CACHE_CONTROL).unwrap_or(""); + assert!(cc.contains("no-store")); + assert!(cc.contains("private")); + }); } // --- Finalization path tests (no network) --- @@ -2060,43 +2094,45 @@ mod tests { // --- Platform HTTP client integration --- - #[tokio::test] - async fn proxy_request_calls_platform_http_client_send() { - use crate::platform::test_support::{build_services_with_http_client, StubHttpClient}; - use std::sync::Arc; - - let stub = Arc::new(StubHttpClient::new()); - stub.push_response(200, b"ok".to_vec()); - let services = build_services_with_http_client( - Arc::clone(&stub) as Arc - ); - let settings = create_test_settings(); - let req = build_http_request(Method::GET, "https://example.com/"); - - let result = proxy_request( - &settings, - req, - ProxyRequestConfig { - target_url: "https://example.com/resource", - follow_redirects: false, - forward_synthetic_id: false, - body: None, - headers: Vec::new(), - copy_request_headers: false, - stream_passthrough: false, - allowed_domains: &[], - }, - &services, - ) - .await; - - assert!(result.is_ok(), "should proxy successfully"); - let calls = stub.recorded_backend_names(); - assert_eq!(calls.len(), 1, "should call send exactly once"); - assert_eq!( - calls[0], "stub-backend", - "should use backend name from StubBackend" - ); + #[test] + fn proxy_request_calls_platform_http_client_send() { + futures::executor::block_on(async { + use crate::platform::test_support::{build_services_with_http_client, StubHttpClient}; + use std::sync::Arc; + + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"ok".to_vec()); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let settings = create_test_settings(); + let req = build_http_request(Method::GET, "https://example.com/"); + + let result = proxy_request( + &settings, + req, + ProxyRequestConfig { + target_url: "https://example.com/resource", + follow_redirects: false, + forward_synthetic_id: false, + body: None, + headers: Vec::new(), + copy_request_headers: false, + stream_passthrough: false, + allowed_domains: &[], + }, + &services, + ) + .await; + + assert!(result.is_ok(), "should proxy successfully"); + let calls = stub.recorded_backend_names(); + assert_eq!(calls.len(), 1, "should call send exactly once"); + assert_eq!( + calls[0], "stub-backend", + "should use backend name from StubBackend" + ); + }); } // --- is_host_allowed --- @@ -2321,36 +2357,38 @@ mod tests { // `redirect_is_permitted` and `ip_literal_blocked_by_domain_allowlist` // cover the blocking logic used at every hop. - #[tokio::test] - async fn proxy_initial_target_blocked_by_allowlist() { - use crate::http_util::compute_encrypted_sha256_token; - - let mut settings = create_test_settings(); - settings.proxy.allowed_domains = vec!["allowed.com".to_string()]; - - let target = "https://blocked.com/pixel.gif"; - let token = compute_encrypted_sha256_token(&settings, target); - let url = format!( - "https://edge.example/first-party/proxy?tsurl={}&tstoken={}", - urlencoding::encode(target), - token, - ); - let req = build_http_request(Method::GET, url); - let services = crate::platform::test_support::noop_services(); - let err = handle_first_party_proxy(&settings, &services, req) - .await - .expect_err("should block initial target not in allowlist"); - assert_eq!( - err.current_context().status_code(), - StatusCode::FORBIDDEN, - "should return 403 for allowlist violation" - ); - assert!( - matches!( - err.current_context(), - TrustedServerError::AllowlistViolation { .. } - ), - "should be AllowlistViolation error" - ); + #[test] + fn proxy_initial_target_blocked_by_allowlist() { + futures::executor::block_on(async { + use crate::http_util::compute_encrypted_sha256_token; + + let mut settings = create_test_settings(); + settings.proxy.allowed_domains = vec!["allowed.com".to_string()]; + + let target = "https://blocked.com/pixel.gif"; + let token = compute_encrypted_sha256_token(&settings, target); + let url = format!( + "https://edge.example/first-party/proxy?tsurl={}&tstoken={}", + urlencoding::encode(target), + token, + ); + let req = build_http_request(Method::GET, url); + let services = crate::platform::test_support::noop_services(); + let err = handle_first_party_proxy(&settings, &services, req) + .await + .expect_err("should block initial target not in allowlist"); + assert_eq!( + err.current_context().status_code(), + StatusCode::FORBIDDEN, + "should return 403 for allowlist violation" + ); + assert!( + matches!( + err.current_context(), + TrustedServerError::AllowlistViolation { .. } + ), + "should be AllowlistViolation error" + ); + }); } } diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 57dd1145..0918f3a0 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -4,7 +4,9 @@ use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; use http::{header, HeaderValue, Request, Response, StatusCode, Uri}; -use crate::consent::{allows_ssc_creation, build_consent_context, ConsentPipelineInput}; +use crate::consent::{ + allows_ssc_creation, build_consent_context, kv::ConsentKvOps, ConsentPipelineInput, +}; use crate::constants::{COOKIE_SYNTHETIC_ID, HEADER_X_COMPRESS_HINT, HEADER_X_SYNTHETIC_ID}; use crate::cookies::handle_request_cookies; use crate::error::TrustedServerError; @@ -314,6 +316,7 @@ pub async fn handle_publisher_request( settings: &Settings, integration_registry: &IntegrationRegistry, services: &RuntimeServices, + kv_ops: Option<&dyn ConsentKvOps>, mut req: Request, ) -> Result, Report> { log::debug!("Proxying request to publisher_origin"); @@ -369,6 +372,7 @@ pub async fn handle_publisher_request( config: &settings.consent, geo: geo.as_ref(), synthetic_id: Some(synthetic_id.as_str()), + kv_ops, }); let ssc_allowed = allows_ssc_creation(&consent_context); log::debug!( @@ -543,8 +547,8 @@ pub async fn handle_publisher_request( "SSC revoked: consent withdrawn (jurisdiction={})", consent_context.jurisdiction, ); - if let Some(store_name) = &settings.consent.consent_store { - crate::consent::kv::delete_consent_from_kv(store_name, cookie_synthetic_id); + if let Some(kv) = kv_ops { + kv.delete_entry(cookie_synthetic_id); } } else { log::warn!( @@ -967,33 +971,35 @@ mod tests { ); } - #[tokio::test] - async fn publisher_request_uses_platform_http_client_with_http_types() { - let settings = create_test_settings(); - let registry = - IntegrationRegistry::new(&settings).expect("should create integration registry"); - let stub = Arc::new(StubHttpClient::new()); - stub.push_response(200, b"origin response".to_vec()); - let services = build_services_with_http_client( - Arc::clone(&stub) as Arc - ); - let req = HttpRequest::builder() - .method(Method::GET) - .uri("https://publisher.example/page") - .header(header::HOST, "publisher.example") - .body(EdgeBody::empty()) - .expect("should build request"); - - let response = handle_publisher_request(&settings, ®istry, &services, req) - .await - .expect("should proxy publisher request"); - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response_body_string(response), "origin response"); - assert_eq!( - stub.recorded_backend_names(), - vec!["stub-backend".to_string()], - "should proxy through the platform http client" - ); + #[test] + fn publisher_request_uses_platform_http_client_with_http_types() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"origin response".to_vec()); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let req = HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/page") + .header(header::HOST, "publisher.example") + .body(EdgeBody::empty()) + .expect("should build request"); + + let response = handle_publisher_request(&settings, ®istry, &services, None, req) + .await + .expect("should proxy publisher request"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response_body_string(response), "origin response"); + assert_eq!( + stub.recorded_backend_names(), + vec!["stub-backend".to_string()], + "should proxy through the platform http client" + ); + }); } } diff --git a/crates/trusted-server-core/src/storage/config_store.rs b/crates/trusted-server-core/src/storage/config_store.rs deleted file mode 100644 index cc396f73..00000000 --- a/crates/trusted-server-core/src/storage/config_store.rs +++ /dev/null @@ -1,159 +0,0 @@ -//! Fastly-backed config store (legacy). -//! -//! This module holds the pre-platform [`FastlyConfigStore`] type. -//! New code should use [`crate::platform::PlatformConfigStore`] via -//! [`crate::platform::RuntimeServices`] instead. This type will be removed -//! once all call sites have migrated. - -use core::fmt::Display; - -use error_stack::Report; -use fastly::ConfigStore; - -use crate::error::TrustedServerError; - -// TODO: Deduplicate this transitional helper with -// trusted-server-adapter-fastly/src/platform.rs:get_config_value once -// FastlyConfigStore is removed. -trait ConfigStoreReader { - type LookupError: Display; - - fn try_get(&self, key: &str) -> Result, Self::LookupError>; -} - -impl ConfigStoreReader for ConfigStore { - type LookupError = fastly::config_store::LookupError; - - fn try_get(&self, key: &str) -> Result, Self::LookupError> { - ConfigStore::try_get(self, key) - } -} - -fn load_config_value( - store_name: &str, - key: &str, - open_store: Open, -) -> Result> -where - S: ConfigStoreReader, - Open: FnOnce(&str) -> Result, - OpenError: Display, -{ - let store = open_store(store_name).map_err(|error| { - Report::new(TrustedServerError::Configuration { - message: format!("failed to open config store '{store_name}': {error}"), - }) - })?; - - store - .try_get(key) - .map_err(|error| { - Report::new(TrustedServerError::Configuration { - message: format!("lookup for key '{key}' failed: {error}"), - }) - })? - .ok_or_else(|| { - Report::new(TrustedServerError::Configuration { - message: format!("key '{key}' not found in config store '{store_name}'"), - }) - }) -} - -/// Fastly-backed config store with the store name baked in at construction. -/// -/// # Migration note -/// -/// This type predates the `platform` abstraction. New code should use -/// [`crate::platform::PlatformConfigStore`] via [`crate::platform::RuntimeServices`] -/// instead. `FastlyConfigStore` will be removed once all call sites have -/// migrated. -pub struct FastlyConfigStore { - store_name: String, -} - -impl FastlyConfigStore { - /// Create a new config store handle for the named store. - pub fn new(store_name: impl Into) -> Self { - Self { - store_name: store_name.into(), - } - } - - /// Retrieves a configuration value from the store. - /// - /// # Errors - /// - /// Returns an error if the key is not found in the config store. - pub fn get(&self, key: &str) -> Result> { - load_config_value::(&self.store_name, key, ConfigStore::try_open) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - struct StubConfigStore { - value: Result, &'static str>, - } - - impl ConfigStoreReader for StubConfigStore { - type LookupError = &'static str; - - fn try_get(&self, _key: &str) -> Result, Self::LookupError> { - self.value.clone() - } - } - - #[test] - fn config_store_new_stores_name() { - let store = FastlyConfigStore::new("test_store"); - assert_eq!( - store.store_name, "test_store", - "should store the store name" - ); - } - - #[test] - fn load_config_value_returns_error_when_open_fails() { - let err = load_config_value::("jwks_store", "current-kid", |_| { - Err("open failed") - }) - .expect_err("should return an error when the store cannot be opened"); - - assert!( - err.to_string().contains("failed to open config store"), - "should describe the open failure" - ); - } - - #[test] - fn load_config_value_returns_error_when_lookup_fails() { - let err = load_config_value::("jwks_store", "current-kid", |_| { - Ok::(StubConfigStore { - value: Err("lookup failed"), - }) - }) - .expect_err("should return an error when lookup fails"); - - assert!( - err.to_string() - .contains("lookup for key 'current-kid' failed"), - "should describe the lookup failure" - ); - } - - #[test] - fn load_config_value_returns_error_when_key_is_missing() { - let err = load_config_value::("jwks_store", "current-kid", |_| { - Ok::(StubConfigStore { value: Ok(None) }) - }) - .expect_err("should return an error when the key is absent"); - - assert!( - err.to_string() - .contains("key 'current-kid' not found in config store 'jwks_store'"), - "should describe the missing key" - ); - } -} diff --git a/crates/trusted-server-core/src/storage/mod.rs b/crates/trusted-server-core/src/storage/mod.rs deleted file mode 100644 index b0679b9d..00000000 --- a/crates/trusted-server-core/src/storage/mod.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! Legacy Fastly-backed store types. -//! -//! These types predate the [`crate::platform`] abstraction and will be removed -//! once all call sites have migrated to the platform traits. New code should -//! use [`crate::platform::PlatformConfigStore`] and -//! [`crate::platform::PlatformSecretStore`] via [`crate::platform::RuntimeServices`]. - -pub(crate) mod config_store; -pub(crate) mod secret_store; - -pub use config_store::FastlyConfigStore; -pub use secret_store::FastlySecretStore; diff --git a/crates/trusted-server-core/src/storage/secret_store.rs b/crates/trusted-server-core/src/storage/secret_store.rs deleted file mode 100644 index f2dd7b91..00000000 --- a/crates/trusted-server-core/src/storage/secret_store.rs +++ /dev/null @@ -1,181 +0,0 @@ -//! Fastly-backed secret store (legacy). -//! -//! This module holds the pre-platform [`FastlySecretStore`] type. -//! New code should use [`crate::platform::PlatformSecretStore`] via -//! [`crate::platform::RuntimeServices`] instead. This type will be removed -//! once all call sites have migrated. - -use core::fmt::Display; - -use error_stack::{Report, ResultExt}; -use fastly::SecretStore; - -use crate::error::TrustedServerError; - -#[derive(Clone)] -enum SecretReadError { - Lookup(LookupError), - Decrypt(DecryptError), -} - -type SecretBytesResult = - Result>, SecretReadError>; - -trait SecretStoreReader: Sized { - type LookupError: Display; - type DecryptError: Display; - - fn try_get_bytes(&self, key: &str) -> SecretBytesResult; -} - -impl SecretStoreReader for SecretStore { - type LookupError = fastly::secret_store::LookupError; - type DecryptError = fastly::secret_store::DecryptError; - - fn try_get_bytes(&self, key: &str) -> SecretBytesResult { - let secret = self.try_get(key).map_err(SecretReadError::Lookup)?; - let Some(secret) = secret else { - return Ok(None); - }; - - secret - .try_plaintext() - .map(|bytes| Some(bytes.into_iter().collect())) - .map_err(SecretReadError::Decrypt) - } -} - -fn get_secret_bytes( - store_name: &str, - key: &str, - open_store: Open, -) -> Result, Report> -where - S: SecretStoreReader, - Open: FnOnce() -> Result, - OpenError: Display, -{ - let store = open_store().map_err(|error| { - Report::new(TrustedServerError::Configuration { - message: format!("failed to open secret store '{store_name}': {error}"), - }) - })?; - - store - .try_get_bytes(key) - .map_err(|error| match error { - SecretReadError::Lookup(error) => Report::new(TrustedServerError::Configuration { - message: format!( - "lookup for secret '{key}' in secret store '{store_name}' failed: {error}" - ), - }), - SecretReadError::Decrypt(error) => Report::new(TrustedServerError::Configuration { - message: format!("failed to decrypt secret '{key}': {error}"), - }), - })? - .ok_or_else(|| { - Report::new(TrustedServerError::Configuration { - message: format!("secret '{key}' not found in secret store '{store_name}'"), - }) - }) -} - -/// Fastly-backed secret store with the store name baked in at construction. -/// -/// # Migration note -/// -/// This type predates the `platform` abstraction. New code should use -/// [`crate::platform::PlatformSecretStore`] via [`crate::platform::RuntimeServices`] -/// instead. `FastlySecretStore` will be removed once all call sites have -/// migrated. -pub struct FastlySecretStore { - store_name: String, -} - -impl FastlySecretStore { - /// Create a new secret store handle for the named store. - pub fn new(store_name: impl Into) -> Self { - Self { - store_name: store_name.into(), - } - } - - /// Retrieves a secret value as raw bytes from the store. - /// - /// # Errors - /// - /// Returns an error if the secret store cannot be opened, the key is not - /// found, or the plaintext cannot be retrieved. - pub fn get(&self, key: &str) -> Result, Report> { - get_secret_bytes::(&self.store_name, key, || { - SecretStore::open(&self.store_name) - }) - } - - /// Retrieves a secret value from the store and decodes it as a UTF-8 string. - /// - /// # Errors - /// - /// Returns an error if the secret cannot be retrieved or is not valid UTF-8. - pub fn get_string(&self, key: &str) -> Result> { - let bytes = self.get(key)?; - String::from_utf8(bytes).change_context(TrustedServerError::Configuration { - message: "failed to decode secret as UTF-8".to_string(), - }) - } -} - -#[cfg(test)] -mod tests { - use core::fmt::{self, Display}; - - use super::*; - - struct StubSecretStore { - value: SecretBytesResult<&'static str, &'static str>, - } - - impl SecretStoreReader for StubSecretStore { - type LookupError = &'static str; - type DecryptError = &'static str; - - fn try_get_bytes( - &self, - _key: &str, - ) -> SecretBytesResult { - self.value.clone() - } - } - - #[derive(Clone)] - struct StubOpenError(&'static str); - - impl Display for StubOpenError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.0) - } - } - - #[test] - fn secret_store_new_stores_name() { - let store = FastlySecretStore::new("test_secrets"); - assert_eq!( - store.store_name, "test_secrets", - "should store the store name" - ); - } - - #[test] - fn get_secret_bytes_includes_open_error_details() { - let err = get_secret_bytes::("signing_keys", "active", || { - Err(StubOpenError("permission denied")) - }) - .expect_err("should return an error when the secret store cannot be opened"); - - assert!( - err.to_string() - .contains("failed to open secret store 'signing_keys': permission denied"), - "should preserve the original open error message" - ); - } -} diff --git a/crates/trusted-server-core/src/streaming_processor.rs b/crates/trusted-server-core/src/streaming_processor.rs index 3bdca7f2..8a4ec2fe 100644 --- a/crates/trusted-server-core/src/streaming_processor.rs +++ b/crates/trusted-server-core/src/streaming_processor.rs @@ -11,7 +11,7 @@ //! This module is **platform-agnostic** (verified in PR 8). It has zero //! `fastly` imports. [`StreamingPipeline::process`] is generic over //! `R: Read + W: Write` — any reader or writer works, including -//! `fastly::Body` (which implements `std::io::Read`) or standard +//! any platform body type (which implements `std::io::Read`) or standard //! `std::io::Cursor<&[u8]>`. //! //! Future adapters (PR 16/17) do not need to implement any compression or diff --git a/docs/superpowers/plans/2026-04-14-edgezero-pr15-remove-fastly-core.md b/docs/superpowers/plans/2026-04-14-edgezero-pr15-remove-fastly-core.md new file mode 100644 index 00000000..683d3db6 --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-edgezero-pr15-remove-fastly-core.md @@ -0,0 +1,600 @@ +# Remove Fastly from Core Crate — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove every `fastly` crate import and the runtime `tokio` dependency from `trusted-server-core`, relocating Fastly-specific code to `trusted-server-adapter-fastly`. + +**Architecture:** Core becomes fully platform-agnostic — it owns domain types, platform traits, and business logic. Adapter owns all Fastly SDK interactions. Four concrete moves: (1) move compat conversion functions inline to adapter and delete core's `compat.rs`; (2) move `geo_from_fastly` from core's `geo.rs` into adapter's `platform.rs`; (3) move `backend.rs` wholesale to the adapter; (4) delete the legacy `storage` module (`FastlyConfigStore`, `FastlySecretStore`) whose call sites have already migrated to platform traits. Finally, move `tokio` to `[dev-dependencies]` (test-only usage) and drop `fastly` from core's `Cargo.toml`. + +**Tech Stack:** Rust 2024 edition, `fastly` 0.11.12, `edgezero-adapter-fastly`, `error-stack`, `derive_more`. + +**Resolves:** [IABTechLab/trusted-server#496](https://github.com/IABTechLab/trusted-server/issues/496). Blocked by PR 14. Part of #480. + +--- + +## Pre-flight: Code Locations to Understand + +Read these before starting — do not guess: + +| What to read | Path | Why | +|---|---|---| +| Core Cargo.toml | `crates/trusted-server-core/Cargo.toml` | Exact dep names to remove | +| Core lib.rs | `crates/trusted-server-core/src/lib.rs` | Module declarations to remove | +| Adapter main.rs | `crates/trusted-server-adapter-fastly/src/main.rs` | `compat::` call sites (lines 12, 159, 169, 182) | +| Core compat.rs | `crates/trusted-server-core/src/compat.rs` | Functions to port | +| Core geo.rs | `crates/trusted-server-core/src/geo.rs` | `geo_from_fastly` impl (lines 25–35) | +| Core backend.rs | `crates/trusted-server-core/src/backend.rs` | Entire module to port | +| Adapter platform.rs | `crates/trusted-server-adapter-fastly/src/platform.rs` | Import lines to update (17, 18, 362) | +| Adapter management_api.rs | `crates/trusted-server-adapter-fastly/src/management_api.rs` | `BackendConfig` import (line 55) | +| Core consent/kv.rs | `crates/trusted-server-core/src/consent/kv.rs` | Verify any `fastly::kv_store` usage | + +--- + +## File Map + +### Files to **delete** from `crates/trusted-server-core/src/` +- `compat.rs` — Fastly conversion scaffolding, scheduled for deletion in PR 15 +- `backend.rs` — Fastly-coupled backend builder, moved to adapter +- `storage/config_store.rs` — Legacy `FastlyConfigStore` (call sites migrated to platform traits) +- `storage/secret_store.rs` — Legacy `FastlySecretStore` (call sites migrated to platform traits) +- `storage/mod.rs` — Empty after above deletions + +### Files to **modify** in `crates/trusted-server-core/src/` +- `lib.rs` — Remove `pub mod compat;`, `pub mod backend;`, `pub mod storage;` +- `geo.rs` — Remove `use fastly::geo::Geo;` and `pub fn geo_from_fastly` + +### Files to **create** in `crates/trusted-server-adapter-fastly/src/` +- `compat.rs` — The 3 conversion functions that adapter's `main.rs` needs +- `backend.rs` — Full `BackendConfig` moved from core + +### Files to **modify** in `crates/trusted-server-adapter-fastly/src/` +- `main.rs` — Add `mod compat;`, update import from `trusted_server_core::compat` to `crate::compat` +- `platform.rs` — Remove `use trusted_server_core::geo::geo_from_fastly;`, add inline private function; remove `use trusted_server_core::backend::BackendConfig;`, add `use crate::backend::BackendConfig;` +- `management_api.rs` — Update `use trusted_server_core::backend::BackendConfig` → `use crate::backend::BackendConfig` + +### Files to **modify** (Cargo.toml) +- `crates/trusted-server-core/Cargo.toml` — Remove `fastly`, move `tokio` → `[dev-dependencies]` + +--- + +## Task 1: Create the PR15 Branch + +**Files:** none (git only) + +- [ ] **Step 1.1: Verify you are on the PR14 branch** + +```bash +git branch --show-current +# Expected: feature/edgezero-pr14-entry-point-dual-path +``` + +- [ ] **Step 1.2: Create and checkout PR15 branch** + +```bash +git checkout -b feature/edgezero-pr15-remove-fastly-core +``` + +- [ ] **Step 1.3: Verify baseline build passes** + +```bash +cargo check --workspace 2>&1 | tail -5 +``` + +Expected: `Finished` with no errors. + +--- + +## Task 2: Move `compat` Functions to Adapter, Delete Core's `compat.rs` + +**Context:** Adapter's `main.rs` uses `trusted_server_core::compat` for 3 functions in `legacy_main()`: `sanitize_fastly_forwarded_headers`, `from_fastly_request`, and `to_fastly_response`. All three deal with `fastly::Request` / `fastly::Response` — they belong in the adapter. The remaining ~8 functions in core's `compat.rs` are unused by the adapter and can be dropped entirely. + +**Files:** +- Create: `crates/trusted-server-adapter-fastly/src/compat.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/main.rs` +- Delete: `crates/trusted-server-core/src/compat.rs` +- Modify: `crates/trusted-server-core/src/lib.rs` + +- [ ] **Step 2.1: Read core's `compat.rs` fully** + +Read `crates/trusted-server-core/src/compat.rs` lines 1–560. You need the exact implementations of: +- `sanitize_fastly_forwarded_headers` — strips spoofable forwarded headers from a `fastly::Request` +- `from_fastly_request` — converts owned `fastly::Request` → `http::Request` +- `to_fastly_response` — converts `http::Response` → `fastly::Response` + +Copy their `use` imports too (they use `fastly::http::header`, `edgezero_core::body::Body as EdgeBody`, `http`, etc.). + +- [ ] **Step 2.2: Create `crates/trusted-server-adapter-fastly/src/compat.rs`** + +Create the file with ONLY the 3 functions the adapter needs, plus their imports. Do not port the unused conversion functions. Pattern: + +```rust +//! Fastly ↔ http type conversion helpers used by the adapter entry point. + +use edgezero_core::body::Body as EdgeBody; +use fastly::http::header; +use http::{Request, Response}; + +// ... (copy exact implementations from core's compat.rs for the 3 functions) + +pub(crate) fn sanitize_fastly_forwarded_headers(req: &mut fastly::Request) { + // ... (copy from core) +} + +pub(crate) fn from_fastly_request(req: fastly::Request) -> Request { + // ... (copy from core) +} + +pub(crate) fn to_fastly_response(response: Response) -> fastly::Response { + // ... (copy from core) +} +``` + +- [ ] **Step 2.3: Declare the module in adapter's `main.rs`** + +Add `mod compat;` near the top of `crates/trusted-server-adapter-fastly/src/main.rs` (after other `mod` declarations). Update the import line: + +```rust +// Remove: +use trusted_server_core::compat; + +// After adding `mod compat;` above, the existing call sites +// `compat::sanitize_fastly_forwarded_headers`, `compat::from_fastly_request`, +// `compat::to_fastly_response` continue to work unchanged — they now resolve +// to the local module. +``` + +- [ ] **Step 2.4: `cargo check` the adapter to verify compat compiles** + +```bash +cargo check -p trusted-server-adapter-fastly --target wasm32-wasip1 2>&1 | grep -E "^error" +``` + +Expected: no errors related to `compat`. + +- [ ] **Step 2.5: Delete core's `compat.rs`** + +```bash +rm crates/trusted-server-core/src/compat.rs +``` + +- [ ] **Step 2.6: Remove `pub mod compat;` from core's `lib.rs`** + +Find and remove the line `pub mod compat;` in `crates/trusted-server-core/src/lib.rs`. + +- [ ] **Step 2.7: `cargo check` workspace** + +```bash +cargo check --workspace 2>&1 | grep -E "^error" +``` + +Expected: no errors. + +- [ ] **Step 2.8: Commit** + +```bash +git add crates/trusted-server-adapter-fastly/src/compat.rs \ + crates/trusted-server-adapter-fastly/src/main.rs \ + crates/trusted-server-core/src/lib.rs +git rm crates/trusted-server-core/src/compat.rs +git commit -m "Move compat conversion fns to adapter, delete core compat.rs" +``` + +--- + +## Task 3: Move `geo_from_fastly` to Adapter + +**Context:** Core's `geo.rs` imports `fastly::geo::Geo` solely for `geo_from_fastly`. The adapter's `platform.rs` (line 18) imports this function from core and calls it at line 362. Moving it inline into `platform.rs` as a `pub(crate)` or private function is the minimal change — no new file required. + +**Files:** +- Modify: `crates/trusted-server-core/src/geo.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/platform.rs` + +- [ ] **Step 3.1: Read core `geo.rs` lines 1–50** + +Capture the exact implementation of `geo_from_fastly` (lines 25–35): + +```rust +pub fn geo_from_fastly(geo: &Geo) -> GeoInfo { + GeoInfo { + city: geo.city().to_string(), + country: geo.country_code().to_string(), + continent: format!("{:?}", geo.continent()), + latitude: geo.latitude(), + longitude: geo.longitude(), + metro_code: geo.metro_code(), + region: geo.region().map(str::to_string), + } +} +``` + +- [ ] **Step 3.2: Add `geo_from_fastly` as a private function in adapter's `platform.rs`** + +In `crates/trusted-server-adapter-fastly/src/platform.rs`, directly above the existing `FastlyPlatformGeo` impl block that calls `geo_from_fastly` (around line 362), add: + +```rust +use fastly::geo::Geo; + +fn geo_from_fastly(geo: &Geo) -> GeoInfo { + GeoInfo { + city: geo.city().to_string(), + country: geo.country_code().to_string(), + continent: format!("{:?}", geo.continent()), + latitude: geo.latitude(), + longitude: geo.longitude(), + metro_code: geo.metro_code(), + region: geo.region().map(str::to_string), + } +} +``` + +Then remove the import line `use trusted_server_core::geo::geo_from_fastly;` (line 18 of `platform.rs`). + +- [ ] **Step 3.3: Remove `geo_from_fastly` and the fastly import from core's `geo.rs`** + +In `crates/trusted-server-core/src/geo.rs`: +- Remove: `use fastly::geo::Geo;` +- Remove: the entire `pub fn geo_from_fastly(geo: &Geo) -> GeoInfo { ... }` function and its doc comment + +Keep `GeoInfo` re-export, header injection helpers, and all tests. + +- [ ] **Step 3.4: `cargo check` workspace** + +```bash +cargo check --workspace 2>&1 | grep -E "^error" +``` + +Expected: no errors. + +- [ ] **Step 3.5: Commit** + +```bash +git add crates/trusted-server-core/src/geo.rs \ + crates/trusted-server-adapter-fastly/src/platform.rs +git commit -m "Move geo_from_fastly from core to adapter platform" +``` + +--- + +## Task 4: Move `BackendConfig` to Adapter + +**Context:** Core's `backend.rs` exists solely to create dynamic Fastly backends (`fastly::backend::Backend`). Both `platform.rs` (line 17) and `management_api.rs` (line 55) in the adapter import `BackendConfig` from core. Moving the entire module to the adapter is a clean cut with minimal ripple. + +**Files:** +- Create: `crates/trusted-server-adapter-fastly/src/backend.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/main.rs` (add `mod backend;`) +- Modify: `crates/trusted-server-adapter-fastly/src/platform.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/management_api.rs` +- Delete: `crates/trusted-server-core/src/backend.rs` +- Modify: `crates/trusted-server-core/src/lib.rs` + +- [ ] **Step 4.1: Read core's `backend.rs` fully** + +Read `crates/trusted-server-core/src/backend.rs` (lines 1–465). Note the imports — it uses `fastly::backend::Backend`, `error_stack`, `url::Url`, and `crate::error::TrustedServerError`. The last import becomes `trusted_server_core::error::TrustedServerError` after the move. + +- [ ] **Step 4.2: Create `crates/trusted-server-adapter-fastly/src/backend.rs`** + +Copy the entire content of core's `backend.rs` verbatim, then update the one internal import: + +```rust +// Change: +use crate::error::TrustedServerError; +// To: +use trusted_server_core::error::TrustedServerError; +``` + +No other changes needed. + +- [ ] **Step 4.3: Declare the module in adapter's `main.rs`** + +Add `mod backend;` to `crates/trusted-server-adapter-fastly/src/main.rs`. + +- [ ] **Step 4.4: Update imports in `platform.rs` and `management_api.rs`** + +In `crates/trusted-server-adapter-fastly/src/platform.rs` (line 17): +```rust +// Remove: +use trusted_server_core::backend::BackendConfig; +// Add: +use crate::backend::BackendConfig; +``` + +In `crates/trusted-server-adapter-fastly/src/management_api.rs` (line 55): +```rust +// Remove: +use trusted_server_core::backend::BackendConfig; +// Add: +use crate::backend::BackendConfig; +``` + +- [ ] **Step 4.5: `cargo check` the adapter** + +```bash +cargo check -p trusted-server-adapter-fastly --target wasm32-wasip1 2>&1 | grep -E "^error" +``` + +Expected: no errors. + +- [ ] **Step 4.6: Delete core's `backend.rs` and remove module declaration** + +```bash +git rm crates/trusted-server-core/src/backend.rs +``` + +Remove `pub mod backend;` from `crates/trusted-server-core/src/lib.rs`. + +- [ ] **Step 4.7: `cargo check` workspace** + +```bash +cargo check --workspace 2>&1 | grep -E "^error" +``` + +Expected: no errors. + +- [ ] **Step 4.8: Commit** + +```bash +git add crates/trusted-server-adapter-fastly/src/backend.rs \ + crates/trusted-server-adapter-fastly/src/main.rs \ + crates/trusted-server-adapter-fastly/src/platform.rs \ + crates/trusted-server-adapter-fastly/src/management_api.rs \ + crates/trusted-server-core/src/lib.rs +git rm crates/trusted-server-core/src/backend.rs +git commit -m "Move BackendConfig from core to adapter backend module" +``` + +--- + +## Task 5: Delete Legacy Storage Module + +**Context:** `crates/trusted-server-core/src/storage/` exports `FastlyConfigStore` and `FastlySecretStore`. The adapter does not import either — it uses the platform traits (`PlatformConfigStore`, `PlatformSecretStore`) directly. Core's `platform/mod.rs` is also trait-only and has no dependency on these legacy types. The storage doc comment confirms: "will be removed once all call sites have migrated to platform traits." + +**Files:** +- Delete: `crates/trusted-server-core/src/storage/config_store.rs` +- Delete: `crates/trusted-server-core/src/storage/secret_store.rs` +- Delete: `crates/trusted-server-core/src/storage/mod.rs` +- Modify: `crates/trusted-server-core/src/lib.rs` + +- [ ] **Step 5.1: Confirm no external callers before deleting** + +```bash +grep -r "FastlyConfigStore\|FastlySecretStore\|trusted_server_core::storage" \ + crates/trusted-server-adapter-fastly/src/ \ + crates/trusted-server-core/src/ +``` + +Expected: zero results (or only the definitions themselves). If any callers appear outside `storage/`, stop and investigate before continuing. + +- [ ] **Step 5.2: Delete the storage module** + +```bash +git rm crates/trusted-server-core/src/storage/config_store.rs \ + crates/trusted-server-core/src/storage/secret_store.rs \ + crates/trusted-server-core/src/storage/mod.rs +``` + +- [ ] **Step 5.3: Remove `pub mod storage;` from core's `lib.rs`** + +- [ ] **Step 5.4: `cargo check` workspace** + +```bash +cargo check --workspace 2>&1 | grep -E "^error" +``` + +Expected: no errors. + +- [ ] **Step 5.5: Commit** + +```bash +git add crates/trusted-server-core/src/lib.rs +git rm crates/trusted-server-core/src/storage/config_store.rs \ + crates/trusted-server-core/src/storage/secret_store.rs \ + crates/trusted-server-core/src/storage/mod.rs +git commit -m "Delete legacy FastlyConfigStore and FastlySecretStore from core" +``` + +--- + +## Task 6: Audit and Fix `consent/kv.rs` Fastly Usage + +**Context:** The initial audit flagged possible `fastly::kv_store::KVStore` usage at line 230 of `consent/kv.rs`. The top of the file (lines 1–50) shows no fastly imports — the reference may be via fully-qualified path or may have been a hallucination. Verify before removing `fastly` from Cargo.toml. + +**Files:** +- Inspect: `crates/trusted-server-core/src/consent/kv.rs` +- Possibly modify: same file + +- [ ] **Step 6.1: Search for fastly usage in consent/kv.rs** + +```bash +grep -n "fastly" crates/trusted-server-core/src/consent/kv.rs +``` + +- [ ] **Step 6.2a (if grep returns nothing): No action needed.** The file is clean. Proceed to Task 7. + +- [ ] **Step 6.2b (if fastly:: appears): Investigate and move** + +Read the lines around each match. The KV store usage in consent likely goes through the `PlatformKvStore` trait (from `edgezero-core`). If raw `fastly::kv_store::KVStore` calls exist: +- Understand what function uses it (likely `open_store` or `fingerprint_unchanged`) +- Move that function to adapter's consent integration or abstract via a trait closure / callback passed in from the adapter +- The goal is zero `fastly::` references in core + +- [ ] **Step 6.3: `cargo check` after any changes** + +```bash +cargo check --workspace 2>&1 | grep -E "^error" +``` + +- [ ] **Step 6.4: Commit if changes were made** + +```bash +git add crates/trusted-server-core/src/consent/kv.rs +git commit -m "Remove fastly::kv_store usage from core consent module" +``` + +--- + +## Task 7: Move Tokio to Dev-Dependencies + +**Context:** `tokio` appears in `[dependencies]` (line 45 of core's `Cargo.toml`). The audit found zero tokio usage in production code — all 30 uses are `#[tokio::test]` attributes in test modules. Moving it to `[dev-dependencies]` removes it from the production dependency graph for wasm builds. + +**Files:** +- Modify: `crates/trusted-server-core/Cargo.toml` + +- [ ] **Step 7.1: Confirm no production tokio usage** + +```bash +grep -n "tokio::" crates/trusted-server-core/src/*.rs \ + crates/trusted-server-core/src/**/*.rs 2>/dev/null | \ + grep -v "#\[cfg(test\|#\[tokio::test" +``` + +Expected: no results. If any appear, investigate and refactor before proceeding. + +- [ ] **Step 7.2: Move `tokio` from `[dependencies]` to `[dev-dependencies]`** + +In `crates/trusted-server-core/Cargo.toml`: + +Remove from `[dependencies]`: +```toml +tokio = { workspace = true } +``` + +Add to `[dev-dependencies]` (alongside `tokio-test`): +```toml +tokio = { workspace = true } +``` + +The `tokio-test` entry should already be in `[dev-dependencies]`. The result is both under `[dev-dependencies]`. + +- [ ] **Step 7.3: `cargo check` workspace (native)** + +```bash +cargo check --workspace 2>&1 | grep -E "^error" +``` + +- [ ] **Step 7.4: `cargo test` to verify tests still compile and run** + +```bash +cargo test --workspace 2>&1 | tail -20 +``` + +Expected: all tests pass. + +- [ ] **Step 7.5: Commit** + +```bash +git add crates/trusted-server-core/Cargo.toml +git commit -m "Move tokio to dev-dependencies in core (test-only usage)" +``` + +--- + +## Task 8: Remove `fastly` from Core's `Cargo.toml` + +**Context:** After Tasks 2–6, core should have zero `fastly::` references. Now remove the dependency. + +**Files:** +- Modify: `crates/trusted-server-core/Cargo.toml` + +- [ ] **Step 8.1: Confirm zero remaining fastly references in core** + +```bash +grep -rn "fastly" crates/trusted-server-core/src/ --exclude=migration_guards.rs +``` + +Expected: zero results. `migration_guards.rs` is deliberately excluded — it contains `"fastly::Request"` etc. as **string literals** in a `#[test]` function (guard patterns), not actual imports. Any matches in that file are expected and not a failure. + +Also check for `log-fastly` (spec says to remove it if present): + +```bash +grep "log-fastly" crates/trusted-server-core/Cargo.toml +``` + +If `log-fastly` appears, remove it alongside `fastly` in the next step. + +- [ ] **Step 8.2: Remove `fastly` (and `log-fastly` if present) from core's `Cargo.toml`** + +In `crates/trusted-server-core/Cargo.toml`, remove: +```toml +fastly = { workspace = true } +# Also remove if present: +# log-fastly = { workspace = true } +``` + +- [ ] **Step 8.3: `cargo check` workspace (native)** + +```bash +cargo check --workspace 2>&1 | grep -E "^error" +``` + +- [ ] **Step 8.4: `cargo check` for wasm target** + +```bash +cargo check -p trusted-server-adapter-fastly --target wasm32-wasip1 2>&1 | grep -E "^error" +``` + +Expected: no errors on either target. + +- [ ] **Step 8.5: Commit** + +```bash +git add crates/trusted-server-core/Cargo.toml +git commit -m "Remove fastly dependency from trusted-server-core" +``` + +--- + +## Task 9: Full Verification + +- [ ] **Step 9.1: Run clippy** + +```bash +cargo clippy --workspace --all-targets --all-features -- -D warnings 2>&1 | grep -E "^error" +``` + +Fix any warnings that become errors. + +- [ ] **Step 9.2: Run all tests** + +```bash +cargo test --workspace 2>&1 | tail -30 +``` + +Expected: all tests pass. + +- [ ] **Step 9.3: Run JS tests** + +```bash +cd crates/js/lib && npx vitest run +``` + +- [ ] **Step 9.4: Verify the "done when" criteria** + +```bash +# Zero fastly imports in core: +grep -rn "fastly" crates/trusted-server-core/src/ && echo "FAIL: fastly refs remain" || echo "PASS: core is fastly-free" + +# Zero tokio in core [dependencies]: +grep "tokio" crates/trusted-server-core/Cargo.toml + +# compat.rs deleted: +ls crates/trusted-server-core/src/compat.rs 2>/dev/null && echo "FAIL: compat.rs still exists" || echo "PASS: compat.rs deleted" +``` + +- [ ] **Step 9.5: Final commit if any lint fixes were needed** + +```bash +git add -p # stage only lint fixes +git commit -m "Fix clippy warnings after fastly removal" +``` + +--- + +## Done When + +- `grep -rn "use fastly" crates/trusted-server-core/src/` → zero results +- `grep -rn "fastly::" crates/trusted-server-core/src/` → zero results +- `tokio` no longer in `[dependencies]` section of core's `Cargo.toml` (only `[dev-dependencies]`) +- `crates/trusted-server-core/src/compat.rs` does not exist +- `cargo test --workspace` passes +- `cargo clippy --workspace --all-targets --all-features -- -D warnings` passes +- `cargo check -p trusted-server-adapter-fastly --target wasm32-wasip1` passes