diff --git a/Cargo.lock b/Cargo.lock index 55289366e..d403b1e87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1121,8 +1121,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -2672,6 +2674,7 @@ version = "0.1.0" dependencies = [ "async-trait", "base64", + "bytes", "chrono", "edgezero-adapter-fastly", "edgezero-core", @@ -2684,6 +2687,8 @@ dependencies = [ "serde", "serde_json", "trusted-server-core", + "trusted-server-js", + "url", "urlencoding", ] @@ -2704,9 +2709,9 @@ dependencies = [ "ed25519-dalek", "edgezero-core", "error-stack", - "fastly", "flate2", "futures", + "getrandom 0.2.17", "hex", "hmac", "http", diff --git a/crates/trusted-server-adapter-fastly/Cargo.toml b/crates/trusted-server-adapter-fastly/Cargo.toml index e483ea621..060c491f5 100644 --- a/crates/trusted-server-adapter-fastly/Cargo.toml +++ b/crates/trusted-server-adapter-fastly/Cargo.toml @@ -21,7 +21,10 @@ log-fastly = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } trusted-server-core = { workspace = true } +url = { workspace = true } urlencoding = { workspace = true } +trusted-server-js = { path = "../js" } [dev-dependencies] +bytes = { workspace = true } edgezero-core = { workspace = true, features = ["test-utils"] } diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 6123d6546..6818d27e9 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -198,8 +198,7 @@ async fn dispatch_fallback( /// mirroring [`crate::http_error_response`] exactly. /// /// The near-identical function in `main.rs` is intentional: the legacy path -/// uses fastly HTTP types while this path uses `edgezero_core` types. The -/// duplication will be removed when `legacy_main` is deleted in PR 15. +/// uses fastly HTTP types while this path uses `edgezero_core` types. pub(crate) fn http_error(report: &Report) -> Response { let root_error = report.current_context(); log::error!("Error occurred: {:?}", report); diff --git a/crates/trusted-server-adapter-fastly/src/backend.rs b/crates/trusted-server-adapter-fastly/src/backend.rs new file mode 100644 index 000000000..1a226489a --- /dev/null +++ b/crates/trusted-server-adapter-fastly/src/backend.rs @@ -0,0 +1,433 @@ +use std::time::Duration; + +use error_stack::{Report, ResultExt}; +use fastly::backend::Backend; +use url::Url; + +use trusted_server_core::error::TrustedServerError; + +/// Returns the default port for the given scheme (443 for HTTPS, 80 for HTTP). +#[inline] +fn default_port_for_scheme(scheme: &str) -> u16 { + if scheme.eq_ignore_ascii_case("https") { + 443 + } else { + 80 + } +} + +/// Compute the Host header value for a backend request. +/// +/// For standard ports (443 for HTTPS, 80 for HTTP), returns just the hostname. +/// For non-standard ports, returns "hostname:port" to ensure backends that +/// generate URLs based on the Host header include the port. +/// +/// This fixes the issue where backends behind reverse proxies (like Caddy) +/// would generate URLs without the port when the Host header didn't include it. +#[inline] +fn compute_host_header(scheme: &str, host: &str, port: u16) -> String { + if port != default_port_for_scheme(scheme) { + format!("{}:{}", host, port) + } else { + host.to_string() + } +} + +/// Default first-byte timeout for backends (15 seconds). +pub(crate) const DEFAULT_FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(15); + +/// Configuration for creating a dynamic Fastly backend. +/// +/// Uses the builder pattern so that new options can be added without changing +/// existing call sites — fields carry sensible defaults. +pub struct BackendConfig<'a> { + scheme: &'a str, + host: &'a str, + port: Option, + certificate_check: bool, + first_byte_timeout: Duration, +} + +impl<'a> BackendConfig<'a> { + /// Create a new configuration with required fields and safe defaults. + /// + /// `certificate_check` defaults to `true`. + /// `first_byte_timeout` defaults to 15 seconds. + #[must_use] + pub fn new(scheme: &'a str, host: &'a str) -> Self { + Self { + scheme, + host, + port: None, + certificate_check: true, + first_byte_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, + } + } + + /// Set the port for the backend. When `None`, the default port for the + /// scheme is used (443 for HTTPS, 80 for HTTP). + #[must_use] + pub fn port(mut self, port: Option) -> Self { + self.port = port; + self + } + + /// Control TLS certificate verification. Defaults to `true`. + #[must_use] + pub fn certificate_check(mut self, check: bool) -> Self { + self.certificate_check = check; + self + } + + /// Set the maximum time to wait for the first byte of the response. + /// + /// Defaults to 15 seconds. For latency-sensitive paths like auction + /// requests, callers should set a tighter timeout derived from the + /// auction deadline. + #[must_use] + pub fn first_byte_timeout(mut self, timeout: Duration) -> Self { + self.first_byte_timeout = timeout; + self + } + + /// Compute the deterministic backend name and resolved port without + /// registering anything. + /// + /// The name encodes scheme, host, port, certificate setting, and + /// first-byte timeout so that backends with different configurations + /// never collide. Including the timeout prevents "first-registration-wins" + /// poisoning where a later request for the same origin with a tighter + /// timeout would silently inherit the original registration's value. + fn compute_name(&self) -> Result<(String, u16), Report> { + if self.host.is_empty() { + return Err(Report::new(TrustedServerError::Proxy { + message: "missing host".to_string(), + })); + } + if self.host.chars().any(char::is_control) { + return Err(Report::new(TrustedServerError::Proxy { + message: "host contains control characters".to_string(), + })); + } + if self.scheme.chars().any(char::is_control) { + return Err(Report::new(TrustedServerError::Proxy { + message: "scheme contains control characters".to_string(), + })); + } + + let target_port = self + .port + .unwrap_or_else(|| default_port_for_scheme(self.scheme)); + + let name_base = format!("{}_{}_{}", self.scheme, self.host, target_port); + let cert_suffix = if self.certificate_check { + "" + } else { + "_nocert" + }; + let timeout_ms = self.first_byte_timeout.as_millis(); + let backend_name = format!( + "backend_{}{}_t{}", + name_base.replace(['.', ':'], "_"), + cert_suffix, + timeout_ms + ); + + Ok((backend_name, target_port)) + } + + /// Return the deterministic backend name without registering anything. + /// + /// Convenience wrapper over [`Self::compute_name`] that discards the + /// resolved port, used by [`crate::platform::PlatformBackend`] + /// implementations that only need the name for correlation. + /// + /// # Errors + /// + /// Returns an error if the host is empty. + pub fn predict_name(self) -> Result> { + self.compute_name().map(|(name, _)| name) + } + + /// Ensure a dynamic backend exists for this configuration and return its name. + /// + /// The backend name is derived from the scheme, host, port, certificate + /// setting, and `first_byte_timeout` to avoid collisions. Different + /// timeout values produce different backend registrations so that a + /// tight deadline cannot be silently widened by an earlier registration. + /// + /// # Errors + /// + /// Returns an error if the host is empty or if backend creation fails + /// (except for `NameInUse` which reuses the existing backend). + pub fn ensure(self) -> Result> { + let (backend_name, target_port) = self.compute_name()?; + + let host_with_port = format!("{}:{}", self.host, target_port); + + let host_header = compute_host_header(self.scheme, self.host, target_port); + + // Target base is host[:port]; SSL is enabled only for https scheme + let mut builder = Backend::builder(&backend_name, &host_with_port) + .override_host(&host_header) + .connect_timeout(Duration::from_secs(1)) + .first_byte_timeout(self.first_byte_timeout) + .between_bytes_timeout(Duration::from_secs(10)); + if self.scheme.eq_ignore_ascii_case("https") { + builder = builder.enable_ssl().sni_hostname(self.host); + if self.certificate_check { + builder = builder + .enable_ssl() + .sni_hostname(self.host) + .check_certificate(self.host); + } else { + log::warn!( + "INSECURE: certificate check disabled for backend: {}", + backend_name + ); + } + log::info!("enable ssl for backend: {}", backend_name); + } + + match builder.finish() { + Ok(_) => { + log::info!( + "created dynamic backend: {} -> {}", + backend_name, + host_with_port + ); + Ok(backend_name) + } + Err(e) => { + let msg = e.to_string(); + if msg.contains("NameInUse") || msg.contains("already in use") { + log::info!("reusing existing dynamic backend: {}", backend_name); + Ok(backend_name) + } else { + Err(Report::new(TrustedServerError::Proxy { + message: format!( + "dynamic backend creation failed ({} -> {}): {}", + backend_name, host_with_port, msg + ), + })) + } + } + } + } + + /// Parse an origin URL into its (scheme, host, port) components. + /// + /// 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> { + let parsed_url = Url::parse(origin_url).change_context(TrustedServerError::Proxy { + message: format!("Invalid origin_url: {}", origin_url), + })?; + + let scheme = parsed_url.scheme().to_owned(); + let host = parsed_url + .host_str() + .ok_or_else(|| { + Report::new(TrustedServerError::Proxy { + message: "Missing host in origin_url".to_string(), + }) + })? + .to_owned(); + let port = parsed_url.port(); + + Ok((scheme, host, port)) + } + + /// Parse an origin URL and ensure a dynamic backend exists for it. + /// + /// This is a convenience constructor that parses the URL, extracts scheme, + /// host, and port, then calls [`ensure`](Self::ensure) with the default + /// 15 s first-byte timeout. + /// + /// # Errors + /// + /// Returns an error if the URL cannot be parsed or lacks a host, or if + /// backend creation fails. + pub fn from_url( + origin_url: &str, + certificate_check: bool, + ) -> Result> { + Self::from_url_with_first_byte_timeout( + origin_url, + certificate_check, + DEFAULT_FIRST_BYTE_TIMEOUT, + ) + } + + /// Parse an origin URL and ensure a dynamic backend with a custom + /// first-byte timeout. + /// + /// For latency-sensitive paths (e.g. auction bid requests) callers should + /// pass the remaining auction budget so that individual requests don't hang + /// longer than the overall deadline allows. + /// + /// # Errors + /// + /// Returns an error if the URL cannot be parsed or lacks a host, or if + /// backend creation fails. + pub fn from_url_with_first_byte_timeout( + origin_url: &str, + certificate_check: bool, + first_byte_timeout: Duration, + ) -> Result> { + let (scheme, host, port) = Self::parse_origin(origin_url)?; + + BackendConfig::new(&scheme, &host) + .port(port) + .certificate_check(certificate_check) + .first_byte_timeout(first_byte_timeout) + .ensure() + } +} + +#[cfg(test)] +mod tests { + use super::{compute_host_header, BackendConfig}; + + // Tests for compute_host_header - the fix for port preservation in Host header + #[test] + fn host_header_includes_port_for_non_standard_https() { + assert_eq!( + compute_host_header("https", "cdn.example.com", 9443), + "cdn.example.com:9443", + "should include non-standard HTTPS port 9443 in Host header" + ); + assert_eq!( + compute_host_header("https", "cdn.example.com", 8443), + "cdn.example.com:8443", + "should include non-standard HTTPS port 8443 in Host header" + ); + } + + #[test] + fn host_header_excludes_port_for_standard_https() { + assert_eq!( + compute_host_header("https", "cdn.example.com", 443), + "cdn.example.com", + "should omit standard HTTPS port 443 from Host header" + ); + } + + #[test] + fn host_header_includes_port_for_non_standard_http() { + assert_eq!( + compute_host_header("http", "cdn.example.com", 8080), + "cdn.example.com:8080", + "should include non-standard HTTP port 8080 in Host header" + ); + } + + #[test] + fn host_header_excludes_port_for_standard_http() { + assert_eq!( + compute_host_header("http", "cdn.example.com", 80), + "cdn.example.com", + "should omit standard HTTP port 80 from Host header" + ); + } + + #[test] + fn returns_name_for_https_with_cert_check() { + let name = BackendConfig::new("https", "origin.example.com") + .ensure() + .expect("should create backend for valid HTTPS origin"); + assert_eq!(name, "backend_https_origin_example_com_443_t15000"); + } + + #[test] + fn returns_name_for_https_without_cert_check() { + let name = BackendConfig::new("https", "origin.example.com") + .certificate_check(false) + .ensure() + .expect("should create backend with cert check disabled"); + assert_eq!(name, "backend_https_origin_example_com_443_nocert_t15000"); + } + + #[test] + fn returns_name_for_http_with_port_and_sanitizes() { + let name = BackendConfig::new("http", "api.test-site.org") + .port(Some(8080)) + .ensure() + .expect("should create backend for HTTP origin with explicit port"); + assert_eq!(name, "backend_http_api_test-site_org_8080_t15000"); + } + + #[test] + fn returns_name_for_http_without_port_defaults_to_80() { + let name = BackendConfig::new("http", "example.org") + .ensure() + .expect("should create backend defaulting to port 80 for HTTP"); + assert_eq!(name, "backend_http_example_org_80_t15000"); + } + + #[test] + fn error_on_host_with_control_characters() { + let err = BackendConfig::new("https", "evil.com\nINFO fake log entry") + .predict_name() + .expect_err("should reject host containing newline"); + assert!( + err.to_string().contains("control characters"), + "should report control characters in error message" + ); + } + + #[test] + fn error_on_missing_host() { + let err = BackendConfig::new("https", "") + .ensure() + .expect_err("should reject empty host"); + let msg = err.to_string(); + assert!( + msg.contains("missing host"), + "should report missing host in error message" + ); + } + + #[test] + fn second_call_reuses_existing_backend() { + let first = BackendConfig::new("https", "reuse.example.com") + .ensure() + .expect("should create backend on first call"); + let second = BackendConfig::new("https", "reuse.example.com") + .ensure() + .expect("should reuse backend on second call"); + assert_eq!( + first, second, + "should return same backend name on repeat call" + ); + } + + #[test] + fn different_timeouts_produce_different_names() { + use std::time::Duration; + + let (name_a, _) = BackendConfig::new("https", "origin.example.com") + .first_byte_timeout(Duration::from_millis(2000)) + .compute_name() + .expect("should compute name with 2000ms timeout"); + let (name_b, _) = BackendConfig::new("https", "origin.example.com") + .first_byte_timeout(Duration::from_millis(500)) + .compute_name() + .expect("should compute name with 500ms timeout"); + assert_ne!( + name_a, name_b, + "backends with different timeouts should have different names" + ); + assert!( + name_a.ends_with("_t2000"), + "name should include timeout suffix" + ); + assert!( + name_b.ends_with("_t500"), + "name should include timeout suffix" + ); + } +} diff --git a/crates/trusted-server-adapter-fastly/src/compat.rs b/crates/trusted-server-adapter-fastly/src/compat.rs new file mode 100644 index 000000000..9aaff4dd6 --- /dev/null +++ b/crates/trusted-server-adapter-fastly/src/compat.rs @@ -0,0 +1,145 @@ +//! 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` as part of removing all `fastly` crate +//! imports from the core library. + +use edgezero_core::body::Body as EdgeBody; +use edgezero_core::http::{Request as HttpRequest, RequestBuilder, Response as HttpResponse, Uri}; +use trusted_server_core::http_util::SPOOFABLE_FORWARDED_HEADERS; + +fn build_http_request(req: &fastly::Request, body: EdgeBody) -> HttpRequest { + 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 a `fastly::Response` into an [`HttpResponse`]. +pub(crate) fn from_fastly_response(mut resp: fastly::Response) -> HttpResponse { + let status = resp.get_status(); + let mut builder = edgezero_core::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 [`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); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_fastly_forwarded_headers_strips_spoofable() { + let mut req = fastly::Request::get("https://example.com/"); + req.set_header("forwarded", "for=1.2.3.4"); + req.set_header("x-forwarded-host", "evil.example.com"); + req.set_header("x-forwarded-proto", "http"); + req.set_header("fastly-ssl", "1"); + req.set_header("host", "example.com"); + + sanitize_fastly_forwarded_headers(&mut req); + + assert!( + req.get_header("forwarded").is_none(), + "should strip forwarded" + ); + assert!( + req.get_header("x-forwarded-host").is_none(), + "should strip x-forwarded-host" + ); + assert!( + req.get_header("x-forwarded-proto").is_none(), + "should strip x-forwarded-proto" + ); + assert!( + req.get_header("fastly-ssl").is_none(), + "should strip fastly-ssl" + ); + assert!(req.get_header("host").is_some(), "should preserve host"); + } + + #[test] + fn to_fastly_response_with_streaming_body_produces_empty_body() { + use edgezero_core::http::StatusCode; + + let stream = futures::stream::empty::(); + let stream_body = EdgeBody::stream(stream); + + let http_resp = edgezero_core::http::response_builder() + .status(StatusCode::OK) + .body(stream_body) + .expect("should build response"); + + let mut fastly_resp = to_fastly_response(http_resp); + + assert_eq!( + fastly_resp.get_status().as_u16(), + 200, + "should preserve status" + ); + assert!( + fastly_resp.take_body_bytes().is_empty(), + "should produce empty body for streaming response" + ); + } +} diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 2876667cf..4bc13328b 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::AuctionOrchestrator; use trusted_server_core::auth::enforce_basic_auth; -use trusted_server_core::compat; use trusted_server_core::error::{IntoHttpResponse, TrustedServerError}; use trusted_server_core::geo::GeoInfo; use trusted_server_core::integrations::IntegrationRegistry; @@ -30,6 +29,8 @@ 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; @@ -127,8 +128,7 @@ fn main(mut req: FastlyRequest) -> Result { /// the `edgezero_enabled` config flag is absent or `false`. /// /// The thin fastly↔http conversion layer (via `compat::from_fastly_request` / -/// `compat::to_fastly_response`) lives here in the adapter crate. `compat.rs` -/// will be deleted in PR 15 once this legacy path is retired. +/// `compat::to_fastly_response`) lives here in the adapter crate. /// /// # Errors /// diff --git a/crates/trusted-server-adapter-fastly/src/management_api.rs b/crates/trusted-server-adapter-fastly/src/management_api.rs index 92ae8e6c0..58be9711b 100644 --- a/crates/trusted-server-adapter-fastly/src/management_api.rs +++ b/crates/trusted-server-adapter-fastly/src/management_api.rs @@ -24,6 +24,7 @@ use fastly::http::{Method, StatusCode}; use fastly::{Request, Response}; use trusted_server_core::platform::{PlatformError, PlatformSecretStore, StoreName}; +use crate::backend::BackendConfig; use crate::platform::FastlyPlatformSecretStore; const FASTLY_API_HOST: &str = "https://api.fastly.com"; @@ -122,8 +123,6 @@ impl FastlyManagementApiClient { /// be registered, or [`PlatformError::SecretStore`] if the API key cannot /// be read. pub(crate) fn new() -> Result> { - use trusted_server_core::backend::BackendConfig; - let backend_name = BackendConfig::from_url(FASTLY_API_HOST, true) .change_context(PlatformError::Backend) .attach("failed to register Fastly management API backend")?; diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index dd1f098b8..b951d3f0b 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 /// @@ -81,8 +80,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 /// @@ -321,10 +320,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 { diff --git a/crates/trusted-server-adapter-fastly/src/route_tests.rs b/crates/trusted-server-adapter-fastly/src/route_tests.rs index aa1843a25..d41da7488 100644 --- a/crates/trusted-server-adapter-fastly/src/route_tests.rs +++ b/crates/trusted-server-adapter-fastly/src/route_tests.rs @@ -1,12 +1,12 @@ use std::net::IpAddr; use std::sync::Arc; +use crate::compat; use edgezero_core::key_value_store::NoopKvStore; use error_stack::Report; use fastly::http::StatusCode; use fastly::Request; use trusted_server_core::auction::build_orchestrator; -use trusted_server_core::compat; use trusted_server_core::error::IntoHttpResponse; use trusted_server_core::integrations::IntegrationRegistry; use trusted_server_core::platform::{ diff --git a/crates/trusted-server-core/Cargo.toml b/crates/trusted-server-core/Cargo.toml index 95ef3a035..704dc515c 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 } hex = { workspace = true } @@ -40,7 +39,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" } @@ -51,6 +49,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 } @@ -71,6 +76,7 @@ default = [] criterion = { workspace = true } edgezero-core = { workspace = true, features = ["test-utils"] } temp-env = { workspace = true } +tokio = { workspace = true } [[bench]] name = "consent_decode" diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index bb8fec3f2..eeac8943b 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -304,7 +304,7 @@ impl AuctionOrchestrator { // Get the backend name for this provider to map responses back. // Must be computed after effective_timeout since the timeout is // part of the backend name. - let backend_name = match provider.backend_name(effective_timeout) { + let backend_name = match provider.backend_name(services, effective_timeout) { Some(name) => name, None => { log::warn!( diff --git a/crates/trusted-server-core/src/auction/provider.rs b/crates/trusted-server-core/src/auction/provider.rs index 5f4b82341..f5d235aa0 100644 --- a/crates/trusted-server-core/src/auction/provider.rs +++ b/crates/trusted-server-core/src/auction/provider.rs @@ -4,7 +4,7 @@ use async_trait::async_trait; use error_stack::Report; use crate::error::TrustedServerError; -use crate::platform::{PlatformPendingRequest, PlatformResponse}; +use crate::platform::{PlatformPendingRequest, PlatformResponse, RuntimeServices}; use super::types::{AuctionContext, AuctionRequest, AuctionResponse}; @@ -67,9 +67,10 @@ pub trait AuctionProvider: Send + Sync { /// /// `timeout_ms` is the effective timeout that will be used when the backend /// is registered in [`request_bids`](Self::request_bids). It must be - /// forwarded to [`crate::backend::BackendConfig::backend_name_for_url`] so the predicted - /// name matches the actual registration (the timeout is part of the name). - fn backend_name(&self, _timeout_ms: u32) -> Option { + /// forwarded to [`crate::platform::PlatformBackend::predict_name`] through + /// `services` so the predicted name matches the actual platform backend + /// registration. + fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option { None } } diff --git a/crates/trusted-server-core/src/backend.rs b/crates/trusted-server-core/src/backend.rs index 468a3f830..841d523d6 100644 --- a/crates/trusted-server-core/src/backend.rs +++ b/crates/trusted-server-core/src/backend.rs @@ -1,465 +1,3 @@ use std::time::Duration; -use error_stack::{Report, ResultExt}; -use fastly::backend::Backend; -use url::Url; - -use crate::error::TrustedServerError; - -/// Returns the default port for the given scheme (443 for HTTPS, 80 for HTTP). -#[inline] -fn default_port_for_scheme(scheme: &str) -> u16 { - if scheme.eq_ignore_ascii_case("https") { - 443 - } else { - 80 - } -} - -/// Compute the Host header value for a backend request. -/// -/// For standard ports (443 for HTTPS, 80 for HTTP), returns just the hostname. -/// For non-standard ports, returns "hostname:port" to ensure backends that -/// generate URLs based on the Host header include the port. -/// -/// This fixes the issue where backends behind reverse proxies (like Caddy) -/// would generate URLs without the port when the Host header didn't include it. -#[inline] -fn compute_host_header(scheme: &str, host: &str, port: u16) -> String { - if port != default_port_for_scheme(scheme) { - format!("{}:{}", host, port) - } else { - host.to_string() - } -} - -/// Default first-byte timeout for backends (15 seconds). pub(crate) const DEFAULT_FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(15); - -/// Configuration for creating a dynamic Fastly backend. -/// -/// Uses the builder pattern so that new options can be added without changing -/// existing call sites — fields carry sensible defaults. -pub struct BackendConfig<'a> { - scheme: &'a str, - host: &'a str, - port: Option, - certificate_check: bool, - first_byte_timeout: Duration, -} - -impl<'a> BackendConfig<'a> { - /// Create a new configuration with required fields and safe defaults. - /// - /// `certificate_check` defaults to `true`. - /// `first_byte_timeout` defaults to 15 seconds. - #[must_use] - pub fn new(scheme: &'a str, host: &'a str) -> Self { - Self { - scheme, - host, - port: None, - certificate_check: true, - first_byte_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, - } - } - - /// Set the port for the backend. When `None`, the default port for the - /// scheme is used (443 for HTTPS, 80 for HTTP). - #[must_use] - pub fn port(mut self, port: Option) -> Self { - self.port = port; - self - } - - /// Control TLS certificate verification. Defaults to `true`. - #[must_use] - pub fn certificate_check(mut self, check: bool) -> Self { - self.certificate_check = check; - self - } - - /// Set the maximum time to wait for the first byte of the response. - /// - /// Defaults to 15 seconds. For latency-sensitive paths like auction - /// requests, callers should set a tighter timeout derived from the - /// auction deadline. - #[must_use] - pub fn first_byte_timeout(mut self, timeout: Duration) -> Self { - self.first_byte_timeout = timeout; - self - } - - /// Compute the deterministic backend name and resolved port without - /// registering anything. - /// - /// The name encodes scheme, host, port, certificate setting, and - /// first-byte timeout so that backends with different configurations - /// never collide. Including the timeout prevents "first-registration-wins" - /// poisoning where a later request for the same origin with a tighter - /// timeout would silently inherit the original registration's value. - fn compute_name(&self) -> Result<(String, u16), Report> { - if self.host.is_empty() { - return Err(Report::new(TrustedServerError::Proxy { - message: "missing host".to_string(), - })); - } - if self.host.chars().any(char::is_control) { - return Err(Report::new(TrustedServerError::Proxy { - message: "host contains control characters".to_string(), - })); - } - if self.scheme.chars().any(char::is_control) { - return Err(Report::new(TrustedServerError::Proxy { - message: "scheme contains control characters".to_string(), - })); - } - - let target_port = self - .port - .unwrap_or_else(|| default_port_for_scheme(self.scheme)); - - let name_base = format!("{}_{}_{}", self.scheme, self.host, target_port); - let cert_suffix = if self.certificate_check { - "" - } else { - "_nocert" - }; - let timeout_ms = self.first_byte_timeout.as_millis(); - let backend_name = format!( - "backend_{}{}_t{}", - name_base.replace(['.', ':'], "_"), - cert_suffix, - timeout_ms - ); - - Ok((backend_name, target_port)) - } - - /// Return the deterministic backend name without registering anything. - /// - /// Convenience wrapper over [`Self::compute_name`] that discards the - /// resolved port, used by [`crate::platform::PlatformBackend`] - /// implementations that only need the name for correlation. - /// - /// # Errors - /// - /// Returns an error if the host is empty. - pub fn predict_name(self) -> Result> { - self.compute_name().map(|(name, _)| name) - } - - /// Ensure a dynamic backend exists for this configuration and return its name. - /// - /// The backend name is derived from the scheme, host, port, certificate - /// setting, and `first_byte_timeout` to avoid collisions. Different - /// timeout values produce different backend registrations so that a - /// tight deadline cannot be silently widened by an earlier registration. - /// - /// # Errors - /// - /// Returns an error if the host is empty or if backend creation fails - /// (except for `NameInUse` which reuses the existing backend). - pub fn ensure(self) -> Result> { - let (backend_name, target_port) = self.compute_name()?; - - let host_with_port = format!("{}:{}", self.host, target_port); - - let host_header = compute_host_header(self.scheme, self.host, target_port); - - // Target base is host[:port]; SSL is enabled only for https scheme - let mut builder = Backend::builder(&backend_name, &host_with_port) - .override_host(&host_header) - .connect_timeout(Duration::from_secs(1)) - .first_byte_timeout(self.first_byte_timeout) - .between_bytes_timeout(Duration::from_secs(10)); - if self.scheme.eq_ignore_ascii_case("https") { - builder = builder.enable_ssl().sni_hostname(self.host); - if self.certificate_check { - builder = builder - .enable_ssl() - .sni_hostname(self.host) - .check_certificate(self.host); - } else { - log::warn!( - "INSECURE: certificate check disabled for backend: {}", - backend_name - ); - } - log::info!("enable ssl for backend: {}", backend_name); - } - - match builder.finish() { - Ok(_) => { - log::info!( - "created dynamic backend: {} -> {}", - backend_name, - host_with_port - ); - Ok(backend_name) - } - Err(e) => { - let msg = e.to_string(); - if msg.contains("NameInUse") || msg.contains("already in use") { - log::info!("reusing existing dynamic backend: {}", backend_name); - Ok(backend_name) - } else { - Err(Report::new(TrustedServerError::Proxy { - message: format!( - "dynamic backend creation failed ({} -> {}): {}", - backend_name, host_with_port, msg - ), - })) - } - } - } - } - - /// 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. - fn parse_origin( - origin_url: &str, - ) -> Result<(String, String, Option), Report> { - let parsed_url = Url::parse(origin_url).change_context(TrustedServerError::Proxy { - message: format!("Invalid origin_url: {}", origin_url), - })?; - - let scheme = parsed_url.scheme().to_owned(); - let host = parsed_url - .host_str() - .ok_or_else(|| { - Report::new(TrustedServerError::Proxy { - message: "Missing host in origin_url".to_string(), - }) - })? - .to_owned(); - let port = parsed_url.port(); - - Ok((scheme, host, port)) - } - - /// Parse an origin URL and ensure a dynamic backend exists for it. - /// - /// This is a convenience constructor that parses the URL, extracts scheme, - /// host, and port, then calls [`ensure`](Self::ensure) with the default - /// 15 s first-byte timeout. - /// - /// # Errors - /// - /// Returns an error if the URL cannot be parsed or lacks a host, or if - /// backend creation fails. - pub fn from_url( - origin_url: &str, - certificate_check: bool, - ) -> Result> { - Self::from_url_with_first_byte_timeout( - origin_url, - certificate_check, - DEFAULT_FIRST_BYTE_TIMEOUT, - ) - } - - /// Parse an origin URL and ensure a dynamic backend with a custom - /// first-byte timeout. - /// - /// For latency-sensitive paths (e.g. auction bid requests) callers should - /// pass the remaining auction budget so that individual requests don't hang - /// longer than the overall deadline allows. - /// - /// # Errors - /// - /// Returns an error if the URL cannot be parsed or lacks a host, or if - /// backend creation fails. - pub fn from_url_with_first_byte_timeout( - origin_url: &str, - certificate_check: bool, - first_byte_timeout: Duration, - ) -> Result> { - let (scheme, host, port) = Self::parse_origin(origin_url)?; - - BackendConfig::new(&scheme, &host) - .port(port) - .certificate_check(certificate_check) - .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)] -mod tests { - use super::{compute_host_header, BackendConfig}; - - // Tests for compute_host_header - the fix for port preservation in Host header - #[test] - fn host_header_includes_port_for_non_standard_https() { - assert_eq!( - compute_host_header("https", "cdn.example.com", 9443), - "cdn.example.com:9443", - "should include non-standard HTTPS port 9443 in Host header" - ); - assert_eq!( - compute_host_header("https", "cdn.example.com", 8443), - "cdn.example.com:8443", - "should include non-standard HTTPS port 8443 in Host header" - ); - } - - #[test] - fn host_header_excludes_port_for_standard_https() { - assert_eq!( - compute_host_header("https", "cdn.example.com", 443), - "cdn.example.com", - "should omit standard HTTPS port 443 from Host header" - ); - } - - #[test] - fn host_header_includes_port_for_non_standard_http() { - assert_eq!( - compute_host_header("http", "cdn.example.com", 8080), - "cdn.example.com:8080", - "should include non-standard HTTP port 8080 in Host header" - ); - } - - #[test] - fn host_header_excludes_port_for_standard_http() { - assert_eq!( - compute_host_header("http", "cdn.example.com", 80), - "cdn.example.com", - "should omit standard HTTP port 80 from Host header" - ); - } - - #[test] - fn returns_name_for_https_with_cert_check() { - let name = BackendConfig::new("https", "origin.example.com") - .ensure() - .expect("should create backend for valid HTTPS origin"); - assert_eq!(name, "backend_https_origin_example_com_443_t15000"); - } - - #[test] - fn returns_name_for_https_without_cert_check() { - let name = BackendConfig::new("https", "origin.example.com") - .certificate_check(false) - .ensure() - .expect("should create backend with cert check disabled"); - assert_eq!(name, "backend_https_origin_example_com_443_nocert_t15000"); - } - - #[test] - fn returns_name_for_http_with_port_and_sanitizes() { - let name = BackendConfig::new("http", "api.test-site.org") - .port(Some(8080)) - .ensure() - .expect("should create backend for HTTP origin with explicit port"); - assert_eq!(name, "backend_http_api_test-site_org_8080_t15000"); - } - - #[test] - fn returns_name_for_http_without_port_defaults_to_80() { - let name = BackendConfig::new("http", "example.org") - .ensure() - .expect("should create backend defaulting to port 80 for HTTP"); - assert_eq!(name, "backend_http_example_org_80_t15000"); - } - - #[test] - fn error_on_host_with_control_characters() { - let err = BackendConfig::new("https", "evil.com\nINFO fake log entry") - .predict_name() - .expect_err("should reject host containing newline"); - assert!( - err.to_string().contains("control characters"), - "should report control characters in error message" - ); - } - - #[test] - fn error_on_missing_host() { - let err = BackendConfig::new("https", "") - .ensure() - .expect_err("should reject empty host"); - let msg = err.to_string(); - assert!( - msg.contains("missing host"), - "should report missing host in error message" - ); - } - - #[test] - fn second_call_reuses_existing_backend() { - let first = BackendConfig::new("https", "reuse.example.com") - .ensure() - .expect("should create backend on first call"); - let second = BackendConfig::new("https", "reuse.example.com") - .ensure() - .expect("should reuse backend on second call"); - assert_eq!( - first, second, - "should return same backend name on repeat call" - ); - } - - #[test] - fn different_timeouts_produce_different_names() { - use std::time::Duration; - - let (name_a, _) = BackendConfig::new("https", "origin.example.com") - .first_byte_timeout(Duration::from_millis(2000)) - .compute_name() - .expect("should compute name with 2000ms timeout"); - let (name_b, _) = BackendConfig::new("https", "origin.example.com") - .first_byte_timeout(Duration::from_millis(500)) - .compute_name() - .expect("should compute name with 500ms timeout"); - assert_ne!( - name_a, name_b, - "backends with different timeouts should have different names" - ); - assert!( - name_a.ends_with("_t2000"), - "name should include timeout suffix" - ); - assert!( - name_b.ends_with("_t500"), - "name should include timeout suffix" - ); - } -} diff --git a/crates/trusted-server-core/src/compat.rs b/crates/trusted-server-core/src/compat.rs deleted file mode 100644 index 32788bd15..000000000 --- a/crates/trusted-server-core/src/compat.rs +++ /dev/null @@ -1,659 +0,0 @@ -//! Compatibility bridge between `fastly` SDK types and `http` crate types. -//! -//! All items in this module are temporary scaffolding created in PR 11 and -//! scheduled for deletion in PR 15. Do not add new callers after PR 13. -//! -//! # PR 15 removal target - -use edgezero_core::body::Body as EdgeBody; -use fastly::http::header; - -use crate::constants::INTERNAL_HEADERS; -use crate::http_util::SPOOFABLE_FORWARDED_HEADERS; - -fn build_http_request(req: &fastly::Request, body: EdgeBody) -> http::Request { - let uri: http::Uri = req - .get_url_str() - .parse() - .unwrap_or_else(|_| http::Uri::from_static("/")); - - let mut builder = http::Request::builder() - .method(req.get_method().clone()) - .uri(uri); - - for (name, value) in req.get_headers() { - builder = builder.header(name.as_str(), value.as_bytes()); - } - - // Cannot fail: URI is always valid (parsed above or the "/" fallback), - // and Fastly pre-validates all method and header values. - builder - .body(body) - .expect("should build http request from fastly request") -} - -/// Convert an owned `fastly::Request` into an `http::Request`. -/// -/// # PR 15 removal target -/// -/// # Panics -/// -/// 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.append_header(name.as_str(), value.as_bytes()); - } - - fastly_req -} - -/// Convert a `fastly::Response` into an `http::Response`. -/// -/// # PR 15 removal target -/// -/// # Panics -/// -/// Panics if the copied Fastly response parts cannot form a valid -/// `http::Response`. -pub fn from_fastly_response(mut resp: fastly::Response) -> http::Response { - let status = resp.get_status(); - let mut builder = http::Response::builder().status(status); - for (name, value) in resp.get_headers() { - builder = builder.header(name.as_str(), value.as_bytes()); - } - - builder - .body(EdgeBody::from(resp.take_body_bytes())) - .expect("should build http response from fastly response") -} - -/// Convert an `http::Response` into a `fastly::Response`. -/// -/// # PR 15 removal target -pub fn to_fastly_response(resp: http::Response) -> fastly::Response { - let (parts, body) = resp.into_parts(); - let mut fastly_resp = fastly::Response::from_status(parts.status.as_u16()); - for (name, value) in &parts.headers { - fastly_resp.append_header(name.as_str(), value.as_bytes()); - } - - match body { - EdgeBody::Once(bytes) => { - if !bytes.is_empty() { - 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 EC 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 EC 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_ec_cookie(settings, synthetic_id), - ); -} - -/// Expire the EC 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, - format!( - "{}=; Domain={}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0", - crate::constants::COOKIE_TS_EC, - settings.publisher.cookie_domain, - ), - ); -} - -#[cfg(test)] -mod tests { - use super::*; - - fn assert_once_body_eq(body: EdgeBody, expected: &[u8]) { - match body { - EdgeBody::Once(bytes) => assert_eq!(bytes.as_ref(), expected, "should copy body bytes"), - EdgeBody::Stream(_) => panic!("expected non-streaming body"), - } - } - - #[test] - fn from_fastly_headers_ref_copies_headers() { - let mut fastly_req = - fastly::Request::new(fastly::http::Method::GET, "https://example.com/path"); - fastly_req.set_header("x-custom", "value"); - - let http_req = from_fastly_headers_ref(&fastly_req); - - assert_eq!(http_req.uri().path(), "/path", "should copy path"); - assert_eq!( - http_req - .headers() - .get("x-custom") - .and_then(|v| v.to_str().ok()), - Some("value"), - "should copy custom header" - ); - } - - #[test] - fn from_fastly_headers_ref_preserves_duplicate_headers() { - let mut fastly_req = - fastly::Request::new(fastly::http::Method::GET, "https://example.com/path"); - fastly_req.append_header("x-custom", "first"); - fastly_req.append_header("x-custom", "second"); - - let http_req = from_fastly_headers_ref(&fastly_req); - let values: Vec<_> = http_req - .headers() - .get_all("x-custom") - .iter() - .map(|value| value.to_str().expect("should be valid utf8")) - .collect(); - - assert_eq!( - values, - vec!["first", "second"], - "should preserve duplicates" - ); - } - - #[test] - fn from_fastly_headers_ref_body_is_empty() { - let fastly_req = fastly::Request::new(fastly::http::Method::POST, "https://example.com/"); - - let http_req = from_fastly_headers_ref(&fastly_req); - - assert_eq!(http_req.method(), http::Method::POST, "should copy method"); - assert_once_body_eq(http_req.into_body(), b""); - } - - #[test] - fn from_fastly_request_copies_body() { - let mut fastly_req = - fastly::Request::new(fastly::http::Method::POST, "https://example.com/path"); - fastly_req.set_header("content-type", "application/json"); - fastly_req.set_body(r#"{"ok":true}"#); - - let http_req = from_fastly_request(fastly_req); - let (parts, body) = http_req.into_parts(); - - assert_eq!(parts.method, http::Method::POST, "should copy method"); - assert_eq!(parts.uri.path(), "/path", "should copy uri path"); - assert_eq!( - parts - .headers - .get("content-type") - .and_then(|v| v.to_str().ok()), - Some("application/json"), - "should copy headers" - ); - assert_once_body_eq(body, br#"{"ok":true}"#); - } - - #[test] - fn to_fastly_request_copies_headers_and_body() { - let http_req = http::Request::builder() - .method(http::Method::POST) - .uri("https://example.com/submit") - .header("x-custom", "value") - .body(EdgeBody::from(b"payload".as_ref())) - .expect("should build request"); - - let mut fastly_req = to_fastly_request(http_req); - - assert_eq!( - fastly_req.get_method(), - &fastly::http::Method::POST, - "should copy method" - ); - assert_eq!( - fastly_req - .get_header("x-custom") - .and_then(|v| v.to_str().ok()), - Some("value"), - "should copy headers" - ); - assert_eq!( - fastly_req.take_body_bytes().as_slice(), - b"payload", - "should copy body bytes" - ); - } - - #[test] - fn to_fastly_request_preserves_duplicate_headers() { - let http_req = http::Request::builder() - .method(http::Method::GET) - .uri("https://example.com/") - .header("x-custom", "first") - .header("x-custom", "second") - .body(EdgeBody::empty()) - .expect("should build request"); - - let fastly_req = to_fastly_request(http_req); - - let values: Vec<_> = fastly_req - .get_headers() - .filter(|(name, _)| name.as_str() == "x-custom") - .map(|(_, value)| value.to_str().expect("should be valid utf8")) - .collect(); - assert_eq!( - values, - vec!["first", "second"], - "should preserve duplicate headers" - ); - } - - #[test] - fn from_fastly_response_copies_status_headers_and_body() { - let mut fastly_resp = fastly::Response::from_status(202); - fastly_resp.set_header("content-type", "application/json"); - fastly_resp.set_body(r#"{"ok":true}"#); - - let http_resp = from_fastly_response(fastly_resp); - let (parts, body) = http_resp.into_parts(); - - assert_eq!(parts.status.as_u16(), 202, "should copy status"); - assert_eq!( - parts - .headers - .get("content-type") - .and_then(|v| v.to_str().ok()), - Some("application/json"), - "should copy headers" - ); - assert_once_body_eq(body, br#"{"ok":true}"#); - } - - #[test] - fn to_fastly_response_copies_status_and_headers() { - let http_resp = http::Response::builder() - .status(201) - .header("content-type", "application/json") - .body(EdgeBody::from(b"{}".as_ref())) - .expect("should build response"); - - let fastly_resp = to_fastly_response(http_resp); - - assert_eq!(fastly_resp.get_status().as_u16(), 201, "should copy status"); - assert!( - fastly_resp.get_header("content-type").is_some(), - "should copy content-type header" - ); - } - - #[test] - fn to_fastly_request_ref_copies_method_uri_and_headers_without_body() { - let http_req = http::Request::builder() - .method(http::Method::POST) - .uri("https://example.com/path?q=1") - .header("x-custom", "value") - .body(EdgeBody::from(b"payload".as_ref())) - .expect("should build request"); - - let mut fastly_req = to_fastly_request_ref(&http_req); - - assert_eq!( - fastly_req.get_method(), - &fastly::http::Method::POST, - "should copy method" - ); - assert_eq!( - fastly_req.get_url_str(), - "https://example.com/path?q=1", - "should copy URI" - ); - assert_eq!( - fastly_req - .get_header("x-custom") - .and_then(|v| v.to_str().ok()), - Some("value"), - "should copy headers" - ); - assert!( - fastly_req.take_body_bytes().is_empty(), - "borrowed conversion should not copy body bytes" - ); - } - - #[test] - fn to_fastly_request_ref_preserves_duplicate_headers() { - let http_req = http::Request::builder() - .method(http::Method::GET) - .uri("https://example.com/") - .header("x-custom", "first") - .header("x-custom", "second") - .body(EdgeBody::empty()) - .expect("should build request"); - - let fastly_req = to_fastly_request_ref(&http_req); - - let values: Vec<_> = fastly_req - .get_headers() - .filter(|(name, _)| name.as_str() == "x-custom") - .map(|(_, value)| value.to_str().expect("should be valid utf8")) - .collect(); - assert_eq!( - values, - vec!["first", "second"], - "should preserve duplicate headers" - ); - } - - #[test] - fn sanitize_fastly_forwarded_headers_strips_spoofable() { - let mut req = fastly::Request::new(fastly::http::Method::GET, "https://example.com"); - req.set_header("forwarded", "host=evil.com"); - req.set_header("x-forwarded-host", "evil.com"); - req.set_header("x-forwarded-proto", "https"); - req.set_header("fastly-ssl", "1"); - req.set_header("host", "legit.example.com"); - - sanitize_fastly_forwarded_headers(&mut req); - - assert!( - req.get_header("forwarded").is_none(), - "should strip Forwarded" - ); - assert!( - req.get_header("x-forwarded-host").is_none(), - "should strip X-Forwarded-Host" - ); - assert!( - req.get_header("x-forwarded-proto").is_none(), - "should strip X-Forwarded-Proto" - ); - assert!( - req.get_header("fastly-ssl").is_none(), - "should strip Fastly-SSL" - ); - assert_eq!( - req.get_header("host").and_then(|v| v.to_str().ok()), - Some("legit.example.com"), - "should preserve Host" - ); - } - - #[test] - fn forward_fastly_cookie_header_strips_consent() { - let mut from_req = fastly::Request::new(fastly::http::Method::GET, "https://example.com"); - from_req.set_header(header::COOKIE, "euconsent-v2=BOE; session=abc"); - let mut to_req = fastly::Request::new(fastly::http::Method::GET, "https://partner.com"); - - forward_fastly_cookie_header(&from_req, &mut to_req, true); - - let forwarded = to_req - .get_header(header::COOKIE) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - assert!( - !forwarded.contains("euconsent-v2"), - "should strip consent cookie" - ); - assert!( - forwarded.contains("session=abc"), - "should keep non-consent cookie" - ); - } - - #[test] - fn copy_fastly_custom_headers_filters_internal() { - let mut from_req = fastly::Request::new(fastly::http::Method::GET, "https://example.com"); - from_req.set_header("x-custom-data", "present"); - from_req.set_header("x-ts-ec", "should-not-copy"); - let mut to_req = fastly::Request::new(fastly::http::Method::GET, "https://partner.com"); - - copy_fastly_custom_headers(&from_req, &mut to_req); - - assert_eq!( - to_req - .get_header("x-custom-data") - .and_then(|v| v.to_str().ok()), - Some("present"), - "should copy arbitrary x-header" - ); - assert!( - to_req.get_header("x-ts-ec").is_none(), - "should not copy internal header" - ); - } - - #[test] - fn copy_fastly_custom_headers_preserves_duplicate_values() { - let mut from_req = fastly::Request::new(fastly::http::Method::GET, "https://example.com"); - from_req.append_header("x-custom-data", "first"); - from_req.append_header("x-custom-data", "second"); - let mut to_req = fastly::Request::new(fastly::http::Method::GET, "https://partner.com"); - - copy_fastly_custom_headers(&from_req, &mut to_req); - - let values: Vec<_> = to_req - .get_headers() - .filter(|(name, _)| name.as_str() == "x-custom-data") - .map(|(_, value)| value.to_str().expect("should be valid utf8")) - .collect(); - assert_eq!( - values, - vec!["first", "second"], - "should preserve duplicates" - ); - } - - #[test] - fn set_fastly_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!( - "ts-ec=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!( - "ts-ec=; 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/geo.rs b/crates/trusted-server-core/src/geo.rs index cf0d8851a..d4eb3a2f5 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/http_util.rs b/crates/trusted-server-core/src/http_util.rs index d7e61e3ba..8b686172b 100644 --- a/crates/trusted-server-core/src/http_util.rs +++ b/crates/trusted-server-core/src/http_util.rs @@ -29,7 +29,7 @@ pub fn copy_custom_headers(from: &Request, to: &mut Request) /// On Fastly Compute the service is the edge - there is no upstream proxy that /// legitimately sets these. Stripping them forces [`RequestInfo::from_request`] /// to fall back to the trustworthy `Host` header and [`ClientInfo`] TLS detection. -pub(crate) const SPOOFABLE_FORWARDED_HEADERS: &[&str] = &[ +pub const SPOOFABLE_FORWARDED_HEADERS: &[&str] = &[ "forwarded", "x-forwarded-host", "x-forwarded-proto", diff --git a/crates/trusted-server-core/src/integrations/adserver_mock.rs b/crates/trusted-server-core/src/integrations/adserver_mock.rs index f3a72d733..c5caa7614 100644 --- a/crates/trusted-server-core/src/integrations/adserver_mock.rs +++ b/crates/trusted-server-core/src/integrations/adserver_mock.rs @@ -20,10 +20,13 @@ 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::platform::{PlatformHttpRequest, PlatformPendingRequest, PlatformResponse}; +use crate::integrations::{ + collect_body, ensure_integration_backend_with_timeout, predict_integration_backend_name, +}; +use crate::platform::{ + PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, RuntimeServices, +}; use crate::settings::{IntegrationConfig, Settings}; // ============================================================================ @@ -335,9 +338,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 { @@ -407,15 +411,17 @@ impl AuctionProvider for AdServerMockProvider { self.config.enabled } - fn backend_name(&self, timeout_ms: u32) -> Option { - BackendConfig::backend_name_for_url( + fn backend_name(&self, services: &RuntimeServices, timeout_ms: u32) -> Option { + predict_integration_backend_name( + services, &self.config.endpoint, + "adserver_mock", true, Duration::from_millis(u64::from(timeout_ms)), ) .inspect_err(|e| { log::error!( - "Failed to create backend for AdServer Mock endpoint '{}': {e:?}", + "Failed to predict backend name for AdServer Mock endpoint '{}': {e:?}", self.config.endpoint ); }) diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 4d322bedb..80083cbb8 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -14,10 +14,13 @@ 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::platform::{PlatformHttpRequest, PlatformPendingRequest, PlatformResponse}; +use crate::integrations::{ + collect_body, ensure_integration_backend_with_timeout, predict_integration_backend_name, +}; +use crate::platform::{ + PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, RuntimeServices, +}; use crate::settings::IntegrationConfig; // ============================================================================ @@ -514,9 +517,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 { @@ -589,15 +593,17 @@ impl AuctionProvider for ApsAuctionProvider { self.config.enabled } - fn backend_name(&self, timeout_ms: u32) -> Option { - BackendConfig::backend_name_for_url( + fn backend_name(&self, services: &RuntimeServices, timeout_ms: u32) -> Option { + predict_integration_backend_name( + services, &self.config.endpoint, + "aps", true, Duration::from_millis(u64::from(timeout_ms)), ) .inspect_err(|e| { log::error!( - "Failed to create backend for APS endpoint '{}': {e:?}", + "Failed to predict backend name for APS endpoint '{}': {e:?}", self.config.endpoint ); }) 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 205771129..eb089014e 100644 --- a/crates/trusted-server-core/src/integrations/google_tag_manager.rs +++ b/crates/trusted-server-core/src/integrations/google_tag_manager.rs @@ -1084,380 +1084,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 25f410a21..d925813b8 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 _; @@ -45,34 +47,108 @@ pub(crate) fn ensure_integration_backend( url: &str, integration: &'static str, ) -> 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: std::time::Duration::from_secs(15), + .ensure(&integration_backend_spec( + url, + integration, + true, + std::time::Duration::from_secs(15), + )?) + .change_context(TrustedServerError::Integration { + integration: integration.to_string(), + message: "Failed to register backend".to_string(), }) +} + +/// 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> { + services + .backend() + .ensure(&integration_backend_spec( + url, + integration, + 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. +/// +/// Parses `url`, builds a [`PlatformBackendSpec`], and delegates to +/// [`crate::platform::PlatformBackend::predict_name`]. +/// +/// # Errors +/// +/// Returns an error when the URL cannot be parsed, is missing a host, or the +/// platform backend cannot predict a name for the spec. +pub(crate) fn predict_integration_backend_name( + services: &RuntimeServices, + url: &str, + integration: &'static str, + certificate_check: bool, + first_byte_timeout: Duration, +) -> Result> { + services + .backend() + .predict_name(&integration_backend_spec( + url, + integration, + certificate_check, + first_byte_timeout, + )?) + .change_context(TrustedServerError::Integration { + integration: integration.to_string(), + message: "Failed to predict backend name".to_string(), + }) +} + +fn integration_backend_spec( + url: &str, + integration: &'static str, + certificate_check: bool, + first_byte_timeout: Duration, +) -> Result> { + let parsed = Url::parse(url).change_context(TrustedServerError::Integration { + integration: integration.to_string(), + message: format!("Invalid upstream URL: {url}"), + })?; + Ok(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, + first_byte_timeout, + }) +} + /// 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 3a9a96d04..28bf92a24 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_integration_backend_name, + AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, + IntegrationEndpoint, IntegrationHeadInjector, IntegrationHtmlContext, IntegrationProxy, + IntegrationRegistration, }; use crate::openrtb::{ to_openrtb_i32, Banner, ConsentedProvidersSettings, Device, Format, Geo, Imp, ImpExt, @@ -1133,9 +1133,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 @@ -1217,15 +1218,17 @@ impl AuctionProvider for PrebidAuctionProvider { self.config.enabled } - fn backend_name(&self, timeout_ms: u32) -> Option { - BackendConfig::backend_name_for_url( + fn backend_name(&self, services: &RuntimeServices, timeout_ms: u32) -> Option { + predict_integration_backend_name( + services, &self.config.server_url, + PREBID_INTEGRATION_ID, true, Duration::from_millis(u64::from(timeout_ms)), ) .inspect_err(|e| { log::error!( - "Failed to create backend for Prebid server URL '{}': {e:?}", + "Failed to predict backend name for Prebid server URL '{}': {e:?}", self.config.server_url ); }) @@ -1292,7 +1295,13 @@ mod tests { use crate::integrations::{ AttributeRewriteAction, IntegrationDocumentState, IntegrationRegistry, }; - use crate::platform::test_support::{build_services_with_http_client, StubHttpClient}; + use crate::platform::test_support::{ + build_services_with_http_client, NoopConfigStore, NoopGeo, NoopHttpClient, NoopSecretStore, + StubHttpClient, + }; + use crate::platform::{ + ClientInfo, PlatformBackend, PlatformBackendSpec, PlatformError, RuntimeServices, + }; use crate::settings::Settings; use crate::streaming_processor::{Compression, PipelineConfig, StreamingPipeline}; use crate::test_support::tests::crate_test_settings_str; @@ -1322,6 +1331,57 @@ mod tests { } } + struct PredictOnlyBackend; + + impl PlatformBackend for PredictOnlyBackend { + fn predict_name( + &self, + spec: &PlatformBackendSpec, + ) -> Result> { + Ok(format!( + "predicted_{}_{}_{}", + spec.scheme, + spec.host, + spec.first_byte_timeout.as_millis() + )) + } + + fn ensure(&self, _spec: &PlatformBackendSpec) -> Result> { + Ok("unused".to_string()) + } + } + + fn services_with_backend(backend: impl PlatformBackend + 'static) -> RuntimeServices { + RuntimeServices::builder() + .config_store(Arc::new(NoopConfigStore)) + .secret_store(Arc::new(NoopSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(Arc::new(backend)) + .http_client(Arc::new(NoopHttpClient)) + .geo(Arc::new(NoopGeo)) + .client_info(ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + }) + .build() + } + + #[test] + fn prebid_backend_name_delegates_to_platform_backend_prediction() { + let provider = PrebidAuctionProvider::new(base_config()); + let services = services_with_backend(PredictOnlyBackend); + + let backend_name = provider + .backend_name(&services, 123) + .expect("should predict backend name through platform backend"); + + assert_eq!( + backend_name, "predicted_https_prebid.example_123", + "should use PlatformBackend::predict_name instead of duplicating the naming scheme" + ); + } + fn create_test_auction_request() -> AuctionRequest { AuctionRequest { id: "auction-123".to_string(), diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 38c698879..03c71c107 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -34,8 +34,7 @@ pub mod auction; pub mod auction_config_types; pub mod auth; -pub mod backend; -pub mod compat; +pub(crate) mod backend; pub mod consent; pub mod consent_config; pub mod constants; diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index 63628ad68..b77f53d8a 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -1,12 +1,15 @@ use std::collections::{HashMap, VecDeque}; use std::net::IpAddr; use std::sync::{Arc, Mutex}; +use std::time::Duration; use base64::{engine::general_purpose, Engine as _}; use ed25519_dalek::SigningKey; use error_stack::{Report, ResultExt}; use rand::rngs::OsRng; +use edgezero_core::key_value_store::{KvError, KvPage, KvStore as PlatformKvStore}; + use super::{ ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, @@ -356,6 +359,67 @@ impl PlatformHttpClient for StubHttpClient { } } +// --------------------------------------------------------------------------- +// RecordingKvStore +// --------------------------------------------------------------------------- + +/// Test stub for [`PlatformKvStore`] that records `delete()` keys for assertion. +/// +/// All other operations are no-ops: reads return `Ok(None)`, writes return `Ok(())`. +pub(crate) struct RecordingKvStore { + deleted: Mutex>, +} + +impl RecordingKvStore { + pub(crate) fn new() -> Self { + Self { + deleted: Mutex::new(Vec::new()), + } + } + + /// Return the keys passed to `delete()`, in call order. + pub(crate) fn deleted_keys(&self) -> Vec { + self.deleted.lock().expect("should lock deleted").clone() + } +} + +#[async_trait::async_trait(?Send)] +impl PlatformKvStore for RecordingKvStore { + async fn get_bytes(&self, _key: &str) -> Result, KvError> { + Ok(None) + } + + async fn put_bytes(&self, _key: &str, _value: bytes::Bytes) -> Result<(), KvError> { + Ok(()) + } + + async fn put_bytes_with_ttl( + &self, + _key: &str, + _value: bytes::Bytes, + _ttl: Duration, + ) -> Result<(), KvError> { + Ok(()) + } + + async fn delete(&self, key: &str) -> Result<(), KvError> { + self.deleted + .lock() + .expect("should lock deleted") + .push(key.to_owned()); + Ok(()) + } + + async fn list_keys_page( + &self, + _prefix: &str, + _cursor: Option<&str>, + _limit: usize, + ) -> Result { + Ok(KvPage::default()) + } +} + pub(crate) struct NoopGeo; impl PlatformGeo for NoopGeo { diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 4582a3344..70c689509 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -1320,6 +1320,66 @@ mod tests { ); } + #[test] + fn revocation_deletes_kv_entry_for_cookie_ec_id() { + use crate::platform::test_support::RecordingKvStore; + + let mut settings = create_test_settings(); + settings.consent.consent_store = Some("test-consent-store".to_string()); + + let recording = Arc::new(RecordingKvStore::new()); + let services = noop_services() + .with_kv_store(Arc::clone(&recording) as Arc); + + let mut response = Response::new(EdgeBody::empty()); + let consent_ctx = crate::consent::ConsentContext::default(); + + apply_ec_headers( + &settings, + &services, + &mut response, + "new-ec-id", + false, + Some("cookie-ec-id"), + &consent_ctx, + ); + + assert_eq!( + recording.deleted_keys(), + vec!["cookie-ec-id"], + "should delete KV entry for the revoked EC cookie ID" + ); + } + + #[test] + fn revocation_does_not_delete_kv_when_consent_store_absent() { + use crate::platform::test_support::RecordingKvStore; + + let settings = create_test_settings(); + + let recording = Arc::new(RecordingKvStore::new()); + let services = noop_services() + .with_kv_store(Arc::clone(&recording) as Arc); + + let mut response = Response::new(EdgeBody::empty()); + let consent_ctx = crate::consent::ConsentContext::default(); + + apply_ec_headers( + &settings, + &services, + &mut response, + "new-ec-id", + false, + Some("cookie-ec-id"), + &consent_ctx, + ); + + assert!( + recording.deleted_keys().is_empty(), + "should not delete KV entry when no consent_store is configured" + ); + } + #[test] fn tsjs_dynamic_returns_not_found_for_unknown_filename() { let settings = create_test_settings(); 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 cc396f732..000000000 --- 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 index 60f550670..0c6998b6d 100644 --- a/crates/trusted-server-core/src/storage/mod.rs +++ b/crates/trusted-server-core/src/storage/mod.rs @@ -1,15 +1 @@ -//! Store helpers and legacy Fastly-backed store types. -//! -//! The Fastly config/secret store 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`], -//! [`crate::platform::PlatformSecretStore`], and the management write methods -//! via [`crate::platform::RuntimeServices`]. - -pub(crate) mod config_store; pub mod kv_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 f2dd7b91e..000000000 --- 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 ec5f8ddfa..256d3fa5a 100644 --- a/crates/trusted-server-core/src/streaming_processor.rs +++ b/crates/trusted-server-core/src/streaming_processor.rs @@ -12,7 +12,7 @@ //! `docs/superpowers/plans/2026-03-31-pr8-content-rewriting-verification.md`). 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 (Cloudflare Workers, Axum, Spin) 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 000000000..683d3db6d --- /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