diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index cb99551a..c1acf4a7 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -547,7 +547,6 @@ async fn route_request( false, ), (m, path) if integration_registry.has_route(&m, path) => { - let fastly_req = compat::to_fastly_request(req); let result = integration_registry .handle_proxy(ProxyDispatchInput { method: &m, @@ -556,15 +555,14 @@ async fn route_request( kv: kv_graph.as_ref(), ec_context: &mut ec_context, services: runtime_services, - req: fastly_req, + req, }) .await .unwrap_or_else(|| { Err(Report::new(TrustedServerError::BadRequest { message: format!("Unknown integration route: {path}"), })) - }) - .map(compat::from_fastly_response); + }); (result, true) } diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index ed080837..57facbaf 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -331,7 +331,7 @@ impl PlatformHttpClient for FastlyPlatformHttpClient { .map(PlatformPendingRequest::new) .collect(); - let ready = match result { + let (ready, failed_backend_name) = match result { Ok(fastly_resp) => { let backend_name = fastly_resp .get_backend_name() @@ -340,15 +340,24 @@ impl PlatformHttpClient for FastlyPlatformHttpClient { "" }) .to_string(); - fastly_response_to_platform(fastly_resp, backend_name) + (fastly_response_to_platform(fastly_resp, backend_name), None) } Err(e) => { - Err(Report::new(PlatformError::HttpClient) - .attach(format!("fastly select error: {e}"))) + let failed_name = e.backend_name().to_string(); + ( + Err(Report::new(PlatformError::HttpClient).attach(format!( + "fastly select error for backend '{failed_name}': {e}" + ))), + Some(failed_name), + ) } }; - Ok(PlatformSelectResult { ready, remaining }) + Ok(PlatformSelectResult { + ready, + remaining, + failed_backend_name, + }) } } diff --git a/crates/trusted-server-core/src/auction/README.md b/crates/trusted-server-core/src/auction/README.md index e92c4dbb..dcc9e150 100644 --- a/crates/trusted-server-core/src/auction/README.md +++ b/crates/trusted-server-core/src/auction/README.md @@ -24,7 +24,8 @@ The auction orchestration system allows you to: ▼ ┌─────────────────────────────────────────────────────────┐ │ AuctionProvider Trait │ -│ - request_bids() │ +│ - request_bids() async │ +│ - parse_response() │ │ - provider_name() │ │ - timeout_ms() │ │ - is_enabled() │ @@ -479,6 +480,7 @@ timeout_ms = 500 use async_trait::async_trait; use crate::auction::provider::AuctionProvider; use crate::auction::types::{AuctionContext, AuctionRequest, AuctionResponse}; +use crate::platform::{PlatformPendingRequest, PlatformResponse}; pub struct YourAuctionProvider { config: YourConfig, @@ -494,11 +496,19 @@ impl AuctionProvider for YourAuctionProvider { &self, request: &AuctionRequest, _context: &AuctionContext<'_>, - ) -> Result> { + ) -> Result> { // 1. Transform AuctionRequest to your provider's format - // 2. Make HTTP request to your provider - // 3. Parse response - // 4. Return AuctionResponse with bids + // 2. Launch HTTP request through services.http_client().send_async(...) + // 3. Return PlatformPendingRequest for the orchestrator to await + todo!() + } + + async fn parse_response( + &self, + response: PlatformResponse, + response_time_ms: u64, + ) -> Result> { + // 4. Parse PlatformResponse into AuctionResponse todo!() } @@ -534,7 +544,7 @@ let orchestrator = AuctionOrchestrator::new(config); orchestrator.register_provider(Arc::new(PrebidAuctionProvider::try_new(prebid_config)?)); orchestrator.register_provider(Arc::new(ApsAuctionProvider::new(aps_config))); -let result = orchestrator.run_auction(&request, &context).await?; +let result = orchestrator.run_auction(&request, &context, &services).await?; // Check results assert_eq!(result.winning_bids.len(), 2); @@ -543,7 +553,7 @@ assert!(result.total_time_ms < 2000); ## Performance Considerations -- **Parallel Execution**: Currently runs sequentially in Fastly Compute (no tokio runtime), but structured for easy parallelization +- **Parallel Execution**: Providers are launched concurrently via `select()` over `PendingRequest`s; responses are processed as they become ready within the auction deadline - **Timeouts**: Each provider has independent timeout; global timeout enforced at flow level - **Error Handling**: Provider failures don't fail entire auction; partial results returned diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index ed32cb07..b80547d2 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -5,8 +5,6 @@ use error_stack::{Report, ResultExt}; use http::{header, Request, Response, StatusCode}; use serde_json::Value as JsonValue; -use crate::compat; - use crate::auction::formats::AdRequest; use crate::consent::gate_eids_by_consent; use crate::constants::COOKIE_TS_EIDS; @@ -157,13 +155,10 @@ pub async fn handle_auction( log::warn!("Auction EIDs stripped by TCF consent gating"); } - // Provider context only needs request metadata. - let fastly_req = compat::to_fastly_request_ref(&http_req); - // Create auction context let context = AuctionContext { settings, - request: &fastly_req, + request: &http_req, timeout_ms: settings.auction.timeout_ms, provider_responses: None, services, diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index 69adb333..a402dddc 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -140,7 +140,7 @@ pub fn convert_tsjs_to_auction_request( .get(header::USER_AGENT) .and_then(|value| value.to_str().ok()) .map(str::to_string), - ip: services.client_info.client_ip.map(|ip| ip.to_string()), + ip: services.client_info().client_ip.map(|ip| ip.to_string()), geo, }); diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index fd59c778..0440d4b1 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -1,7 +1,6 @@ //! Auction orchestrator for managing multi-provider auctions. use error_stack::{Report, ResultExt}; -use fastly::http::request::{select, PendingRequest}; use std::collections::HashMap; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -14,7 +13,6 @@ use super::types::{AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStat const PROVIDER_ERROR_MESSAGE_CHARS: usize = 500; -pub(crate) const ERROR_TYPE_HTTP_STATUS: &str = "http_status"; const ERROR_TYPE_PARSE_RESPONSE: &str = "parse_response"; const ERROR_TYPE_LAUNCH_FAILED: &str = "launch_failed"; @@ -187,17 +185,24 @@ impl AuctionOrchestrator { let start_time = Instant::now(); let pending = mediator .request_bids(request, &mediator_context) + .await .change_context(TrustedServerError::Auction { message: format!("Mediator {} failed to launch", mediator.provider_name()), })?; - let backend_response = pending.wait().change_context(TrustedServerError::Auction { - message: format!("Mediator {} request failed", mediator.provider_name()), - })?; + let platform_resp = mediator_context + .services + .http_client() + .wait(pending) + .await + .change_context(TrustedServerError::Auction { + message: format!("Mediator {} request failed", mediator.provider_name()), + })?; let response_time_ms = start_time.elapsed().as_millis() as u64; let mediator_resp = mediator - .parse_response_with_context(backend_response, response_time_ms, &mediator_context) + .parse_response(platform_resp, response_time_ms) + .await .change_context(TrustedServerError::Auction { message: format!("Mediator {} parse failed", mediator.provider_name()), })?; @@ -262,7 +267,7 @@ impl AuctionOrchestrator { /// Run all providers in parallel and collect responses. /// - /// Uses `fastly::http::request::select()` to process responses as they + /// Uses `PlatformHttpClient::select()` to process responses as they /// become ready, rather than waiting for each response sequentially. async fn run_providers_parallel( &self, @@ -289,7 +294,7 @@ impl AuctionOrchestrator { // Maps backend_name -> (provider_name, start_time, provider) let mut backend_to_provider: HashMap = HashMap::new(); - let mut pending_requests: Vec = Vec::new(); + let mut pending_requests: Vec = Vec::new(); let mut responses = Vec::new(); for provider_name in provider_names { @@ -357,10 +362,22 @@ impl AuctionOrchestrator { ); let start_time = Instant::now(); - match provider.request_bids(request, &provider_context) { + match provider.request_bids(request, &provider_context).await { Ok(pending) => { + let request_backend_name = pending + .backend_name() + .map(str::to_string) + .unwrap_or_else(|| { + log::warn!( + "Provider '{}' pending request returned no backend name; \ + using predicted name '{}'", + provider.provider_name(), + backend_name, + ); + backend_name.clone() + }); backend_to_provider.insert( - backend_name, + request_backend_name.clone(), (provider.provider_name(), start_time, provider.as_ref()), ); pending_requests.push(pending); @@ -404,24 +421,35 @@ impl AuctionOrchestrator { let mut remaining = pending_requests; while !remaining.is_empty() { - let (result, rest) = select(remaining); - remaining = rest; - - match result { + let platform_result = match context.services.http_client().select(remaining).await { + Ok(r) => r, + Err(e) => { + log::warn!("select() failed: {:?}", e); + break; + } + }; + let crate::platform::PlatformSelectResult { + ready, + remaining: new_remaining, + failed_backend_name, + } = platform_result; + remaining = new_remaining; + + match ready { Ok(response) => { // Identify the provider from the backend name - let backend_name = response.get_backend_name().unwrap_or_default().to_string(); + let backend_name = response + .backend_name + .as_deref() + .unwrap_or_default() + .to_string(); if let Some((provider_name, start_time, provider)) = backend_to_provider.remove(&backend_name) { let response_time_ms = start_time.elapsed().as_millis() as u64; - match provider.parse_response_with_context( - response, - response_time_ms, - context, - ) { + match provider.parse_response(response, response_time_ms).await { Ok(auction_response) => { log::info!( "Provider '{}' returned {} bids (status: {:?}, time: {}ms)", @@ -456,9 +484,26 @@ impl AuctionOrchestrator { } } Err(e) => { - // When select() returns an error, we can't easily identify which - // provider failed since the PendingRequest is consumed - log::warn!("A provider request failed: {:?}", e); + if let Some(ref backend_name) = failed_backend_name { + if let Some((provider_name, start_time, _)) = + backend_to_provider.remove(backend_name) + { + let response_time_ms = start_time.elapsed().as_millis() as u64; + log::warn!("Provider '{}' request failed: {:?}", provider_name, e); + responses.push(AuctionResponse::error(provider_name, response_time_ms)); + } else { + log::warn!( + "A provider request failed (backend '{}' not tracked): {:?}", + backend_name, + e + ); + } + } else { + log::warn!( + "A provider request failed (backend not identified): {:?}", + e + ); + } } } @@ -647,18 +692,83 @@ impl OrchestrationResult { #[cfg(test)] mod tests { use crate::auction::config::AuctionConfig; + use crate::auction::provider::AuctionProvider; use crate::auction::test_support::create_test_auction_context; use crate::auction::types::{ - AdFormat, AdSlot, AuctionRequest, Bid, BidStatus, MediaType, PublisherInfo, UserInfo, + AdFormat, AdSlot, AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStatus, + MediaType, PublisherInfo, UserInfo, }; use crate::error::TrustedServerError; + use crate::platform::test_support::{build_services_with_http_client, StubHttpClient}; + use crate::platform::{ + PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, RuntimeServices, + }; use crate::test_support::tests::crate_test_settings_str; - use error_stack::Report; - use fastly::Request; + use error_stack::{Report, ResultExt}; use std::collections::{HashMap, HashSet}; + use std::sync::Arc; use super::AuctionOrchestrator; + // --------------------------------------------------------------------------- + // Minimal test double for AuctionProvider + // --------------------------------------------------------------------------- + + struct StubAuctionProvider { + name: &'static str, + backend: &'static str, + } + + #[async_trait::async_trait(?Send)] + impl AuctionProvider for StubAuctionProvider { + fn provider_name(&self) -> &'static str { + self.name + } + + async fn request_bids( + &self, + _request: &AuctionRequest, + context: &AuctionContext<'_>, + ) -> Result> { + let req = PlatformHttpRequest::new( + http::Request::builder() + .method("POST") + .uri("https://example.com/bid") + .body(edgezero_core::body::Body::empty()) + .expect("should build stub bid request"), + self.backend, + ); + context + .services + .http_client() + .send_async(req) + .await + .change_context(TrustedServerError::Auction { + message: "stub launch failed".to_string(), + }) + } + + async fn parse_response( + &self, + _response: PlatformResponse, + response_time_ms: u64, + ) -> Result> { + Ok(AuctionResponse::success( + self.name, + vec![], + response_time_ms, + )) + } + + fn timeout_ms(&self) -> u32 { + 2000 + } + + fn backend_name(&self, _timeout_ms: u32) -> Option { + Some(self.backend.to_string()) + } + } + fn create_test_auction_request() -> AuctionRequest { AuctionRequest { id: "test-auction-123".to_string(), @@ -838,8 +948,9 @@ mod tests { } // TODO: Re-enable provider integration tests after implementing mock support - // for send_async(). Mock providers can't create PendingRequest without real - // Fastly backends. + // for `PlatformHttpClient::send_async()`. Mock providers currently cannot + // create realistic pending requests for the select loop without real + // platform-backed transport handles. // // Untested timeout enforcement paths (require real backends): // - Deadline check in select() loop (drops remaining requests) @@ -847,9 +958,9 @@ mod tests { // - Provider skip when effective_timeout == 0 (budget exhausted before launch) // - Provider context receives reduced timeout_ms per remaining budget // - // Follow-up: introduce a thin abstraction over `select()` (e.g. a trait) + // Follow-up: introduce a thin abstraction over `PlatformHttpClient::select()` // so the deadline/drop logic can be unit-tested with mock futures instead - // of requiring real Fastly backends. An `#[ignore]` integration test + // of requiring real platform backends. An `#[ignore]` integration test // exercising the full path via Viceroy would also catch regressions. #[tokio::test] @@ -867,7 +978,11 @@ mod tests { let request = create_test_auction_request(); let settings = create_test_settings(); - let req = Request::get("https://test.com/test"); + let req = http::Request::builder() + .method(http::Method::GET) + .uri("https://test.com/test") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); let context = create_test_auction_context(&settings, &req, 2000); let result = orchestrator.run_auction(&request, &context).await; @@ -929,6 +1044,89 @@ mod tests { ); } + #[tokio::test] + async fn select_error_is_attributed_to_correct_provider() { + // Arrange: two stub providers backed by distinct backend names. + // The stub HTTP client injects a select() error for the first request + // that completes (backend-a). backend-b should still produce a success. + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"{}".to_vec()); // consumed by send_async for backend-a + stub.push_response(200, b"{}".to_vec()); // consumed by send_async for backend-b + stub.push_select_error(); // first select() reports backend-a as failed + + let services = build_services_with_http_client(stub); + // SAFETY: `Box::leak` creates a `'static` reference for test use only. + // The leaked allocation is bounded to the test process lifetime. + let services: &'static RuntimeServices = Box::leak(Box::new(services)); + + let config = AuctionConfig { + enabled: true, + providers: vec!["provider-a".to_string(), "provider-b".to_string()], + timeout_ms: 2000, + mediator: None, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(StubAuctionProvider { + name: "provider-a", + backend: "backend-a", + })); + orchestrator.register_provider(Arc::new(StubAuctionProvider { + name: "provider-b", + backend: "backend-b", + })); + + let request = create_test_auction_request(); + let settings = create_test_settings(); + let req = http::Request::builder() + .method(http::Method::GET) + .uri("https://example.com/test") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let context = AuctionContext { + settings: &settings, + request: &req, + timeout_ms: 2000, + provider_responses: None, + services, + }; + + // Act + let result = orchestrator + .run_auction(&request, &context) + .await + .expect("should complete auction even when one provider errors"); + + // Assert: exactly two responses — one error, one success. + assert_eq!( + result.provider_responses.len(), + 2, + "should collect responses from both providers" + ); + + let provider_a = result + .provider_responses + .iter() + .find(|r| r.provider == "provider-a") + .expect("should have provider-a response"); + let provider_b = result + .provider_responses + .iter() + .find(|r| r.provider == "provider-b") + .expect("should have provider-b response"); + + assert_eq!( + provider_a.status, + BidStatus::Error, + "provider-a should be marked error — select() Err was attributed via failed_backend_name" + ); + assert_eq!( + provider_b.status, + BidStatus::Success, + "provider-b should succeed — error was correctly isolated to provider-a" + ); + } + #[test] fn test_apply_floor_prices_allows_none_prices_for_encoded_bids() { // Test that bids with None prices (APS-style) pass through floor pricing diff --git a/crates/trusted-server-core/src/auction/provider.rs b/crates/trusted-server-core/src/auction/provider.rs index 7e509043..5f4b8234 100644 --- a/crates/trusted-server-core/src/auction/provider.rs +++ b/crates/trusted-server-core/src/auction/provider.rs @@ -1,13 +1,15 @@ //! Trait definition for auction providers. +use async_trait::async_trait; use error_stack::Report; -use fastly::http::request::PendingRequest; use crate::error::TrustedServerError; +use crate::platform::{PlatformPendingRequest, PlatformResponse}; use super::types::{AuctionContext, AuctionRequest, AuctionResponse}; /// Trait implemented by all auction providers (Prebid, APS, GAM, etc.). +#[async_trait(?Send)] pub trait AuctionProvider: Send + Sync { /// Unique identifier for this provider (e.g., "prebid", "aps", "gam"). fn provider_name(&self) -> &'static str; @@ -16,53 +18,37 @@ pub trait AuctionProvider: Send + Sync { /// /// Implementations should: /// - Transform `AuctionRequest` to provider-specific format - /// - Make HTTP call to provider endpoint using `send_async()` - /// - Return `PendingRequest` for orchestrator to await + /// - Make an HTTP call through `context.services.http_client().send_async(...)` + /// - Return [`PlatformPendingRequest`] for the orchestrator to await /// /// The orchestrator will handle waiting for responses and parsing them. /// /// # Errors /// /// Returns an error if the request cannot be created or if the provider endpoint - /// cannot be reached (though usually network errors happen during `PendingRequest` await). - fn request_bids( + /// cannot be reached (though usually network errors happen while the returned + /// [`PlatformPendingRequest`] is polled). + async fn request_bids( &self, request: &AuctionRequest, context: &AuctionContext<'_>, - ) -> Result>; + ) -> Result>; /// Parse the response from the provider into an `AuctionResponse`. /// - /// Called by the orchestrator after the `PendingRequest` completes. + /// Called by the orchestrator after the [`PlatformPendingRequest`] completes. + /// Declared async so implementations can safely drain streaming response bodies + /// without panicking on the `Body::Stream` variant. /// /// # Errors /// /// Returns an error if the response cannot be parsed into a valid `AuctionResponse`. - fn parse_response( + async fn parse_response( &self, - response: fastly::Response, + response: PlatformResponse, response_time_ms: u64, ) -> Result>; - /// Parse the response with access to the original auction context. - /// - /// Providers that need request-local metadata while transforming responses - /// can override this method. The default preserves the existing - /// response-only provider contract. - /// - /// # Errors - /// - /// Returns an error if the response cannot be parsed into a valid [`AuctionResponse`]. - fn parse_response_with_context( - &self, - response: fastly::Response, - response_time_ms: u64, - context: &AuctionContext<'_>, - ) -> Result> { - let _ = context; - self.parse_response(response, response_time_ms) - } - /// Check if this provider supports a specific media type. fn supports_media_type(&self, media_type: &super::types::MediaType) -> bool { // By default, support banner ads @@ -81,7 +67,7 @@ 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 [`BackendConfig::backend_name_for_url()`] so the predicted + /// 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 { None diff --git a/crates/trusted-server-core/src/auction/test_support.rs b/crates/trusted-server-core/src/auction/test_support.rs index bfcdd10d..ed11cc73 100644 --- a/crates/trusted-server-core/src/auction/test_support.rs +++ b/crates/trusted-server-core/src/auction/test_support.rs @@ -1,6 +1,7 @@ use std::sync::LazyLock; -use fastly::Request; +use edgezero_core::body::Body as EdgeBody; +use http::Request; use super::AuctionContext; use crate::platform::{test_support::noop_services, RuntimeServices}; @@ -10,7 +11,7 @@ static TEST_SERVICES: LazyLock = LazyLock::new(noop_services); pub(crate) fn create_test_auction_context<'a>( settings: &'a Settings, - request: &'a Request, + request: &'a Request, timeout_ms: u32, ) -> AuctionContext<'a> { let services: &'static RuntimeServices = &TEST_SERVICES; diff --git a/crates/trusted-server-core/src/auction/types.rs b/crates/trusted-server-core/src/auction/types.rs index 9b74d89e..d068078f 100644 --- a/crates/trusted-server-core/src/auction/types.rs +++ b/crates/trusted-server-core/src/auction/types.rs @@ -1,6 +1,7 @@ //! Core types for auction requests and responses. -use fastly::Request; +use edgezero_core::body::Body as EdgeBody; +use http::Request; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -109,7 +110,7 @@ pub struct SiteInfo { /// Context passed to auction providers. pub struct AuctionContext<'a> { pub settings: &'a Settings, - pub request: &'a Request, + pub request: &'a Request, pub timeout_ms: u32, /// Provider responses from the bidding phase, used by mediators. /// This is `None` for regular bidders and `Some` when calling a mediator. diff --git a/crates/trusted-server-core/src/integrations/adserver_mock.rs b/crates/trusted-server-core/src/integrations/adserver_mock.rs index 50b1f9fa..655cb11c 100644 --- a/crates/trusted-server-core/src/integrations/adserver_mock.rs +++ b/crates/trusted-server-core/src/integrations/adserver_mock.rs @@ -4,9 +4,10 @@ //! This integration acts as a mediator in the auction flow, selecting winning bids //! based on price (highest price wins). +use async_trait::async_trait; +use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; -use fastly::http::Method; -use fastly::Request; +use http::{header, Method}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value as Json}; use std::collections::{BTreeMap, HashMap}; @@ -21,6 +22,10 @@ use crate::auction::types::{ }; use crate::backend::BackendConfig; use crate::error::TrustedServerError; +use crate::integrations::{ + collect_response_bounded, ensure_integration_backend, UPSTREAM_RTB_MAX_RESPONSE_BYTES, +}; +use crate::platform::{PlatformHttpRequest, PlatformPendingRequest, PlatformResponse}; use crate::settings::{IntegrationConfig, Settings}; // ============================================================================ @@ -269,16 +274,17 @@ impl AdServerMockProvider { } } +#[async_trait(?Send)] impl AuctionProvider for AdServerMockProvider { fn provider_name(&self) -> &'static str { "adserver_mock" } - fn request_bids( + async fn request_bids( &self, request: &AuctionRequest, context: &AuctionContext<'_>, - ) -> Result> { + ) -> Result> { // Get bidder responses from context (passed by orchestrator for mediation) let bidder_responses = context.provider_responses.unwrap_or(&[]); @@ -301,7 +307,18 @@ impl AuctionProvider for AdServerMockProvider { let endpoint_url = self.build_endpoint_url(request); // Create HTTP POST request - let mut req = Request::new(Method::POST, &endpoint_url); + let mediation_body = + serde_json::to_vec(&mediation_req).change_context(TrustedServerError::Auction { + message: "Failed to serialize mediation request".to_string(), + })?; + let mut req = http::Request::builder() + .method(Method::POST) + .uri(&endpoint_url) + .header(header::CONTENT_TYPE, "application/json") + .body(EdgeBody::from(mediation_body)) + .change_context(TrustedServerError::Auction { + message: "Failed to build mediation request".to_string(), + })?; // Set Host header with port to ensure mocktioneer generates correct iframe URLs if let Ok(url) = url::Url::parse(&self.config.endpoint) { @@ -311,30 +328,33 @@ impl AuctionProvider for AdServerMockProvider { } else { host.to_string() }; - req.set_header("Host", &host_with_port); + match header::HeaderValue::from_str(&host_with_port) { + Ok(value) => { + req.headers_mut().insert(header::HOST, value); + } + Err(e) => { + log::warn!( + "Failed to build Host header for '{}': {}", + host_with_port, + e + ); + } + } } } - req.set_body_json(&mediation_req) - .change_context(TrustedServerError::Auction { - message: "Failed to set mediation request body".to_string(), - })?; - - // Send async with auction-scoped timeout - let backend_name = BackendConfig::from_url_with_first_byte_timeout( + let backend_name = ensure_integration_backend( + context.services, &self.config.endpoint, - true, - Duration::from_millis(u64::from(context.timeout_ms)), - ) - .change_context(TrustedServerError::Auction { - message: format!( - "Failed to resolve backend for mediation endpoint: {}", - self.config.endpoint - ), - })?; - - let pending = req - .send_async(backend_name) + "adserver_mock", + Some(Duration::from_millis(u64::from(context.timeout_ms))), + )?; + + let pending = context + .services + .http_client() + .send_async(PlatformHttpRequest::new(req, backend_name)) + .await .change_context(TrustedServerError::Auction { message: "Failed to send mediation request".to_string(), })?; @@ -342,20 +362,28 @@ impl AuctionProvider for AdServerMockProvider { Ok(pending) } - fn parse_response( + async fn parse_response( &self, - mut response: fastly::Response, + response: PlatformResponse, response_time_ms: u64, ) -> Result> { - if !response.get_status().is_success() { - log::warn!( - "AdServer Mock returned non-success: {}", - response.get_status() - ); + let response = response.response; + + if !response.status().is_success() { + log::warn!("AdServer Mock returned non-success: {}", response.status()); return Ok(AuctionResponse::error("adserver_mock", response_time_ms)); } - let body_bytes = response.take_body_bytes(); + // collect_response_bounded caps memory from misbehaving providers. + let body_bytes = collect_response_bounded( + response.into_body(), + UPSTREAM_RTB_MAX_RESPONSE_BYTES, + "adserver_mock", + ) + .await + .change_context(TrustedServerError::Auction { + message: "Failed to read AdServer Mock response body".to_string(), + })?; let response_json: Json = serde_json::from_slice(&body_bytes).change_context(TrustedServerError::Auction { message: "Failed to parse mediation response".to_string(), diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 1c966e71..2102c2e5 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -2,9 +2,10 @@ //! //! This module provides the APS auction provider for server-side bidding. +use async_trait::async_trait; +use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; -use fastly::http::Method; -use fastly::Request; +use http::{header, Method}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value as Json}; use std::collections::HashMap; @@ -15,6 +16,10 @@ 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_response_bounded, ensure_integration_backend, UPSTREAM_RTB_MAX_RESPONSE_BYTES, +}; +use crate::platform::{PlatformHttpRequest, PlatformPendingRequest, PlatformResponse}; use crate::settings::IntegrationConfig; // ============================================================================ @@ -468,16 +473,17 @@ impl ApsAuctionProvider { } } +#[async_trait(?Send)] impl AuctionProvider for ApsAuctionProvider { fn provider_name(&self) -> &'static str { "aps" } - fn request_bids( + async fn request_bids( &self, request: &AuctionRequest, context: &AuctionContext<'_>, - ) -> Result> { + ) -> Result> { log::info!( "APS: requesting bids for {} slots (pub_id: {})", request.slots.len(), @@ -496,50 +502,58 @@ impl AuctionProvider for ApsAuctionProvider { log::trace!("APS: sending bid request: {:?}", aps_json); // Create HTTP POST request - let mut aps_req = Request::new(Method::POST, &self.config.endpoint); - - aps_req - .set_body_json(&aps_json) + let aps_body = + serde_json::to_vec(&aps_json).change_context(TrustedServerError::Auction { + message: "Failed to serialize APS request body".to_string(), + })?; + let aps_req = http::Request::builder() + .method(Method::POST) + .uri(&self.config.endpoint) + .header(header::CONTENT_TYPE, "application/json") + .body(EdgeBody::from(aps_body)) .change_context(TrustedServerError::Auction { - message: "Failed to set APS request body".to_string(), + message: "Failed to build APS request".to_string(), })?; - // Send request asynchronously with auction-scoped timeout - let backend_name = BackendConfig::from_url_with_first_byte_timeout( + let backend_name = ensure_integration_backend( + context.services, &self.config.endpoint, - true, - Duration::from_millis(u64::from(context.timeout_ms)), - ) - .change_context(TrustedServerError::Auction { - message: format!( - "Failed to resolve backend for APS endpoint: {}", - self.config.endpoint - ), - })?; - - let pending = - aps_req - .send_async(backend_name) - .change_context(TrustedServerError::Auction { - message: "Failed to send async request to APS".to_string(), - })?; + "aps", + Some(Duration::from_millis(u64::from(context.timeout_ms))), + )?; + + let pending = context + .services + .http_client() + .send_async(PlatformHttpRequest::new(aps_req, backend_name)) + .await + .change_context(TrustedServerError::Auction { + message: "Failed to send async request to APS".to_string(), + })?; Ok(pending) } - fn parse_response( + async fn parse_response( &self, - mut response: fastly::Response, + response: PlatformResponse, response_time_ms: u64, ) -> Result> { + let response = response.response; + // Check status code - if !response.get_status().is_success() { - log::warn!("APS returned non-success status: {}", response.get_status()); + if !response.status().is_success() { + log::warn!("APS returned non-success status: {}", response.status()); return Ok(AuctionResponse::error("aps", response_time_ms)); } - // Parse response body - let body_bytes = response.take_body_bytes(); + // Parse response body — collect_response_bounded caps memory from misbehaving providers. + let body_bytes = + collect_response_bounded(response.into_body(), UPSTREAM_RTB_MAX_RESPONSE_BYTES, "aps") + .await + .change_context(TrustedServerError::Auction { + message: "Failed to read APS response body".to_string(), + })?; let response_json: Json = serde_json::from_slice(&body_bytes).change_context(TrustedServerError::Auction { message: "Failed to parse APS response JSON".to_string(), diff --git a/crates/trusted-server-core/src/integrations/datadome.rs b/crates/trusted-server-core/src/integrations/datadome.rs index 0ce83b2f..578c4123 100644 --- a/crates/trusted-server-core/src/integrations/datadome.rs +++ b/crates/trusted-server-core/src/integrations/datadome.rs @@ -58,20 +58,22 @@ use std::sync::{Arc, LazyLock}; use async_trait::async_trait; +use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; -use fastly::http::{header, Method, StatusCode}; -use fastly::{Request, Response}; +use http::header; +use http::{Method, StatusCode}; use regex::Regex; use serde::Deserialize; use validator::Validate; -use crate::backend::BackendConfig; use crate::error::TrustedServerError; use crate::integrations::{ + collect_body_bounded, collect_response_bounded, ensure_integration_backend, AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, - IntegrationEndpoint, IntegrationProxy, IntegrationRegistration, + IntegrationEndpoint, IntegrationProxy, IntegrationRegistration, INTEGRATION_MAX_BODY_BYTES, + UPSTREAM_SDK_MAX_RESPONSE_BYTES, }; -use crate::platform::RuntimeServices; +use crate::platform::{PlatformHttpRequest, RuntimeServices}; use crate::settings::{IntegrationConfig, Settings}; const DATADOME_INTEGRATION_ID: &str = "datadome"; @@ -249,122 +251,163 @@ impl DataDomeIntegration { } /// Handle the /tags.js endpoint - fetch and rewrite the `DataDome` SDK. - async fn handle_tags_js(&self, req: Request) -> Result> { - let target_url = self.build_sdk_url("/tags.js", req.get_query_str()); + async fn handle_tags_js( + &self, + services: &RuntimeServices, + req: http::Request, + ) -> Result, Report> { + let target_url = self.build_sdk_url("/tags.js", req.uri().query()); log::info!("[datadome] Fetching tags.js from {}", target_url); - let backend = BackendConfig::from_url(&target_url, true) + let backend = Self::backend_name_for_url(services, &target_url) .change_context(Self::error("Invalid SDK URL"))?; let sdk_host = Self::extract_host(&self.config.sdk_origin); - let mut backend_req = Request::new(Method::GET, &target_url); - backend_req.set_header(header::HOST, sdk_host); - backend_req.set_header(header::ACCEPT, "application/javascript, */*"); + let mut backend_req = http::Request::builder() + .method(Method::GET) + .uri(&target_url) + .header(header::HOST, sdk_host) + .header(header::ACCEPT, "application/javascript, */*") + .body(EdgeBody::empty()) + .change_context(Self::error("Failed to build DataDome SDK request"))?; // Copy relevant headers from original request - if let Some(ua) = req.get_header(header::USER_AGENT) { - backend_req.set_header(header::USER_AGENT, ua); + if let Some(ua) = req.headers().get(header::USER_AGENT) { + backend_req + .headers_mut() + .insert(header::USER_AGENT, ua.clone()); } - let mut backend_resp = backend_req - .send(&backend) + let backend_resp = services + .http_client() + .send(PlatformHttpRequest::new(backend_req, backend)) + .await .change_context(Self::error("Failed to fetch tags.js from DataDome"))?; - if backend_resp.get_status() != StatusCode::OK { + if backend_resp.response.status() != StatusCode::OK { log::warn!( "[datadome] tags.js fetch returned status {}", - backend_resp.get_status() + backend_resp.response.status() ); - return Ok(backend_resp); + return Ok(backend_resp.response); } // Read and rewrite the script content - let body = backend_resp.take_body_str(); - let rewritten = self.rewrite_script_content(&body); + let cors_header = backend_resp + .response + .headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .cloned(); + let body = collect_response_bounded( + backend_resp.response.into_body(), + UPSTREAM_SDK_MAX_RESPONSE_BYTES, + DATADOME_INTEGRATION_ID, + ) + .await + .change_context(Self::error("Failed to read DataDome SDK response body"))?; + let rewritten = self.rewrite_script_content(&String::from_utf8_lossy(&body)); // Build response with caching headers - let mut response = Response::new(); - response.set_status(StatusCode::OK); - response.set_header( - header::CONTENT_TYPE, - "application/javascript; charset=utf-8", - ); - response.set_header( - header::CACHE_CONTROL, - format!("public, max-age={}", self.config.cache_ttl_seconds), - ); + let mut response = http::Response::builder() + .status(StatusCode::OK) + .header( + header::CONTENT_TYPE, + "application/javascript; charset=utf-8", + ) + .header( + header::CACHE_CONTROL, + format!("public, max-age={}", self.config.cache_ttl_seconds), + ) + .body(EdgeBody::from(rewritten.into_bytes())) + .change_context(Self::error("Failed to build DataDome SDK response"))?; // Copy CORS headers if present - if let Some(cors) = backend_resp.get_header(header::ACCESS_CONTROL_ALLOW_ORIGIN) { - response.set_header(header::ACCESS_CONTROL_ALLOW_ORIGIN, cors); + if let Some(cors) = cors_header { + response + .headers_mut() + .insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, cors); } - response.set_body(rewritten); Ok(response) } /// Handle the /js/* signal collection endpoint - proxy pass-through to api-js.datadome.co. - async fn handle_js_api(&self, req: Request) -> Result> { - let original_path = req.get_path(); + async fn handle_js_api( + &self, + services: &RuntimeServices, + req: http::Request, + ) -> Result, Report> { + let (parts, body) = req.into_parts(); + let original_path = parts.uri.path().to_string(); // Strip our prefix to get the DataDome path let datadome_path = original_path .strip_prefix("/integrations/datadome") - .unwrap_or(original_path); + .unwrap_or(&original_path); // Use api_origin (api-js.datadome.co) for signal collection requests - let target_url = self.build_api_url(datadome_path, req.get_query_str()); + let target_url = self.build_api_url(datadome_path, parts.uri.query()); let api_host = Self::extract_host(&self.config.api_origin); log::info!( "[datadome] Proxying signal request to {} (method: {}, host: {})", target_url, - req.get_method(), + parts.method, api_host ); - let backend = BackendConfig::from_url(&target_url, true) + let backend = Self::backend_name_for_url(services, &target_url) .change_context(Self::error("Invalid API URL"))?; - let mut backend_req = Request::new(req.get_method().clone(), &target_url); - backend_req.set_header(header::HOST, api_host); + let request_body = if parts.method == Method::POST || parts.method == Method::PUT { + let bytes = + collect_body_bounded(body, INTEGRATION_MAX_BODY_BYTES, DATADOME_INTEGRATION_ID) + .await?; + EdgeBody::from(bytes) + } else { + EdgeBody::empty() + }; + + let mut backend_req = http::Request::builder() + .method(parts.method.clone()) + .uri(&target_url) + .header(header::HOST, api_host) + .body(request_body) + .change_context(Self::error("Failed to build DataDome API request"))?; - // Copy relevant headers + // Copy relevant headers from the original client request. + // CONTENT_LENGTH is intentionally omitted: the body is re-materialized + // via collect_body_bounded, so its length may differ from the original. let headers_to_copy = [ header::USER_AGENT, header::ACCEPT, header::ACCEPT_LANGUAGE, header::ACCEPT_ENCODING, header::CONTENT_TYPE, - header::CONTENT_LENGTH, header::ORIGIN, header::REFERER, ]; for h in &headers_to_copy { - if let Some(value) = req.get_header(h) { - backend_req.set_header(h, value); + if let Some(value) = parts.headers.get(h) { + backend_req.headers_mut().insert(h, value.clone()); } } - // Copy body for POST/PUT requests - if req.get_method() == Method::POST || req.get_method() == Method::PUT { - let body = req.into_body(); - backend_req.set_body(body); - } - - let backend_resp = backend_req - .send(&backend) + let backend_resp = services + .http_client() + .send(PlatformHttpRequest::new(backend_req, backend)) + .await .change_context(Self::error("Failed to proxy signal request to DataDome"))?; log::info!( "[datadome] Signal request returned status {}", - backend_resp.get_status() + backend_resp.response.status() ); - Ok(backend_resp) + Ok(backend_resp.response) } /// Extract the path portion after the `DataDome` domain from a URL. @@ -381,6 +424,13 @@ impl DataDomeIntegration { }) .unwrap_or("/tags.js") } + + fn backend_name_for_url( + services: &RuntimeServices, + target_url: &str, + ) -> Result> { + ensure_integration_backend(services, target_url, DATADOME_INTEGRATION_ID, None) + } } #[async_trait(?Send)] @@ -405,15 +455,15 @@ impl IntegrationProxy for DataDomeIntegration { async fn handle( &self, _settings: &Settings, - _services: &RuntimeServices, - req: Request, - ) -> Result> { - let path = req.get_path(); + services: &RuntimeServices, + req: http::Request, + ) -> Result, Report> { + let path = req.uri().path().to_string(); if path == "/integrations/datadome/tags.js" { - self.handle_tags_js(req).await + self.handle_tags_js(services, req).await } else if path.starts_with("/integrations/datadome/js/") { - self.handle_js_api(req).await + self.handle_js_api(services, req).await } else { Err(Report::new(Self::error(format!( "Unknown DataDome route: {}", @@ -503,7 +553,11 @@ pub fn register( #[cfg(test)] mod tests { + use std::sync::Arc; + use super::*; + use crate::platform::test_support::{build_services_with_http_client, StubHttpClient}; + use crate::test_support::tests::create_test_settings; fn test_config() -> DataDomeConfig { DataDomeConfig { @@ -820,4 +874,34 @@ mod tests { _ => panic!("Expected Replace action for bare domain"), } } + + #[test] + fn datadome_proxy_uses_platform_http_client() { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"ok".to_vec()); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let settings = create_test_settings(); + let integration = DataDomeIntegration::new(test_config()); + let req = http::Request::builder() + .method(http::Method::GET) + .uri("https://publisher.example/integrations/datadome/js/check") + .body(EdgeBody::empty()) + .expect("should build request"); + + let response = futures::executor::block_on(integration.handle(&settings, &services, req)) + .expect("should proxy request"); + + assert_eq!( + response.status(), + http::StatusCode::OK, + "should return stubbed response" + ); + assert_eq!( + stub.recorded_backend_names(), + vec!["stub-backend".to_string()], + "should route outbound request through PlatformHttpClient" + ); + } } diff --git a/crates/trusted-server-core/src/integrations/didomi.rs b/crates/trusted-server-core/src/integrations/didomi.rs index 2cada38f..d281d96f 100644 --- a/crates/trusted-server-core/src/integrations/didomi.rs +++ b/crates/trusted-server-core/src/integrations/didomi.rs @@ -1,17 +1,20 @@ use std::sync::Arc; use async_trait::async_trait; +use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; -use fastly::http::{header, Method}; -use fastly::{Request, Response}; +use http::header::{self, HeaderMap, HeaderValue}; +use http::Method; use serde::{Deserialize, Serialize}; use url::Url; use validator::Validate; -use crate::backend::BackendConfig; use crate::error::TrustedServerError; -use crate::integrations::{IntegrationEndpoint, IntegrationProxy, IntegrationRegistration}; -use crate::platform::RuntimeServices; +use crate::integrations::{ + collect_body_bounded, ensure_integration_backend, IntegrationEndpoint, IntegrationProxy, + IntegrationRegistration, INTEGRATION_MAX_BODY_BYTES, +}; +use crate::platform::{PlatformHttpRequest, RuntimeServices}; use crate::settings::{IntegrationConfig, Settings}; const DIDOMI_INTEGRATION_ID: &str = "didomi"; @@ -101,33 +104,42 @@ impl DidomiIntegration { fn copy_headers( &self, backend: &DidomiBackend, - original_req: &Request, - proxy_req: &mut Request, + client_ip: Option, + original_headers: &HeaderMap, + proxy_headers: &mut HeaderMap, ) { - if let Some(client_ip) = original_req.get_client_ip_addr() { - proxy_req.set_header("X-Forwarded-For", client_ip.to_string()); + if let Some(ip) = client_ip { + proxy_headers.insert( + "X-Forwarded-For", + HeaderValue::from_str(&ip.to_string()) + .expect("should format X-Forwarded-For header"), + ); } for header_name in [ header::ACCEPT, header::ACCEPT_LANGUAGE, header::ACCEPT_ENCODING, + header::CONTENT_TYPE, header::USER_AGENT, header::REFERER, header::ORIGIN, header::AUTHORIZATION, ] { - if let Some(value) = original_req.get_header(&header_name) { - proxy_req.set_header(&header_name, value); + if let Some(value) = original_headers.get(&header_name) { + proxy_headers.insert(header_name, value.clone()); } } if matches!(backend, DidomiBackend::Sdk) { - Self::copy_geo_headers(original_req, proxy_req); + Self::copy_geo_headers(original_headers, proxy_headers); } } - fn copy_geo_headers(original_req: &Request, proxy_req: &mut Request) { + fn copy_geo_headers( + original_headers: &HeaderMap, + proxy_headers: &mut HeaderMap, + ) { let geo_headers = [ ("X-Geo-Country", "FastlyGeo-CountryCode"), ("X-Geo-Region", "FastlyGeo-Region"), @@ -135,23 +147,33 @@ impl DidomiIntegration { ]; for (target, source) in geo_headers { - if let Some(value) = original_req.get_header(source) { - proxy_req.set_header(target, value); + if let Some(value) = original_headers.get(source) { + proxy_headers.insert(target, value.clone()); } } } - fn add_cors_headers(response: &mut Response) { - response.set_header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*"); - response.set_header( + fn add_cors_headers(response: &mut http::Response) { + response.headers_mut().insert( + header::ACCESS_CONTROL_ALLOW_ORIGIN, + HeaderValue::from_static("*"), + ); + response.headers_mut().insert( header::ACCESS_CONTROL_ALLOW_HEADERS, - "Content-Type, Authorization, X-Requested-With", + HeaderValue::from_static("Content-Type, Authorization, X-Requested-With"), ); - response.set_header( + response.headers_mut().insert( header::ACCESS_CONTROL_ALLOW_METHODS, - "GET, POST, PUT, DELETE, OPTIONS", + HeaderValue::from_static("GET, POST, PUT, DELETE, OPTIONS"), ); } + + fn backend_name_for_origin( + services: &RuntimeServices, + origin: &str, + ) -> Result> { + ensure_integration_backend(services, origin, DIDOMI_INTEGRATION_ID, None) + } } fn build( @@ -199,11 +221,12 @@ impl IntegrationProxy for DidomiIntegration { async fn handle( &self, _settings: &Settings, - _services: &RuntimeServices, - req: Request, - ) -> Result> { - let path = req.get_path(); - let consent_path = path.strip_prefix(DIDOMI_PREFIX).unwrap_or(path); + services: &RuntimeServices, + req: http::Request, + ) -> Result, Report> { + let (parts, body) = req.into_parts(); + let path = parts.uri.path().to_string(); + let consent_path = path.strip_prefix(DIDOMI_PREFIX).unwrap_or(&path); let backend = self.backend_for_path(consent_path); let base_origin = match backend { DidomiBackend::Sdk => self.config.sdk_origin.as_str(), @@ -211,39 +234,56 @@ impl IntegrationProxy for DidomiIntegration { }; let target_url = self - .build_target_url(base_origin, consent_path, req.get_query_str()) + .build_target_url(base_origin, consent_path, parts.uri.query()) .change_context(Self::error("Failed to build Didomi target URL"))?; - let backend_name = BackendConfig::from_url(base_origin, true) + let backend_name = Self::backend_name_for_origin(services, base_origin) .change_context(Self::error("Failed to configure Didomi backend"))?; - let mut proxy_req = Request::new(req.get_method().clone(), &target_url); - self.copy_headers(&backend, &req, &mut proxy_req); + let request_body = if parts.method == Method::POST { + let bytes = + collect_body_bounded(body, INTEGRATION_MAX_BODY_BYTES, DIDOMI_INTEGRATION_ID) + .await?; + EdgeBody::from(bytes) + } else { + EdgeBody::empty() + }; - if matches!(req.get_method(), &Method::POST | &Method::PUT) { - if let Some(content_type) = req.get_header(header::CONTENT_TYPE) { - proxy_req.set_header(header::CONTENT_TYPE, content_type); - } - proxy_req.set_body(req.into_body()); - } + let mut proxy_req = http::Request::builder() + .method(parts.method.clone()) + .uri(&target_url) + .body(request_body) + .change_context(Self::error("Failed to build Didomi proxy request"))?; + self.copy_headers( + &backend, + services.client_info().client_ip, + &parts.headers, + proxy_req.headers_mut(), + ); - let mut response = proxy_req - .send(&backend_name) + let mut response = services + .http_client() + .send(PlatformHttpRequest::new(proxy_req, backend_name)) + .await .change_context(Self::error("Didomi upstream request failed"))?; if matches!(backend, DidomiBackend::Sdk) { - Self::add_cors_headers(&mut response); + Self::add_cors_headers(&mut response.response); } - Ok(response) + Ok(response.response) } } #[cfg(test)] mod tests { + use std::sync::Arc; + use super::*; use crate::integrations::IntegrationRegistry; + use crate::platform::test_support::{build_services_with_http_client, StubHttpClient}; use crate::test_support::tests::create_test_settings; - use fastly::http::Method; + use http::Method; + use std::net::{IpAddr, Ipv4Addr}; fn config(enabled: bool) -> DidomiIntegrationConfig { DidomiIntegrationConfig { @@ -288,4 +328,95 @@ mod tests { assert!(registry.has_route(&Method::POST, "/integrations/didomi/consent/api/events")); assert!(!registry.has_route(&Method::GET, "/other")); } + + #[test] + fn copy_headers_sets_x_forwarded_for_from_client_ip() { + let integration = DidomiIntegration::new(Arc::new(config(true))); + let backend = DidomiBackend::Sdk; + let original_req = http::Request::builder() + .method(Method::GET) + .uri("https://example.com/test") + .body(EdgeBody::empty()) + .expect("should build original request"); + let mut proxy_req = http::Request::builder() + .method(Method::GET) + .uri("https://sdk.privacy-center.org/test") + .body(EdgeBody::empty()) + .expect("should build proxy request"); + let client_ip = Some(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4))); + + integration.copy_headers( + &backend, + client_ip, + original_req.headers(), + proxy_req.headers_mut(), + ); + + assert_eq!( + proxy_req + .headers() + .get("X-Forwarded-For") + .and_then(|v| v.to_str().ok()), + Some("1.2.3.4"), + "should set X-Forwarded-For from client_ip" + ); + } + + #[test] + fn copy_headers_omits_x_forwarded_for_when_no_client_ip() { + let integration = DidomiIntegration::new(Arc::new(config(true))); + let backend = DidomiBackend::Sdk; + let original_req = http::Request::builder() + .method(Method::GET) + .uri("https://example.com/test") + .body(EdgeBody::empty()) + .expect("should build original request"); + let mut proxy_req = http::Request::builder() + .method(Method::GET) + .uri("https://sdk.privacy-center.org/test") + .body(EdgeBody::empty()) + .expect("should build proxy request"); + + integration.copy_headers( + &backend, + None, + original_req.headers(), + proxy_req.headers_mut(), + ); + + assert!( + proxy_req.headers().get("X-Forwarded-For").is_none(), + "should omit X-Forwarded-For when client_ip is None" + ); + } + + #[test] + fn didomi_proxy_uses_platform_http_client() { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"ok".to_vec()); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let settings = create_test_settings(); + let integration = DidomiIntegration::new(Arc::new(config(true))); + let req = http::Request::builder() + .method(http::Method::GET) + .uri("https://publisher.example/integrations/didomi/consent/api/events") + .body(EdgeBody::empty()) + .expect("should build request"); + + let response = futures::executor::block_on(integration.handle(&settings, &services, req)) + .expect("should proxy request"); + + assert_eq!( + response.status(), + http::StatusCode::OK, + "should return stubbed response" + ); + assert_eq!( + stub.recorded_backend_names(), + vec!["stub-backend".to_string()], + "should route outbound request through PlatformHttpClient" + ); + } } 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 e3acf1ea..ff20d68c 100644 --- a/crates/trusted-server-core/src/integrations/google_tag_manager.rs +++ b/crates/trusted-server-core/src/integrations/google_tag_manager.rs @@ -15,19 +15,20 @@ use std::sync::{Arc, LazyLock, Mutex}; use async_trait::async_trait; +use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; -use fastly::http::{Method, StatusCode}; -use fastly::{Request, Response}; +use futures::StreamExt as _; +use http::{header, Method, Request, Response, StatusCode}; use regex::Regex; use serde::{Deserialize, Serialize}; use validator::Validate; -use crate::compat; use crate::error::TrustedServerError; use crate::integrations::{ - AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, - IntegrationEndpoint, IntegrationProxy, IntegrationRegistration, IntegrationScriptContext, - IntegrationScriptRewriter, ScriptRewriteAction, + collect_response_bounded, AttributeRewriteAction, IntegrationAttributeContext, + IntegrationAttributeRewriter, IntegrationEndpoint, IntegrationProxy, IntegrationRegistration, + IntegrationScriptContext, IntegrationScriptRewriter, ScriptRewriteAction, + UPSTREAM_SDK_MAX_RESPONSE_BYTES, }; use crate::platform::RuntimeServices; use crate::proxy::{proxy_request, ProxyRequestConfig}; @@ -39,7 +40,12 @@ const DEFAULT_UPSTREAM: &str = "https://www.googletagmanager.com"; /// Error type for payload size validation #[derive(Debug)] enum PayloadSizeError { - TooLarge { actual: usize, max: usize }, + TooLarge { + actual: usize, + max: usize, + }, + /// Transport error while reading a streaming body chunk. + StreamRead(String), } /// Regex pattern for validating GTM container IDs. @@ -279,7 +285,7 @@ impl GoogleTagManagerIntegration { path.ends_with("/gtm.js") || path.ends_with("/gtag/js") || path.ends_with("/gtag.js") } - fn build_target_url(&self, req: &Request, path: &str) -> Option { + fn build_target_url(&self, req: &Request, path: &str) -> Option { let upstream_base = self.upstream_url(); let mut target_url = if path.ends_with("/gtm.js") { @@ -294,7 +300,7 @@ impl GoogleTagManagerIntegration { return None; }; - if let Some(query) = req.get_url().query() { + if let Some(query) = req.uri().query() { target_url = format!("{}?{}", target_url, query); } else if path.ends_with("/gtm.js") { target_url = format!("{}?id={}", target_url, self.config.container_id); @@ -303,10 +309,10 @@ impl GoogleTagManagerIntegration { Some(target_url) } - fn build_proxy_config<'a>( + async fn build_proxy_config<'a>( &self, path: &str, - req: &mut Request, + req: &mut Request, target_url: &'a str, ) -> Result, PayloadSizeError> { let mut proxy_config = ProxyRequestConfig::new(target_url); @@ -314,42 +320,11 @@ impl GoogleTagManagerIntegration { // If it's a POST request (e.g. /collect beacon), we must manually attach the body // because ProxyRequestConfig doesn't automatically copy it from the source request. - if req.get_method() == Method::POST { + if req.method() == Method::POST { // Read body with size cap to prevent unbounded memory allocation. - // Read in chunks and reject early if body exceeds max_beacon_body_size. - let mut body = req.take_body(); - let mut body_bytes = Vec::new(); - let max_size = self.config.max_beacon_body_size; - const CHUNK_SIZE: usize = 8192; // 8KB chunks - - for chunk_result in body.read_chunks(CHUNK_SIZE) { - let chunk = chunk_result.map_err(|e| { - log::error!("Error reading request body: {}", e); - // Convert I/O error to size error for uniform handling - PayloadSizeError::TooLarge { - actual: 0, - max: max_size, - } - })?; - - // Check if adding this chunk would exceed the limit - // This prevents buffering oversized bodies into memory - if body_bytes.len() + chunk.len() > max_size { - let total_size = body_bytes.len() + chunk.len(); - log::warn!( - "POST body size {} exceeds max {} (rejected during chunked read)", - total_size, - max_size - ); - return Err(PayloadSizeError::TooLarge { - actual: total_size, - max: max_size, - }); - } - - body_bytes.extend_from_slice(&chunk); - } - + let body = std::mem::replace(req.body_mut(), EdgeBody::empty()); + let body_bytes = + Self::collect_request_body_bounded(body, self.config.max_beacon_body_size).await?; proxy_config.body = Some(body_bytes); } @@ -357,18 +332,65 @@ impl GoogleTagManagerIntegration { // The empty value will override any existing header during proxy forwarding. proxy_config = proxy_config.with_header( crate::constants::HEADER_X_FORWARDED_FOR, - fastly::http::HeaderValue::from_static(""), + http::HeaderValue::from_static(""), ); if self.is_rewritable_script(path) { proxy_config = proxy_config.with_header( - fastly::http::header::ACCEPT_ENCODING, - fastly::http::HeaderValue::from_static("identity"), + header::ACCEPT_ENCODING, + http::HeaderValue::from_static("identity"), ); } Ok(proxy_config) } + + async fn collect_request_body_bounded( + body: EdgeBody, + max_size: usize, + ) -> Result, PayloadSizeError> { + match body { + EdgeBody::Once(bytes) => { + if bytes.len() > max_size { + log::warn!( + "POST body size {} exceeds max {} (rejected before proxy)", + bytes.len(), + max_size + ); + return Err(PayloadSizeError::TooLarge { + actual: bytes.len(), + max: max_size, + }); + } + Ok(bytes.to_vec()) + } + EdgeBody::Stream(mut stream) => { + let mut body_bytes = Vec::new(); + while let Some(chunk_result) = stream.next().await { + let chunk = chunk_result.map_err(|error| { + log::error!("Error reading request body stream: {}", error); + PayloadSizeError::StreamRead(error.to_string()) + })?; + + if body_bytes.len() + chunk.len() > max_size { + let total_size = body_bytes.len() + chunk.len(); + log::warn!( + "POST body size {} exceeds max {} (rejected during stream read)", + total_size, + max_size + ); + return Err(PayloadSizeError::TooLarge { + actual: total_size, + max: max_size, + }); + } + + body_bytes.extend_from_slice(&chunk); + } + Ok(body_bytes) + } + } + } } fn build( @@ -430,17 +452,20 @@ impl IntegrationProxy for GoogleTagManagerIntegration { &self, settings: &Settings, services: &RuntimeServices, - mut req: Request, - ) -> Result> { - let path = req.get_path().to_string(); - let method = req.get_method(); + req: http::Request, + ) -> Result, Report> { + let mut req = req; + let path = req.uri().path().to_string(); + let method = req.method().clone(); log::debug!("Handling GTM request: {} {}", method, path); // Validate body size for POST requests to prevent memory pressure // Check Content-Length header if present for early rejection if method == Method::POST { - if let Some(content_length_str) = - req.get_header_str(fastly::http::header::CONTENT_LENGTH) + if let Some(content_length_str) = req + .headers() + .get(header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) { match content_length_str.parse::() { Ok(content_length) => { @@ -451,13 +476,23 @@ impl IntegrationProxy for GoogleTagManagerIntegration { content_length, self.config.max_beacon_body_size ); - return Ok(Response::from_status(StatusCode::PAYLOAD_TOO_LARGE)); + return Response::builder() + .status(StatusCode::PAYLOAD_TOO_LARGE) + .body(EdgeBody::empty()) + .change_context(Self::error( + "Failed to build GTM payload-too-large response", + )); } } Err(_) => { // Invalid Content-Length header log::warn!("POST request with malformed Content-Length header"); - return Ok(Response::from_status(StatusCode::BAD_REQUEST)); + return Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(EdgeBody::empty()) + .change_context(Self::error( + "Failed to build GTM bad-request response", + )); } } } @@ -466,13 +501,16 @@ impl IntegrationProxy for GoogleTagManagerIntegration { } let Some(target_url) = self.build_target_url(&req, &path) else { - return Ok(Response::from_status(StatusCode::NOT_FOUND)); + return Response::builder() + .status(StatusCode::NOT_FOUND) + .body(EdgeBody::empty()) + .change_context(Self::error("Failed to build GTM not-found response")); }; log::debug!("Proxying to upstream: {}", target_url); // Handle payload size errors explicitly to return 413 instead of 502 - let proxy_config = match self.build_proxy_config(&path, &mut req, &target_url) { + let proxy_config = match self.build_proxy_config(&path, &mut req, &target_url).await { Ok(config) => config, Err(PayloadSizeError::TooLarge { actual, max }) => { // This catches cases where Content-Length was incorrect @@ -481,40 +519,63 @@ impl IntegrationProxy for GoogleTagManagerIntegration { actual, max ); - return Ok(Response::from_status(StatusCode::PAYLOAD_TOO_LARGE)); + return Response::builder() + .status(StatusCode::PAYLOAD_TOO_LARGE) + .body(EdgeBody::empty()) + .change_context(Self::error( + "Failed to build GTM payload-too-large response", + )); + } + Err(PayloadSizeError::StreamRead(error)) => { + log::error!("Returning 502: failed to read GTM request body stream: {error}"); + return Response::builder() + .status(StatusCode::BAD_GATEWAY) + .body(EdgeBody::empty()) + .change_context(Self::error("Failed to build GTM bad-gateway response")); } }; - let mut response = compat::to_fastly_response( - proxy_request( - settings, - compat::from_fastly_request(req), - proxy_config, - services, - ) + let response = proxy_request(settings, req, proxy_config, services) .await - .change_context(Self::error("Failed to proxy GTM request"))?, - ); + .change_context(Self::error("Failed to proxy GTM request"))?; // If we are serving gtm.js or gtag.js, rewrite internal URLs to route beacons through us. if self.is_rewritable_script(&path) { - if !response.get_status().is_success() { - log::warn!("GTM upstream returned status {}", response.get_status()); + if !response.status().is_success() { + log::warn!("GTM upstream returned status {}", response.status()); return Ok(response); } log::debug!("Rewriting GTM/gtag script content"); - let body_str = response.take_body_str(); - let rewritten_body = Self::rewrite_gtm_urls(&body_str); + let status = response.status(); + let body_bytes = collect_response_bounded( + response.into_body(), + UPSTREAM_SDK_MAX_RESPONSE_BYTES, + GTM_INTEGRATION_ID, + ) + .await?; + let (rewritten_body_bytes, content_type) = match std::str::from_utf8(&body_bytes) { + Ok(body_str) => { + let rewritten = Self::rewrite_gtm_urls(body_str); + ( + rewritten.into_bytes(), + "application/javascript; charset=utf-8", + ) + } + Err(_) => { + log::warn!("GTM upstream response body is not valid UTF-8; serving original"); + (body_bytes.to_vec(), "application/javascript") + } + }; - response = Response::from_body(rewritten_body) - .with_header( - fastly::http::header::CONTENT_TYPE, - "application/javascript; charset=utf-8", - ) - .with_header( - fastly::http::header::CACHE_CONTROL, + return Response::builder() + .status(status) + .header(header::CONTENT_TYPE, content_type) + .header( + header::CACHE_CONTROL, format!("public, max-age={}", self.config.cache_max_age), - ); + ) + .body(EdgeBody::from(rewritten_body_bytes)) + .change_context(Self::error("Failed to build rewritten GTM response")); } Ok(response) @@ -625,9 +686,17 @@ mod tests { use crate::streaming_processor::{Compression, PipelineConfig, StreamingPipeline}; use crate::test_support::tests::crate_test_settings_str; - use fastly::http::Method; + use http::Method; use std::io::Cursor; + fn build_http_request(method: Method, uri: &str, body: EdgeBody) -> http::Request { + http::Request::builder() + .method(method) + .uri(uri) + .body(body) + .expect("should build HTTP request") + } + #[test] fn test_rewrite_gtm_urls() { // All URL patterns should be rewritten via the shared regex @@ -1028,8 +1097,8 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= .any(|r| r.path == "/integrations/google_tag_manager/g/collect")); } - #[test] - fn test_post_collect_proxy_config_includes_payload() { + #[tokio::test] + async fn test_post_collect_proxy_config_includes_payload() { let config = GoogleTagManagerConfig { enabled: true, container_id: "GTM-TEST1234".to_string(), @@ -1040,18 +1109,19 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= let integration = GoogleTagManagerIntegration::new(config); let payload = b"v=2&tid=G-TEST&cid=123&en=page_view".to_vec(); - let mut req = Request::new( + 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()), ); - req.set_body(payload.clone()); - let path = req.get_path().to_string(); + 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!( @@ -1061,8 +1131,8 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= ); } - #[test] - fn test_oversized_post_body_rejected() { + #[tokio::test] + async fn test_oversized_post_body_rejected() { let max_size = default_max_beacon_body_size(); let config = GoogleTagManagerConfig { enabled: true, @@ -1075,19 +1145,21 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= // Create a payload larger than the configured max size (64KB by default) let oversized_payload = vec![b'X'; max_size + 1]; - let mut req = Request::new( + let mut req = build_http_request( Method::POST, "https://edge.example.com/integrations/google_tag_manager/collect", + EdgeBody::from(oversized_payload.clone()), ); - req.set_body(oversized_payload.clone()); - let path = req.get_path().to_string(); + 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); + let result = integration + .build_proxy_config(&path, &mut req, &target_url) + .await; assert!(result.is_err(), "Oversized POST body should be rejected"); @@ -1099,8 +1171,8 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= } } - #[test] - fn test_custom_max_beacon_body_size() { + #[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 { @@ -1114,41 +1186,45 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= // Payload just under the custom limit should succeed let acceptable_payload = vec![b'X'; custom_max_size - 1]; - let mut req1 = Request::new( + let mut req1 = build_http_request( Method::POST, "https://edge.example.com/integrations/google_tag_manager/collect", + EdgeBody::from(acceptable_payload.clone()), ); - req1.set_body(acceptable_payload.clone()); - let path = req1.get_path().to_string(); + 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); + 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 = Request::new( + let mut req2 = build_http_request( Method::POST, "https://edge.example.com/integrations/google_tag_manager/collect", + EdgeBody::from(oversized_payload), ); - req2.set_body(oversized_payload); 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); + let result2 = integration + .build_proxy_config(&path, &mut req2, &target_url2) + .await; assert!( result2.is_err(), "Payload over custom limit should be rejected" ); } - #[test] - fn test_incorrect_content_length_returns_413() { + #[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(); @@ -1163,24 +1239,27 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= // Create oversized payload but with incorrect (small) Content-Length let oversized_payload = vec![b'X'; max_size + 1]; - let mut req = Request::new( + let mut req = build_http_request( Method::POST, "https://edge.example.com/integrations/google_tag_manager/collect", + EdgeBody::from(oversized_payload.clone()), ); - req.set_body(oversized_payload.clone()); // Set Content-Length to a small value (incorrect) - req.set_header( - fastly::http::header::CONTENT_LENGTH, - (max_size / 2).to_string(), + 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.get_path().to_string(); + 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); + let result = integration + .build_proxy_config(&path, &mut req, &target_url) + .await; assert!( result.is_err(), @@ -1211,14 +1290,15 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= // Create oversized payload with correct Content-Length let oversized_payload = vec![b'X'; max_size + 1]; - let mut req = Request::new( - Method::POST, - "https://edge.example.com/integrations/google_tag_manager/collect", - ); - req.set_body(oversized_payload.clone()); - req.set_header( - fastly::http::header::CONTENT_LENGTH, - oversized_payload.len().to_string(), + 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(); @@ -1230,7 +1310,7 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= // Verify we get 413 Payload Too Large, not 502 Bad Gateway assert_eq!( - response.get_status(), + response.status(), StatusCode::PAYLOAD_TOO_LARGE, "Should return 413 for oversized POST body" ); @@ -1250,12 +1330,15 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= // Create POST request with invalid Content-Length header let payload = b"v=2&tid=G-TEST&cid=123".to_vec(); - let mut req = Request::new( - Method::POST, - "https://edge.example.com/integrations/google_tag_manager/collect", + 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"), ); - req.set_body(payload); - req.set_header(fastly::http::header::CONTENT_LENGTH, "not-a-number"); let settings = make_settings(); let services = crate::platform::test_support::noop_services(); @@ -1266,7 +1349,7 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= // Verify we get 400 Bad Request for malformed Content-Length assert_eq!( - response.get_status(), + response.status(), StatusCode::BAD_REQUEST, "Should return 400 for malformed Content-Length" ); @@ -1288,20 +1371,22 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= // Create small POST request without Content-Length header let small_payload = b"v=2&tid=G-TEST&cid=123".to_vec(); - let mut req = Request::new( + let mut req = build_http_request( Method::POST, "https://edge.example.com/integrations/google_tag_manager/collect", + EdgeBody::from(small_payload), ); - req.set_body(small_payload); // Intentionally NOT setting Content-Length header (HTTP/2 scenario) - let path = req.get_path().to_string(); + 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); + let result = integration + .build_proxy_config(&path, &mut req, &target_url) + .await; assert!( result.is_ok(), @@ -1309,8 +1394,8 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= ); } - #[test] - fn test_collect_proxy_config_strips_client_ip_forwarding() { + #[tokio::test] + async fn test_collect_proxy_config_strips_client_ip_forwarding() { let config = GoogleTagManagerConfig { enabled: true, container_id: "GTM-TEST1234".to_string(), @@ -1320,18 +1405,23 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= }; let integration = GoogleTagManagerIntegration::new(config); - let mut req = Request::new( + 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"), ); - req.set_header(crate::constants::HEADER_X_FORWARDED_FOR, "198.51.100.42"); - let path = req.get_path().to_string(); + 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, @@ -1348,8 +1438,8 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= ); } - #[test] - fn test_gtag_proxy_config_requests_identity_encoding() { + #[tokio::test] + async fn test_gtag_proxy_config_requests_identity_encoding() { let config = GoogleTagManagerConfig { enabled: true, container_id: "GT-123".to_string(), @@ -1359,22 +1449,25 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= }; let integration = GoogleTagManagerIntegration::new(config); - let mut req = Request::new( + 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.get_path().to_string(); + 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 == fastly::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, diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index d86d98d6..dab62d4d 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -35,14 +35,13 @@ use std::sync::Arc; use async_trait::async_trait; +use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; -use fastly::http::header; -use fastly::{Request, Response}; +use http::{header, Request, Response}; use serde::{Deserialize, Serialize}; use url::Url; use validator::Validate; -use crate::compat; use crate::constants::{HEADER_ACCEPT, HEADER_ACCEPT_ENCODING, HEADER_ACCEPT_LANGUAGE}; use crate::error::TrustedServerError; use crate::integrations::{ @@ -126,7 +125,10 @@ impl GptIntegration { )) } - fn build_proxy_config<'a>(target_url: &'a str, req: &Request) -> ProxyRequestConfig<'a> { + fn build_proxy_config<'a>( + target_url: &'a str, + req: &Request, + ) -> ProxyRequestConfig<'a> { let mut config = ProxyRequestConfig::new(target_url) .with_streaming() .without_forward_headers(); @@ -138,33 +140,33 @@ impl GptIntegration { fn apply_request_header_allowlist<'a>( mut config: ProxyRequestConfig<'a>, - req: &Request, + req: &Request, ) -> ProxyRequestConfig<'a> { for header_name in [ &HEADER_ACCEPT, &HEADER_ACCEPT_LANGUAGE, &HEADER_ACCEPT_ENCODING, ] { - if let Some(value) = req.get_header(header_name).cloned() { + if let Some(value) = req.headers().get(header_name).cloned() { config = config.with_header(header_name.clone(), value); } } config.with_header( header::USER_AGENT, - fastly::http::HeaderValue::from_static("TrustedServer/1.0"), + http::HeaderValue::from_static("TrustedServer/1.0"), ) } fn ensure_successful_gpt_asset_response( - response: &Response, + response: &Response, context: &str, ) -> Result<(), Report> { - if response.get_status().is_success() { + if response.status().is_success() { return Ok(()); } - let status = response.get_status(); + let status = response.status(); log::error!( "GPT proxy upstream returned status {} for {}", status, @@ -175,45 +177,62 @@ impl GptIntegration { )))) } - fn finalize_gpt_asset_response(&self, mut response: Response) -> Response { - let status = response.get_status(); - let content_type = response.get_header(header::CONTENT_TYPE).cloned(); - let content_encoding = response.get_header(header::CONTENT_ENCODING).cloned(); - let etag = response.get_header(header::ETAG).cloned(); - let last_modified = response.get_header(header::LAST_MODIFIED).cloned(); - let upstream_vary = response - .get_header(header::VARY) + fn finalize_gpt_asset_response(&self, response: Response) -> Response { + let (parts, body) = response.into_parts(); + let status = parts.status; + let content_type = parts.headers.get(header::CONTENT_TYPE).cloned(); + let content_encoding = parts.headers.get(header::CONTENT_ENCODING).cloned(); + let etag = parts.headers.get(header::ETAG).cloned(); + let last_modified = parts.headers.get(header::LAST_MODIFIED).cloned(); + let upstream_vary = parts + .headers + .get(header::VARY) .and_then(|value| value.to_str().ok()) .map(str::to_owned); - let body = response.take_body(); - let mut finalized = Response::from_status(status).with_body(body); - finalized.set_header("X-GPT-Proxy", "true"); + let mut finalized = Response::new(body); + *finalized.status_mut() = status; + finalized + .headers_mut() + .insert("X-GPT-Proxy", http::HeaderValue::from_static("true")); if let Some(content_type) = content_type { - finalized.set_header(header::CONTENT_TYPE, content_type); + finalized + .headers_mut() + .insert(header::CONTENT_TYPE, content_type); } if let Some(etag) = etag { - finalized.set_header(header::ETAG, etag); + finalized.headers_mut().insert(header::ETAG, etag); } if let Some(last_modified) = last_modified { - finalized.set_header(header::LAST_MODIFIED, last_modified); + finalized + .headers_mut() + .insert(header::LAST_MODIFIED, last_modified); } if let Some(content_encoding) = content_encoding { - finalized.set_header(header::CONTENT_ENCODING, content_encoding); - finalized.set_header( + finalized + .headers_mut() + .insert(header::CONTENT_ENCODING, content_encoding); + finalized.headers_mut().insert( header::VARY, - Self::vary_with_accept_encoding(upstream_vary.as_deref()), + http::HeaderValue::from_str(&Self::vary_with_accept_encoding( + upstream_vary.as_deref(), + )) + .expect("should build GPT Vary header"), ); } if status.is_success() { - finalized.set_header( + finalized.headers_mut().insert( header::CACHE_CONTROL, - format!("public, max-age={}", self.config.cache_ttl_seconds), + http::HeaderValue::from_str(&format!( + "public, max-age={}", + self.config.cache_ttl_seconds + )) + .expect("should build GPT Cache-Control header"), ); } @@ -241,16 +260,14 @@ impl GptIntegration { &self, settings: &Settings, services: &RuntimeServices, - req: Request, + req: Request, target_url: &str, context: &str, - ) -> Result> { + ) -> Result, Report> { let config = Self::build_proxy_config(target_url, &req); - let response = compat::to_fastly_response( - proxy_request(settings, compat::from_fastly_request(req), config, services) - .await - .change_context(Self::error(context))?, - ); + let response = proxy_request(settings, req, config, services) + .await + .change_context(Self::error(context))?; Self::ensure_successful_gpt_asset_response(&response, context)?; Ok(self.finalize_gpt_asset_response(response)) @@ -293,8 +310,8 @@ impl GptIntegration { &self, settings: &Settings, services: &RuntimeServices, - req: Request, - ) -> Result> { + req: Request, + ) -> Result, Report> { let script_url = &self.config.script_url; log::info!("Fetching GPT script from: {}", script_url); self.proxy_gpt_asset( @@ -317,12 +334,12 @@ impl GptIntegration { &self, settings: &Settings, services: &RuntimeServices, - req: Request, - ) -> Result> { - let original_path = req.get_path(); - let query = req.get_url().query(); + req: Request, + ) -> Result, Report> { + let original_path = req.uri().path().to_string(); + let query = req.uri().query(); - let target_url = Self::build_upstream_url(original_path, query) + let target_url = Self::build_upstream_url(&original_path, query) .ok_or_else(|| Self::error(format!("Invalid GPT pagead path: {}", original_path)))?; log::info!("GPT proxy: forwarding to {}", target_url); @@ -386,9 +403,9 @@ impl IntegrationProxy for GptIntegration { &self, settings: &Settings, services: &RuntimeServices, - req: Request, - ) -> Result> { - let path = req.get_path(); + req: http::Request, + ) -> Result, Report> { + let path = req.uri().path().to_string(); if path == "/integrations/gpt/script" { self.handle_script_serving(settings, services, req).await @@ -476,7 +493,7 @@ mod tests { use crate::constants::HEADER_X_FORWARDED_FOR; use crate::integrations::IntegrationDocumentState; use crate::test_support::tests::create_test_settings; - use fastly::http::Method; + use http::Method; fn test_config() -> GptConfig { GptConfig { @@ -496,6 +513,14 @@ mod tests { } } + fn build_http_request(method: Method, uri: &str) -> http::Request { + http::Request::builder() + .method(method) + .uri(uri) + .body(EdgeBody::empty()) + .expect("should build HTTP request") + } + // -- URL detection -- #[test] @@ -636,7 +661,7 @@ mod tests { #[test] fn build_proxy_config_uses_streaming_without_ec_forwarding_or_redirects() { - let req = Request::new( + let req = build_http_request( Method::GET, "https://edge.example.com/integrations/gpt/script", ); @@ -661,13 +686,22 @@ mod tests { #[test] fn build_proxy_config_forwards_only_required_headers() { - let mut req = Request::new( + let mut req = build_http_request( Method::GET, "https://edge.example.com/integrations/gpt/script", ); - req.set_header(HEADER_ACCEPT, "application/javascript"); - req.set_header(HEADER_ACCEPT_LANGUAGE, "en-US,en;q=0.9"); - req.set_header(HEADER_ACCEPT_ENCODING, "gzip"); + req.headers_mut().insert( + HEADER_ACCEPT, + http::HeaderValue::from_static("application/javascript"), + ); + req.headers_mut().insert( + HEADER_ACCEPT_LANGUAGE, + http::HeaderValue::from_static("en-US,en;q=0.9"), + ); + req.headers_mut().insert( + HEADER_ACCEPT_ENCODING, + http::HeaderValue::from_static("gzip"), + ); let config = GptIntegration::build_proxy_config( "https://securepubads.g.doubleclick.net/tag/js/gpt.js", @@ -737,7 +771,7 @@ mod tests { #[test] fn build_proxy_config_does_not_advertise_accept_encoding_when_client_omits_it() { - let req = Request::new( + let req = build_http_request( Method::GET, "https://edge.example.com/integrations/gpt/script", ); @@ -761,67 +795,94 @@ mod tests { #[test] fn finalize_gpt_asset_response_rebuilds_successful_responses_with_safe_headers() { let integration = GptIntegration::new(test_config()); - let response = Response::from_status(fastly::http::StatusCode::OK) - .with_header( + let response = http::Response::builder() + .status(http::StatusCode::OK) + .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .with_header(header::ETAG, "\"gpt-etag\"") - .with_header(header::LAST_MODIFIED, "Thu, 13 Mar 2025 08:00:00 GMT") - .with_header(header::CONTENT_ENCODING, "br") - .with_header(header::VARY, "Origin") - .with_header(header::SET_COOKIE, "gpt=1; Secure"); + .header(header::ETAG, "\"gpt-etag\"") + .header(header::LAST_MODIFIED, "Thu, 13 Mar 2025 08:00:00 GMT") + .header(header::CONTENT_ENCODING, "br") + .header(header::VARY, "Origin") + .header(header::SET_COOKIE, "gpt=1; Secure") + .body(EdgeBody::empty()) + .expect("should build GPT response"); let response = integration.finalize_gpt_asset_response(response); assert_eq!( - response.get_status(), - fastly::http::StatusCode::OK, + response.status(), + http::StatusCode::OK, "should preserve successful upstream statuses" ); assert_eq!( - response.get_header_str("X-GPT-Proxy"), + response + .headers() + .get("X-GPT-Proxy") + .and_then(|value| value.to_str().ok()), Some("true"), "should tag proxied GPT responses" ); assert_eq!( - response.get_header_str(header::CONTENT_TYPE), + response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), Some("application/javascript; charset=utf-8"), "should preserve upstream content type for GPT assets" ); assert_eq!( - response.get_header_str(header::ETAG), + response + .headers() + .get(header::ETAG) + .and_then(|value| value.to_str().ok()), Some("\"gpt-etag\""), "should preserve upstream ETag validators for GPT assets" ); assert_eq!( - response.get_header_str(header::LAST_MODIFIED), + response + .headers() + .get(header::LAST_MODIFIED) + .and_then(|value| value.to_str().ok()), Some("Thu, 13 Mar 2025 08:00:00 GMT"), "should preserve upstream Last-Modified validators for GPT assets" ); assert_eq!( - response.get_header_str(header::CONTENT_ENCODING), + response + .headers() + .get(header::CONTENT_ENCODING) + .and_then(|value| value.to_str().ok()), Some("br"), "should preserve upstream content encoding for GPT assets" ); assert_eq!( - response.get_header_str(header::VARY), + response + .headers() + .get(header::VARY) + .and_then(|value| value.to_str().ok()), Some("Origin, Accept-Encoding"), "should normalize Vary when returning encoded GPT assets" ); assert_eq!( - response.get_header_str(header::CACHE_CONTROL), + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), Some("public, max-age=3600"), "should add cache headers for successful GPT asset responses" ); assert!( - response.get_header(header::SET_COOKIE).is_none(), + response.headers().get(header::SET_COOKIE).is_none(), "should not project unrelated upstream headers to first-party clients" ); } #[test] fn ensure_successful_gpt_asset_response_rejects_non_success_statuses() { - let response = Response::from_status(fastly::http::StatusCode::SERVICE_UNAVAILABLE); + let response = http::Response::builder() + .status(http::StatusCode::SERVICE_UNAVAILABLE) + .body(EdgeBody::empty()) + .expect("should build service unavailable response"); let err = GptIntegration::ensure_successful_gpt_asset_response( &response, "Failed to fetch GPT script from https://securepubads.g.doubleclick.net/tag/js/gpt.js", diff --git a/crates/trusted-server-core/src/integrations/lockr.rs b/crates/trusted-server-core/src/integrations/lockr.rs index 8e63345f..755e3313 100644 --- a/crates/trusted-server-core/src/integrations/lockr.rs +++ b/crates/trusted-server-core/src/integrations/lockr.rs @@ -10,20 +10,23 @@ use std::sync::Arc; use async_trait::async_trait; +use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; -use fastly::http::{header, Method, StatusCode}; -use fastly::{Request, Response}; +use http::header::{self, HeaderMap, HeaderValue}; +use http::{Method, StatusCode}; use serde::Deserialize; use validator::Validate; -use crate::backend::BackendConfig; -use crate::compat; +use crate::constants::INTERNAL_HEADERS; +use crate::cookies::{strip_cookies, CONSENT_COOKIE_NAMES}; use crate::error::TrustedServerError; use crate::integrations::{ + collect_body_bounded, collect_response_bounded, ensure_integration_backend, AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, - IntegrationEndpoint, IntegrationProxy, IntegrationRegistration, + IntegrationEndpoint, IntegrationProxy, IntegrationRegistration, INTEGRATION_MAX_BODY_BYTES, + UPSTREAM_SDK_MAX_RESPONSE_BYTES, }; -use crate::platform::RuntimeServices; +use crate::platform::{PlatformHttpRequest, RuntimeServices}; use crate::settings::{IntegrationConfig, Settings}; const LOCKR_INTEGRATION_ID: &str = "lockr"; @@ -106,67 +109,83 @@ impl LockrIntegration { async fn handle_sdk_serving( &self, _settings: &Settings, - _req: Request, - ) -> Result> { + services: &RuntimeServices, + ) -> Result, Report> { let sdk_url = &self.config.sdk_url; log::info!("Fetching Lockr SDK from {}", sdk_url); // TODO: Check KV store cache first (future enhancement) - let mut lockr_req = Request::new(Method::GET, sdk_url); - lockr_req.set_header(header::USER_AGENT, "TrustedServer/1.0"); - lockr_req.set_header(header::ACCEPT, "application/javascript, */*"); + let lockr_req = http::Request::builder() + .method(Method::GET) + .uri(sdk_url) + .header(header::USER_AGENT, "TrustedServer/1.0") + .header(header::ACCEPT, "application/javascript, */*") + .body(EdgeBody::empty()) + .change_context(Self::error("Failed to build Lockr SDK request"))?; - let backend_name = BackendConfig::from_url(sdk_url, true) + let backend_name = Self::backend_name_for_url(services, sdk_url) .change_context(Self::error("Failed to determine backend for SDK fetch"))?; - let mut lockr_response = - lockr_req - .send(backend_name) - .change_context(Self::error(format!( - "Failed to fetch Lockr SDK from {}", - sdk_url - )))?; - - if !lockr_response.get_status().is_success() { + let lockr_response = services + .http_client() + .send(PlatformHttpRequest::new(lockr_req, backend_name)) + .await + .change_context(Self::error(format!( + "Failed to fetch Lockr SDK from {}", + sdk_url + )))? + .response; + + if !lockr_response.status().is_success() { log::error!( "Lockr SDK fetch failed with status {}", - lockr_response.get_status() + lockr_response.status() ); return Err(Report::new(Self::error(format!( "Lockr SDK returned error status: {}", - lockr_response.get_status() + lockr_response.status() )))); } - let sdk_body = lockr_response.take_body_bytes(); + let sdk_body = collect_response_bounded( + lockr_response.into_body(), + UPSTREAM_SDK_MAX_RESPONSE_BYTES, + LOCKR_INTEGRATION_ID, + ) + .await + .change_context(Self::error("Failed to read Lockr SDK response body"))?; log::info!("Fetched Lockr SDK ({} bytes)", sdk_body.len()); // TODO: Cache in KV store (future enhancement) - Ok(Response::from_status(StatusCode::OK) - .with_header( + http::Response::builder() + .status(StatusCode::OK) + .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .with_header( + .header( header::CACHE_CONTROL, format!("public, max-age={}", self.config.cache_ttl_seconds), ) - .with_header("X-Lockr-SDK-Proxy", "true") - .with_header("X-Lockr-SDK-Mode", "trust-server") - .with_header("X-SDK-Source", sdk_url) - .with_body(sdk_body)) + .header("X-Lockr-SDK-Proxy", "true") + .header("X-Lockr-SDK-Mode", "trust-server") + .header("X-SDK-Source", sdk_url) + .body(EdgeBody::from(sdk_body)) + .change_context(Self::error("Failed to build Lockr SDK response")) } /// Handle API proxy — forward requests to the configured Lockr API endpoint. async fn handle_api_proxy( &self, _settings: &Settings, - mut req: Request, - ) -> Result> { - let original_path = req.get_path(); - let method = req.get_method(); + services: &RuntimeServices, + req: http::Request, + ) -> Result, Report> { + let (parts, body) = req.into_parts(); + let original_path = parts.uri.path().to_string(); + let method = parts.method.clone(); log::info!("Proxying Lockr API request: {} {}", method, original_path); @@ -176,8 +195,8 @@ impl LockrIntegration { .strip_prefix("/integrations/lockr/api") .ok_or_else(|| Self::error(format!("Invalid Lockr API path: {}", original_path)))?; - let query = req - .get_url() + let query = parts + .uri .query() .map(|q| format!("?{}", q)) .unwrap_or_default(); @@ -185,30 +204,36 @@ impl LockrIntegration { log::info!("Forwarding to Lockr API: {}", target_url); - let mut target_req = Request::new(method.clone(), &target_url); - self.copy_request_headers(&req, &mut target_req); + let request_body = if method == Method::POST { + let bytes = + collect_body_bounded(body, INTEGRATION_MAX_BODY_BYTES, LOCKR_INTEGRATION_ID) + .await?; + EdgeBody::from(bytes) + } else { + EdgeBody::empty() + }; - if matches!(method, &Method::POST | &Method::PUT | &Method::PATCH) { - let body = req.take_body(); - target_req.set_body(body); - } + let mut target_req = http::Request::builder() + .method(method.clone()) + .uri(&target_url) + .body(request_body) + .change_context(Self::error("Failed to build Lockr API proxy request"))?; + self.copy_request_headers(&parts.headers, target_req.headers_mut())?; - let backend_name = BackendConfig::from_url(&self.config.api_endpoint, true) + let backend_name = Self::backend_name_for_url(services, &self.config.api_endpoint) .change_context(Self::error("Failed to determine backend for API proxy"))?; - let response = match target_req.send(backend_name) { - Ok(res) => res, - Err(e) => { - return Err(Self::error(format!( - "failed to forward request to {}, {}", - target_url, - e.root_cause() - )) - .into()); - } - }; + let response = services + .http_client() + .send(PlatformHttpRequest::new(target_req, backend_name)) + .await + .change_context(Self::error(format!( + "Failed to forward request to {}", + target_url + )))? + .response; - log::info!("Lockr API responded with status {}", response.get_status()); + log::info!("Lockr API responded with status {}", response.status()); Ok(response) } @@ -218,7 +243,11 @@ impl LockrIntegration { /// Consent cookies are always stripped — consent signals are forwarded /// through the `OpenRTB` body by the Prebid integration, not through /// Lockr's cookie-based API calls. - fn copy_request_headers(&self, from: &Request, to: &mut Request) { + fn copy_request_headers( + &self, + from: &HeaderMap, + to: &mut HeaderMap, + ) -> Result<(), Report> { let headers_to_copy = [ header::CONTENT_TYPE, header::ACCEPT, @@ -229,25 +258,73 @@ impl LockrIntegration { ]; for header_name in &headers_to_copy { - if let Some(value) = from.get_header(header_name) { - to.set_header(header_name, value); + if let Some(value) = from.get(header_name) { + to.insert(header_name, value.clone()); } } // Always strip consent cookies — consent travels through the OpenRTB body - compat::forward_fastly_cookie_header(from, to, true); + self.copy_cookie_header(from, to)?; // Use origin override if configured, otherwise forward original - let origin = self - .config - .origin_override - .as_deref() - .or_else(|| from.get_header_str(header::ORIGIN)); + let origin = self.config.origin_override.as_deref().or_else(|| { + from.get(header::ORIGIN) + .and_then(|value| value.to_str().ok()) + }); if let Some(origin) = origin { - to.set_header(header::ORIGIN, origin); + match HeaderValue::from_str(origin) { + Ok(value) => { + to.insert(header::ORIGIN, value); + } + Err(error) => { + log::warn!("Skipping invalid Lockr origin header value '{origin}': {error}"); + } + } + } + + for (name, value) in from { + let name_str = name.as_str(); + if name_str.starts_with("x-") && !INTERNAL_HEADERS.contains(&name_str) { + to.append(name.clone(), value.clone()); + } } - compat::copy_fastly_custom_headers(from, to); + Ok(()) + } + + fn copy_cookie_header( + &self, + from: &HeaderMap, + to: &mut HeaderMap, + ) -> Result<(), Report> { + let Some(cookie_value) = from.get(header::COOKIE) else { + return Ok(()); + }; + + match cookie_value.to_str() { + Ok(value) => { + let stripped = strip_cookies(value, CONSENT_COOKIE_NAMES); + if stripped.is_empty() { + return Ok(()); + } + + let cookie_header = HeaderValue::from_str(&stripped) + .change_context(Self::error("Failed to rebuild stripped cookie header"))?; + to.insert(header::COOKIE, cookie_header); + } + Err(_) => { + to.insert(header::COOKIE, cookie_value.clone()); + } + } + + Ok(()) + } + + fn backend_name_for_url( + services: &RuntimeServices, + target_url: &str, + ) -> Result> { + ensure_integration_backend(services, target_url, LOCKR_INTEGRATION_ID, None) } } @@ -304,15 +381,15 @@ impl IntegrationProxy for LockrIntegration { async fn handle( &self, settings: &Settings, - _services: &RuntimeServices, - req: Request, - ) -> Result> { - let path = req.get_path(); + services: &RuntimeServices, + req: http::Request, + ) -> Result, Report> { + let path = req.uri().path().to_string(); if path == "/integrations/lockr/sdk" { - self.handle_sdk_serving(settings, req).await + self.handle_sdk_serving(settings, services).await } else if path.starts_with("/integrations/lockr/api/") { - self.handle_api_proxy(settings, req).await + self.handle_api_proxy(settings, services, req).await } else { Err(Report::new(Self::error(format!( "Unknown Lockr route: {}", @@ -376,9 +453,13 @@ fn default_rewrite_sdk() -> bool { #[cfg(test)] mod tests { + use std::sync::Arc; + use super::*; + use edgezero_core::http::Method as HttpMethod; use serde_json::json; + use crate::platform::test_support::{build_services_with_http_client, StubHttpClient}; use crate::test_support::tests::create_test_settings; fn test_config() -> LockrConfig { @@ -490,6 +571,36 @@ mod tests { ); } + #[test] + fn lockr_proxy_uses_platform_http_client() { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"ok".to_vec()); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let settings = create_test_settings(); + let integration = LockrIntegration::new(test_config()); + let req = http::Request::builder() + .method(HttpMethod::GET) + .uri("https://publisher.example/integrations/lockr/api/publisher/app/v1/identityLockr/settings") + .body(EdgeBody::empty()) + .expect("should build request"); + + let response = futures::executor::block_on(integration.handle(&settings, &services, req)) + .expect("should proxy request"); + + assert_eq!( + response.status(), + http::StatusCode::OK, + "should return stubbed response" + ); + assert_eq!( + stub.recorded_backend_names().len(), + 1, + "should route one outbound request through PlatformHttpClient" + ); + } + #[test] fn test_api_path_extraction_preserves_casing() { let test_cases = [ diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index e9438b32..1f9c79e7 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -1,8 +1,14 @@ //! Integration module registry and sample implementations. -use error_stack::Report; +use std::time::Duration; + +use edgezero_core::body::Body as EdgeBody; +use error_stack::{Report, ResultExt}; +use futures::StreamExt as _; +use url::Url; use crate::error::TrustedServerError; +use crate::platform::{PlatformBackendSpec, RuntimeServices}; use crate::settings::Settings; pub mod adserver_mock; @@ -28,6 +34,168 @@ pub use registry::{ ScriptRewriteAction, }; +/// Registers or retrieves a platform backend for the given URL. +/// +/// Parses `url`, builds a [`PlatformBackendSpec`] with TLS enabled and a +/// 15-second 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( + services: &RuntimeServices, + url: &str, + integration: &'static str, + first_byte_timeout: Option, +) -> 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: first_byte_timeout.unwrap_or_else(|| Duration::from_secs(15)), + }) + .change_context(TrustedServerError::Integration { + integration: integration.to_string(), + message: "Failed to register backend".to_string(), + }) +} + +/// Maximum body size accepted by integration proxy endpoints (256 KiB). +pub(crate) const INTEGRATION_MAX_BODY_BYTES: usize = 256 * 1024; + +/// Maximum response body size from RTB providers (prebid, aps, mediator). +pub(crate) const UPSTREAM_RTB_MAX_RESPONSE_BYTES: usize = 2 * 1024 * 1024; +/// Maximum response body size from SDK/proxy integrations. +pub(crate) const UPSTREAM_SDK_MAX_RESPONSE_BYTES: usize = 16 * 1024 * 1024; + +/// Drains an [`EdgeBody`] into a byte vector, rejecting bodies larger than +/// `max_bytes` with [`TrustedServerError::RequestTooLarge`]. +/// +/// # Errors +/// +/// Returns an error when: +/// - The body exceeds `max_bytes`. +/// - A streaming body chunk cannot be read (mapped to an `Integration` error). +pub(crate) async fn collect_body_bounded( + body: EdgeBody, + max_bytes: usize, + integration: &'static str, +) -> Result, Report> { + match body { + EdgeBody::Once(bytes) => { + if bytes.len() > max_bytes { + return Err(Report::new(TrustedServerError::RequestTooLarge { + message: format!( + "{integration}: request body ({} bytes) exceeds the {max_bytes} byte limit", + bytes.len(), + ), + })); + } + Ok(bytes.to_vec()) + } + EdgeBody::Stream(mut stream) => { + let mut body_bytes = Vec::new(); + while let Some(chunk_result) = stream.next().await { + let chunk = chunk_result.map_err(|error| { + Report::new(TrustedServerError::Integration { + integration: integration.to_string(), + message: format!("Failed to read request body: {error}"), + }) + })?; + if body_bytes.len() + chunk.len() > max_bytes { + return Err(Report::new(TrustedServerError::RequestTooLarge { + message: format!( + "{integration}: request body exceeds the {max_bytes} byte limit", + ), + })); + } + // Size check runs after chunk is materialized — effective bound is + // ≤ max_bytes + one_chunk (Fastly H2/H3 chunks are ≤ 16 KiB in practice). + body_bytes.extend_from_slice(&chunk); + } + Ok(body_bytes) + } + } +} + +/// Drains an upstream [`EdgeBody`] response into a byte vector, rejecting +/// bodies larger than `max_bytes` with [`TrustedServerError::Integration`]. +/// +/// Use this for upstream (provider/integration) response bodies to bound +/// memory usage when a third-party server misbehaves. Unlike +/// [`collect_body_bounded`], oversized bodies are classified as +/// [`TrustedServerError::Integration`] (502 `BAD_GATEWAY`) rather than +/// [`TrustedServerError::RequestTooLarge`] (413). +/// +/// Note: the effective bound for streaming bodies is ≤ `max_bytes` + `one_chunk` +/// because the size check runs after each chunk is materialized. Fastly +/// H2/H3 chunks are ≤ 16 KiB in practice, making the overshoot negligible. +/// +/// # Errors +/// +/// Returns an error when: +/// - The body exceeds `max_bytes` (mapped to [`TrustedServerError::Integration`]). +/// - A streaming body chunk cannot be read (same error type). +pub(crate) async fn collect_response_bounded( + body: EdgeBody, + max_bytes: usize, + integration: &'static str, +) -> Result, Report> { + match body { + EdgeBody::Once(bytes) => { + if bytes.len() > max_bytes { + return Err(Report::new(TrustedServerError::Integration { + integration: integration.to_string(), + message: format!( + "response body ({} bytes) exceeds the {max_bytes} byte limit", + bytes.len(), + ), + })); + } + Ok(bytes.to_vec()) + } + EdgeBody::Stream(mut stream) => { + let mut body_bytes = Vec::new(); + while let Some(chunk_result) = stream.next().await { + let chunk = chunk_result.map_err(|error| { + Report::new(TrustedServerError::Integration { + integration: integration.to_string(), + message: format!("Failed to read response body: {error}"), + }) + })?; + // Size check runs after chunk is materialized — effective bound is + // ≤ max_bytes + one_chunk (Fastly H2/H3 chunks are ≤ 16 KiB in practice). + if body_bytes.len() + chunk.len() > max_bytes { + return Err(Report::new(TrustedServerError::Integration { + integration: integration.to_string(), + message: format!("response body exceeds the {max_bytes} byte limit",), + })); + } + body_bytes.extend_from_slice(&chunk); + } + Ok(body_bytes) + } + } +} + type IntegrationBuilder = fn(&Settings) -> Result, Report>; diff --git a/crates/trusted-server-core/src/integrations/permutive.rs b/crates/trusted-server-core/src/integrations/permutive.rs index 41d7e3bf..23ec597b 100644 --- a/crates/trusted-server-core/src/integrations/permutive.rs +++ b/crates/trusted-server-core/src/integrations/permutive.rs @@ -6,20 +6,22 @@ use std::sync::Arc; use async_trait::async_trait; +use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; -use fastly::http::{header, Method, StatusCode}; -use fastly::{Request, Response}; +use http::header::{self, HeaderMap, HeaderValue}; +use http::{Method, StatusCode}; use serde::Deserialize; use validator::Validate; -use crate::backend::BackendConfig; -use crate::compat; +use crate::constants::INTERNAL_HEADERS; use crate::error::TrustedServerError; use crate::integrations::{ + collect_body_bounded, collect_response_bounded, ensure_integration_backend, AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, - IntegrationEndpoint, IntegrationProxy, IntegrationRegistration, + IntegrationEndpoint, IntegrationProxy, IntegrationRegistration, INTEGRATION_MAX_BODY_BYTES, + UPSTREAM_SDK_MAX_RESPONSE_BYTES, }; -use crate::platform::RuntimeServices; +use crate::platform::{PlatformHttpRequest, RuntimeServices}; use crate::settings::{IntegrationConfig, Settings}; const PERMUTIVE_INTEGRATION_ID: &str = "permutive"; @@ -106,8 +108,8 @@ impl PermutiveIntegration { async fn handle_sdk_serving( &self, _settings: &Settings, - _req: Request, - ) -> Result> { + services: &RuntimeServices, + ) -> Result, Report> { log::info!("Handling Permutive SDK request"); let sdk_url = self.sdk_url(); @@ -116,33 +118,45 @@ impl PermutiveIntegration { // TODO: Check KV store cache first (future enhancement) // Fetch SDK from Permutive CDN - let mut permutive_req = Request::new(Method::GET, &sdk_url); - permutive_req.set_header(header::USER_AGENT, "TrustedServer/1.0"); - permutive_req.set_header(header::ACCEPT, "application/javascript, */*"); - - let backend_name = BackendConfig::from_url(&sdk_url, true) + let permutive_req = http::Request::builder() + .method(Method::GET) + .uri(&sdk_url) + .header(header::USER_AGENT, "TrustedServer/1.0") + .header(header::ACCEPT, "application/javascript, */*") + .body(EdgeBody::empty()) + .change_context(Self::error("Failed to build Permutive SDK request"))?; + + let backend_name = Self::backend_name_for_url(services, &sdk_url) .change_context(Self::error("Failed to determine backend for SDK fetch"))?; - let mut permutive_response = - permutive_req - .send(backend_name) - .change_context(Self::error(format!( - "Failed to fetch Permutive SDK from {}", - sdk_url - )))?; + let permutive_response = services + .http_client() + .send(PlatformHttpRequest::new(permutive_req, backend_name)) + .await + .change_context(Self::error(format!( + "Failed to fetch Permutive SDK from {}", + sdk_url + )))? + .response; - if !permutive_response.get_status().is_success() { + if !permutive_response.status().is_success() { log::error!( "Permutive SDK fetch failed with status: {}", - permutive_response.get_status() + permutive_response.status() ); return Err(Report::new(Self::error(format!( "Permutive SDK returned error status: {}", - permutive_response.get_status() + permutive_response.status() )))); } - let sdk_body = permutive_response.take_body_bytes(); + let sdk_body = collect_response_bounded( + permutive_response.into_body(), + UPSTREAM_SDK_MAX_RESPONSE_BYTES, + PERMUTIVE_INTEGRATION_ID, + ) + .await + .change_context(Self::error("Failed to read Permutive SDK response body"))?; log::info!( "Successfully fetched Permutive SDK: {} bytes", sdk_body.len() @@ -150,335 +164,99 @@ impl PermutiveIntegration { // TODO: Cache in KV store (future enhancement) - Ok(Response::from_status(StatusCode::OK) - .with_header( + http::Response::builder() + .status(StatusCode::OK) + .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .with_header( + .header( header::CACHE_CONTROL, format!("public, max-age={}", self.config.cache_ttl_seconds), ) - .with_header("X-Permutive-SDK-Proxy", "true") - .with_header("X-SDK-Source", &sdk_url) - .with_body(sdk_body)) + .header("X-Permutive-SDK-Proxy", "true") + .header("X-SDK-Source", &sdk_url) + .body(EdgeBody::from(sdk_body)) + .change_context(Self::error("Failed to build Permutive SDK response")) } - /// Handle API proxy - forward requests to api.permutive.com. - async fn handle_api_proxy( + async fn forward_proxy_request( &self, - _settings: &Settings, - mut req: Request, - ) -> Result> { - let original_path = req.get_path(); - let method = req.get_method(); + services: &RuntimeServices, + req: http::Request, + route_prefix: &str, + upstream_base: &str, + route_name: &str, + ) -> Result, Report> { + let (parts, body) = req.into_parts(); + let original_path = parts.uri.path().to_string(); + let method = parts.method.clone(); log::info!( - "Proxying Permutive API request: {} {}", + "Proxying {} request: {} {}", + route_name, method, original_path ); - // Extract path after /integrations/permutive/api - let api_path = original_path - .strip_prefix("/integrations/permutive/api") - .ok_or_else(|| Self::error(format!("Invalid Permutive API path: {}", original_path)))?; + let upstream_path = original_path.strip_prefix(route_prefix).ok_or_else(|| { + Self::error(format!("Invalid {} path: {}", route_name, original_path)) + })?; - // Build full target URL with query parameters - let query = req - .get_url() + let query = parts + .uri .query() .map(|q| format!("?{}", q)) .unwrap_or_default(); - let target_url = format!("{}{}{}", self.config.api_endpoint, api_path, query); - - log::info!("Forwarding to Permutive API: {}", target_url); + let target_url = format!("{}{}{}", upstream_base, upstream_path, query); - // Create new request - let mut target_req = Request::new(method.clone(), &target_url); + log::info!("Forwarding {} to {}", route_name, target_url); - // Copy headers - self.copy_request_headers(&req, &mut target_req); - - // Copy body for POST/PUT/PATCH - if matches!(method, &Method::POST | &Method::PUT | &Method::PATCH) { - let body = req.take_body(); - target_req.set_body(body); - } - - // Get backend and forward - let backend_name = BackendConfig::from_url(&self.config.api_endpoint, true) - .change_context(Self::error("Failed to determine backend for API proxy"))?; + let request_body = if method == Method::POST { + let bytes = + collect_body_bounded(body, INTEGRATION_MAX_BODY_BYTES, PERMUTIVE_INTEGRATION_ID) + .await?; + EdgeBody::from(bytes) + } else { + EdgeBody::empty() + }; - let response = target_req - .send(backend_name) + let mut target_req = http::Request::builder() + .method(method) + .uri(&target_url) + .body(request_body) .change_context(Self::error(format!( - "Failed to forward request to {}", - target_url + "Failed to build {} proxy request", + route_name )))?; + self.copy_request_headers(&parts.headers, target_req.headers_mut()); - log::info!( - "Permutive API responded with status: {}", - response.get_status() - ); - - Ok(response) - } - - /// Handle Secure Signals proxy - forward requests to secure-signals.permutive.app. - async fn handle_secure_signals_proxy( - &self, - _settings: &Settings, - mut req: Request, - ) -> Result> { - let original_path = req.get_path(); - let method = req.get_method(); - - log::info!( - "Proxying Permutive Secure Signals request: {} {}", - method, - original_path - ); - - // Extract path after /integrations/permutive/secure-signal - let signal_path = original_path - .strip_prefix("/integrations/permutive/secure-signal") - .ok_or_else(|| { - Self::error(format!( - "Invalid Permutive Secure Signals path: {}", - original_path - )) - })?; - - // Build full target URL with query parameters - let query = req - .get_url() - .query() - .map(|q| format!("?{}", q)) - .unwrap_or_default(); - let target_url = format!( - "{}{}{}", - self.config.secure_signals_endpoint, signal_path, query - ); - - log::info!("Forwarding to Permutive Secure Signals: {}", target_url); - - // Create new request - let mut target_req = Request::new(method.clone(), &target_url); - - // Copy headers - self.copy_request_headers(&req, &mut target_req); - - // Copy body for POST/PUT/PATCH - if matches!(method, &Method::POST | &Method::PUT | &Method::PATCH) { - let body = req.take_body(); - target_req.set_body(body); - } - - // Get backend and forward - let backend_name = BackendConfig::from_url(&self.config.secure_signals_endpoint, true) - .change_context(Self::error( - "Failed to determine backend for Secure Signals proxy", + let backend_name = + Self::backend_name_for_url(services, upstream_base).change_context(Self::error( + format!("Failed to determine backend for {} proxy", route_name), ))?; - let response = target_req - .send(backend_name) - .change_context(Self::error(format!( - "Failed to forward request to {}", - target_url - )))?; - - log::info!( - "Permutive Secure Signals responded with status: {}", - response.get_status() - ); - - Ok(response) - } - - /// Handle Events proxy - forward requests to events.permutive.app. - async fn handle_events_proxy( - &self, - _settings: &Settings, - mut req: Request, - ) -> Result> { - let original_path = req.get_path(); - let method = req.get_method(); - - log::info!( - "Proxying Permutive Events request: {} {}", - method, - original_path - ); - - // Extract path after /integrations/permutive/events - let events_path = original_path - .strip_prefix("/integrations/permutive/events") - .ok_or_else(|| { - Self::error(format!("Invalid Permutive Events path: {}", original_path)) - })?; - - // Build full target URL with query parameters - let query = req - .get_url() - .query() - .map(|q| format!("?{}", q)) - .unwrap_or_default(); - let target_url = format!("https://events.permutive.app{}{}", events_path, query); - - log::info!("Forwarding to Permutive Events: {}", target_url); - - // Create new request - let mut target_req = Request::new(method.clone(), &target_url); - - // Copy headers - self.copy_request_headers(&req, &mut target_req); - - // Copy body for POST/PUT/PATCH - if matches!(method, &Method::POST | &Method::PUT | &Method::PATCH) { - let body = req.take_body(); - target_req.set_body(body); - } - - // Get backend and forward - let backend_name = BackendConfig::from_url("https://events.permutive.app", true) - .change_context(Self::error("Failed to determine backend for Events proxy"))?; - - let response = target_req - .send(backend_name) - .change_context(Self::error(format!( - "Failed to forward request to {}", - target_url - )))?; - - log::info!( - "Permutive Events responded with status: {}", - response.get_status() - ); - - Ok(response) - } - - /// Handle Sync proxy - forward requests to sync.permutive.com. - async fn handle_sync_proxy( - &self, - _settings: &Settings, - mut req: Request, - ) -> Result> { - let original_path = req.get_path(); - let method = req.get_method(); - - log::info!( - "Proxying Permutive Sync request: {} {}", - method, - original_path - ); - - // Extract path after /integrations/permutive/sync - let sync_path = original_path - .strip_prefix("/integrations/permutive/sync") - .ok_or_else(|| { - Self::error(format!("Invalid Permutive Sync path: {}", original_path)) - })?; - - // Build full target URL with query parameters - let query = req - .get_url() - .query() - .map(|q| format!("?{}", q)) - .unwrap_or_default(); - let target_url = format!("https://sync.permutive.com{}{}", sync_path, query); - - log::info!("Forwarding to Permutive Sync: {}", target_url); - - // Create new request - let mut target_req = Request::new(method.clone(), &target_url); - - // Copy headers - self.copy_request_headers(&req, &mut target_req); - - // Copy body for POST/PUT/PATCH - if matches!(method, &Method::POST | &Method::PUT | &Method::PATCH) { - let body = req.take_body(); - target_req.set_body(body); - } - - // Get backend and forward - let backend_name = BackendConfig::from_url("https://sync.permutive.com", true) - .change_context(Self::error("Failed to determine backend for Sync proxy"))?; - - let response = target_req - .send(backend_name) + let response = services + .http_client() + .send(PlatformHttpRequest::new(target_req, backend_name)) + .await .change_context(Self::error(format!( "Failed to forward request to {}", target_url - )))?; + )))? + .response; log::info!( - "Permutive Sync responded with status: {}", - response.get_status() - ); - - Ok(response) - } - - /// Handle CDN proxy - forward requests to cdn.permutive.com. - async fn handle_cdn_proxy( - &self, - _settings: &Settings, - req: Request, - ) -> Result> { - let original_path = req.get_path(); - let method = req.get_method(); - - log::info!( - "Proxying Permutive CDN request: {} {}", - method, - original_path - ); - - // Extract path after /integrations/permutive/cdn - let cdn_path = original_path - .strip_prefix("/integrations/permutive/cdn") - .ok_or_else(|| Self::error(format!("Invalid Permutive CDN path: {}", original_path)))?; - - // Build full target URL with query parameters - let query = req - .get_url() - .query() - .map(|q| format!("?{}", q)) - .unwrap_or_default(); - let target_url = format!("https://cdn.permutive.com{}{}", cdn_path, query); - - log::info!("Forwarding to Permutive CDN: {}", target_url); - - // Create new request - let mut target_req = Request::new(method.clone(), &target_url); - - // Copy headers - self.copy_request_headers(&req, &mut target_req); - - // Get backend and forward - let backend_name = BackendConfig::from_url("https://cdn.permutive.com", true) - .change_context(Self::error("Failed to determine backend for CDN proxy"))?; - - let response = target_req - .send(backend_name) - .change_context(Self::error(format!( - "Failed to forward request to {}", - target_url - )))?; - - log::info!( - "Permutive CDN responded with status: {}", - response.get_status() + "{} responded with status: {}", + route_name, + response.status() ); Ok(response) } /// Copy relevant request headers for proxying. - fn copy_request_headers(&self, from: &Request, to: &mut Request) { + fn copy_request_headers(&self, from: &HeaderMap, to: &mut HeaderMap) { let headers_to_copy = [ header::CONTENT_TYPE, header::ACCEPT, @@ -489,13 +267,25 @@ impl PermutiveIntegration { ]; for header_name in &headers_to_copy { - if let Some(value) = from.get_header(header_name) { - to.set_header(header_name, value); + if let Some(value) = from.get(header_name) { + to.insert(header_name, value.clone()); } } // Copy any X-* custom headers, skipping TS-internal headers - compat::copy_fastly_custom_headers(from, to); + for (name, value) in from { + let name_str = name.as_str(); + if name_str.starts_with("x-") && !INTERNAL_HEADERS.contains(&name_str) { + to.append(name.clone(), value.clone()); + } + } + } + + fn backend_name_for_url( + services: &RuntimeServices, + target_url: &str, + ) -> Result> { + ensure_integration_backend(services, target_url, PERMUTIVE_INTEGRATION_ID, None) } } @@ -561,23 +351,58 @@ impl IntegrationProxy for PermutiveIntegration { async fn handle( &self, settings: &Settings, - _services: &RuntimeServices, - req: Request, - ) -> Result> { - let path = req.get_path(); + services: &RuntimeServices, + req: http::Request, + ) -> Result, Report> { + let path = req.uri().path().to_string(); if path.starts_with("/integrations/permutive/api/") { - self.handle_api_proxy(settings, req).await + self.forward_proxy_request( + services, + req, + "/integrations/permutive/api", + &self.config.api_endpoint, + "Permutive API", + ) + .await } else if path.starts_with("/integrations/permutive/secure-signal/") { - self.handle_secure_signals_proxy(settings, req).await + self.forward_proxy_request( + services, + req, + "/integrations/permutive/secure-signal", + &self.config.secure_signals_endpoint, + "Permutive Secure Signals", + ) + .await } else if path.starts_with("/integrations/permutive/events/") { - self.handle_events_proxy(settings, req).await + self.forward_proxy_request( + services, + req, + "/integrations/permutive/events", + "https://events.permutive.app", + "Permutive Events", + ) + .await } else if path.starts_with("/integrations/permutive/sync/") { - self.handle_sync_proxy(settings, req).await + self.forward_proxy_request( + services, + req, + "/integrations/permutive/sync", + "https://sync.permutive.com", + "Permutive Sync", + ) + .await } else if path.starts_with("/integrations/permutive/cdn/") { - self.handle_cdn_proxy(settings, req).await + self.forward_proxy_request( + services, + req, + "/integrations/permutive/cdn", + "https://cdn.permutive.com", + "Permutive CDN", + ) + .await } else if path == "/integrations/permutive/sdk" { - self.handle_sdk_serving(settings, req).await + self.handle_sdk_serving(settings, services).await } else { Err(Report::new(Self::error(format!( "Unknown Permutive route: {}", @@ -641,7 +466,10 @@ fn default_rewrite_sdk() -> bool { #[cfg(test)] mod tests { + use std::sync::Arc; + use super::*; + use crate::platform::test_support::{build_services_with_http_client, StubHttpClient}; use crate::test_support::tests::create_test_settings; #[test] @@ -791,4 +619,43 @@ mod tests { "Should register SDK endpoint" ); } + + #[test] + fn permutive_proxy_uses_platform_http_client() { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"ok".to_vec()); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let settings = create_test_settings(); + let integration = PermutiveIntegration::new(PermutiveConfig { + enabled: true, + organization_id: "myorg".to_string(), + workspace_id: "workspace-123".to_string(), + project_id: String::new(), + api_endpoint: default_api_endpoint(), + secure_signals_endpoint: default_secure_signals_endpoint(), + cache_ttl_seconds: 3600, + rewrite_sdk: true, + }); + let req = http::Request::builder() + .method(http::Method::GET) + .uri("https://publisher.example/integrations/permutive/api/v2.0/events") + .body(EdgeBody::empty()) + .expect("should build request"); + + let response = futures::executor::block_on(integration.handle(&settings, &services, req)) + .expect("should proxy request"); + + assert_eq!( + response.status(), + http::StatusCode::OK, + "should return stubbed response" + ); + assert_eq!( + stub.recorded_backend_names(), + vec!["stub-backend".to_string()], + "should route outbound request through PlatformHttpClient" + ); + } } diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index fc649c75..8a2c6157 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -3,39 +3,42 @@ use std::sync::Arc; use std::time::Duration; use async_trait::async_trait; +use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; -use fastly::http::{header, Method, StatusCode, Url}; -use fastly::{Request, Response}; +use http::header::HeaderValue; +use http::{header, Method, StatusCode}; use serde::{Deserialize, Serialize}; use serde_json::Value as Json; +use url::Url; use validator::Validate; -use crate::auction::orchestrator::ERROR_TYPE_HTTP_STATUS; use crate::auction::provider::AuctionProvider; use crate::auction::types::{ AuctionContext, AuctionRequest, AuctionResponse, Bid as AuctionBid, MediaType, }; use crate::backend::BackendConfig; -use crate::compat; 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::{ - AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, - IntegrationEndpoint, IntegrationHeadInjector, IntegrationHtmlContext, IntegrationProxy, - IntegrationRegistration, + collect_response_bounded, ensure_integration_backend, AttributeRewriteAction, + IntegrationAttributeContext, IntegrationAttributeRewriter, IntegrationEndpoint, + IntegrationHeadInjector, IntegrationHtmlContext, IntegrationProxy, IntegrationRegistration, + UPSTREAM_RTB_MAX_RESPONSE_BYTES, }; use crate::openrtb::{ to_openrtb_i32, Banner, ConsentedProvidersSettings, Device, Format, Geo, Imp, ImpExt, OpenRtbRequest, PrebidExt, PrebidImpExt, Publisher, Regs, RegsExt, RequestExt, Site, ToExt, TrustedServerExt, User, UserExt, }; -use crate::platform::RuntimeServices; +use crate::platform::{ + PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, RuntimeServices, +}; use crate::request_signing::{RequestSigner, SigningParams, SIGNING_VERSION}; use crate::settings::{IntegrationConfig, Settings}; const PREBID_INTEGRATION_ID: &str = "prebid"; -const PREBID_REASON_EMPTY_RESPONSE: &str = "empty_response"; const TRUSTED_SERVER_BIDDER: &str = "trustedServer"; const BIDDER_PARAMS_KEY: &str = "bidderParams"; const ZONE_KEY: &str = "zone"; @@ -43,14 +46,13 @@ const ZONE_KEY: &str = "zone"; /// Default currency for `OpenRTB` bid floors and responses. const DEFAULT_CURRENCY: &str = "USD"; -/// Maximum number of characters from upstream failure payloads included in -/// debug-facing `body_preview` metadata. +#[cfg(test)] const PREBID_ERROR_BODY_PREVIEW_CHARS: usize = 1000; -/// Maximum number of bytes processed when constructing debug-facing upstream -/// failure previews. +#[cfg(test)] const PREBID_ERROR_BODY_PREVIEW_BYTES: usize = PREBID_ERROR_BODY_PREVIEW_CHARS * 4; +#[cfg(test)] fn prebid_body_preview(body: &[u8]) -> String { let bounded_body = &body[..body.len().min(PREBID_ERROR_BODY_PREVIEW_BYTES)]; @@ -335,16 +337,22 @@ impl PrebidIntegration { false } - fn handle_script_handler(&self) -> Result> { + fn handle_script_handler( + &self, + ) -> Result, Report> { let body = "// Script overridden by Trusted Server\n"; - Ok(Response::from_status(StatusCode::OK) - .with_header( + http::Response::builder() + .status(StatusCode::OK) + .header( header::CONTENT_TYPE, "application/javascript; charset=utf-8", ) - .with_header(header::CACHE_CONTROL, "public, max-age=31536000") - .with_body(body)) + .header(header::CACHE_CONTROL, "public, max-age=31536000") + .body(EdgeBody::from(body)) + .change_context(TrustedServerError::Prebid { + message: "Failed to build Prebid script handler response".to_string(), + }) } } @@ -422,15 +430,20 @@ impl IntegrationProxy for PrebidIntegration { &self, _settings: &Settings, _services: &RuntimeServices, - req: Request, - ) -> Result> { - let path = req.get_path().to_string(); - let method = req.get_method().clone(); + req: http::Request, + ) -> Result, Report> { + let path = req.uri().path().to_string(); + let method = req.method().clone(); match method { // Serve empty JS for matching script patterns Method::GET if self.matches_script_pattern(&path) => self.handle_script_handler(), - _ => Ok(Response::from_status(StatusCode::NOT_FOUND).with_body("Not Found")), + _ => http::Response::builder() + .status(StatusCode::NOT_FOUND) + .body(EdgeBody::from("Not Found")) + .change_context(TrustedServerError::Prebid { + message: "Failed to build Prebid not found response".to_string(), + }), } } } @@ -829,8 +842,8 @@ fn non_empty_override_object( /// stripped from the `Cookie` header since consent travels exclusively /// through the `OpenRTB` body. fn copy_request_headers( - from: &Request, - to: &mut Request, + from: &http::Request, + to: &mut http::Request, consent_forwarding: ConsentForwardingMode, ) { let headers_to_copy = [ @@ -841,12 +854,37 @@ fn copy_request_headers( ]; for header_name in &headers_to_copy { - if let Some(value) = from.get_header(header_name) { - to.set_header(header_name, value); + if let Some(value) = from.headers().get(header_name) { + to.headers_mut().insert(header_name, value.clone()); } } - compat::forward_fastly_cookie_header(from, to, consent_forwarding.strips_consent_cookies()); + let Some(cookie_value) = from.headers().get(header::COOKIE) else { + return; + }; + + if !consent_forwarding.strips_consent_cookies() { + to.headers_mut() + .insert(header::COOKIE, cookie_value.clone()); + return; + } + + match cookie_value.to_str() { + Ok(value) => { + let stripped = strip_cookies(value, CONSENT_COOKIE_NAMES); + if stripped.is_empty() { + return; + } + + if let Ok(cookie_header) = HeaderValue::from_str(&stripped) { + to.headers_mut().insert(header::COOKIE, cookie_header); + } + } + Err(_) => { + to.headers_mut() + .insert(header::COOKIE, cookie_value.clone()); + } + } } /// Appends query parameters to a URL, handling both URLs with and without existing query strings. @@ -896,7 +934,7 @@ impl PrebidAuctionProvider { request: &AuctionRequest, context: &AuctionContext<'_>, signer: Option<(&RequestSigner, String, &SigningParams)>, - _request_info: RequestInfo, + request_info: RequestInfo, ) -> OpenRtbRequest { let imps = request .slots @@ -1026,17 +1064,24 @@ impl PrebidAuctionProvider { }); // Extract DNT header and Accept-Language from the original request - let dnt = context.request.get_header_str("DNT").and_then(|v| { - if v.trim() == "1" { - Some(true) - } else { - None - } - }); + let dnt = context + .request + .headers() + .get("DNT") + .and_then(|value| value.to_str().ok()) + .and_then(|value| { + if value.trim() == "1" { + Some(true) + } else { + None + } + }); let language = context .request - .get_header_str(header::ACCEPT_LANGUAGE) + .headers() + .get(header::ACCEPT_LANGUAGE) + .and_then(|value| value.to_str().ok()) .and_then(|v| { // Extract the primary ISO-639 language tag (e.g., "en" from // "en-US,en;q=0.9"). Strip the region subtag so bidders get a @@ -1109,8 +1154,6 @@ impl PrebidAuctionProvider { let regs = Self::build_regs(consent_ctx); // Build ext object - let http_req = compat::from_fastly_headers_ref(context.request); - let request_info = RequestInfo::from_request(&http_req, &context.services.client_info); let (version, signature, kid, ts) = signer .map(|(s, sig, params)| { ( @@ -1143,7 +1186,9 @@ impl PrebidAuctionProvider { // Extract Referer header for site.ref let referer = context .request - .get_header_str(header::REFERER) + .headers() + .get(header::REFERER) + .and_then(|value| value.to_str().ok()) .map(std::string::ToString::to_string); let tmax = to_openrtb_i32(self.config.timeout_ms, "tmax", "request"); @@ -1391,100 +1436,21 @@ impl PrebidAuctionProvider { } } -impl PrebidAuctionProvider { - fn parse_response_inner( - &self, - mut response: fastly::Response, - response_time_ms: u64, - ) -> Result> { - // Parse response - let status = response.get_status(); - let body_bytes = response.take_body_bytes(); - - if !status.is_success() { - log::warn!( - "Prebid returned non-success status: {status}; {} bytes", - body_bytes.len() - ); - - let mut auction_response = - AuctionResponse::error(PREBID_INTEGRATION_ID, response_time_ms) - .with_metadata("error_type", serde_json::json!(ERROR_TYPE_HTTP_STATUS)) - .with_metadata("http_status", serde_json::json!(status.as_u16())); - if self.config.debug { - let body_preview = prebid_body_preview(&body_bytes); - if !body_preview.is_empty() { - log::debug!("Prebid non-success response body: {body_preview}"); - auction_response = auction_response - .with_metadata("body_preview", serde_json::json!(body_preview)); - } - } - - return Ok(auction_response); - } - - if body_bytes.is_empty() { - log::info!( - "Prebid returned successful empty response with status {status}; treating as no-bid" - ); - return Ok( - AuctionResponse::no_bid(PREBID_INTEGRATION_ID, response_time_ms) - .with_metadata("reason", serde_json::json!(PREBID_REASON_EMPTY_RESPONSE)) - .with_metadata("http_status", serde_json::json!(status.as_u16())), - ); - } - - let response_json: Json = match serde_json::from_slice(&body_bytes) { - Ok(response_json) => response_json, - Err(error) => { - log::warn!( - "Prebid: failed to parse response JSON (status {status}, {} bytes): {error}", - body_bytes.len() - ); - return Err(Report::new(TrustedServerError::Prebid { - message: "Failed to parse Prebid response JSON".to_string(), - })); - } - }; - - // Log the full response body when debug is enabled to surface - // ext.debug.httpcalls, resolvedrequest, bidstatus, errors, etc. - if self.config.debug && log::log_enabled!(log::Level::Trace) { - match serde_json::to_string_pretty(&response_json) { - Ok(json) => log::trace!("Prebid OpenRTB response:\n{json}"), - Err(e) => { - log::warn!("Prebid: failed to serialize response for logging: {e}"); - } - } - } - - let mut auction_response = self.parse_openrtb_response(&response_json, response_time_ms); - self.enrich_response_metadata(&response_json, &mut auction_response); - - log::info!( - "Prebid returned {} bids in {}ms", - auction_response.bids.len(), - response_time_ms - ); - - Ok(auction_response) - } -} - +#[async_trait(?Send)] impl AuctionProvider for PrebidAuctionProvider { fn provider_name(&self) -> &'static str { PREBID_INTEGRATION_ID } - fn request_bids( + async fn request_bids( &self, request: &AuctionRequest, context: &AuctionContext<'_>, - ) -> Result> { + ) -> Result> { log::info!("Prebid: requesting bids for {} slots", request.slots.len()); - let http_req = compat::from_fastly_headers_ref(context.request); - let request_info = RequestInfo::from_request(&http_req, &context.services.client_info); + let request_info = + RequestInfo::from_request(context.request, context.services.client_info()); // Create signer and compute signature if request signing is enabled let signer_with_signature = @@ -1540,44 +1506,103 @@ impl AuctionProvider for PrebidAuctionProvider { } // Create HTTP request - let mut pbs_req = Request::new( - Method::POST, - format!("{}/openrtb2/auction", self.config.server_url), - ); + let mut pbs_req = http::Request::builder() + .method(http::Method::POST) + .uri(format!("{}/openrtb2/auction", self.config.server_url)) + .body(EdgeBody::empty()) + .change_context(TrustedServerError::Prebid { + message: "Failed to build Prebid request".to_string(), + })?; copy_request_headers( context.request, &mut pbs_req, self.config.consent_forwarding, ); - pbs_req - .set_body_json(&openrtb) - .change_context(TrustedServerError::Prebid { - message: "Failed to set request body".to_string(), - })?; + let pbs_body = serde_json::to_vec(&openrtb).change_context(TrustedServerError::Prebid { + message: "Failed to serialize Prebid request body".to_string(), + })?; + pbs_req.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + *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( + context.services, &self.config.server_url, - true, - Duration::from_millis(u64::from(context.timeout_ms)), + "prebid", + Some(Duration::from_millis(u64::from(context.timeout_ms))), )?; - let pending = - pbs_req - .send_async(backend_name) - .change_context(TrustedServerError::Prebid { - message: "Failed to send async request to Prebid Server".to_string(), - })?; + let pending = context + .services + .http_client() + .send_async(PlatformHttpRequest::new(pbs_req, backend_name)) + .await + .change_context(TrustedServerError::Prebid { + message: "Failed to send async request to Prebid Server".to_string(), + })?; Ok(pending) } - fn parse_response( + async fn parse_response( &self, - response: fastly::Response, + response: PlatformResponse, response_time_ms: u64, ) -> Result> { - self.parse_response_inner(response, response_time_ms) + let response = response.response; + let status = response.status(); + + // Parse response — collect_response_bounded caps memory from misbehaving providers. + let body_bytes = collect_response_bounded( + response.into_body(), + UPSTREAM_RTB_MAX_RESPONSE_BYTES, + "prebid", + ) + .await + .change_context(TrustedServerError::Prebid { + message: "Failed to read Prebid response body".to_string(), + })?; + + if !status.is_success() { + log::warn!("Prebid returned non-success status: {}", status,); + if log::log_enabled!(log::Level::Trace) { + let body_preview = String::from_utf8_lossy(&body_bytes); + log::trace!( + "Prebid error response body: {}", + &body_preview[..body_preview.floor_char_boundary(1000)] + ); + } + return Ok(AuctionResponse::error("prebid", response_time_ms)); + } + + let response_json: Json = + serde_json::from_slice(&body_bytes).change_context(TrustedServerError::Prebid { + message: "Failed to parse Prebid response".to_string(), + })?; + + // Log the full response body when debug is enabled to surface + // ext.debug.httpcalls, resolvedrequest, bidstatus, errors, etc. + if self.config.debug && log::log_enabled!(log::Level::Trace) { + match serde_json::to_string_pretty(&response_json) { + Ok(json) => log::trace!("Prebid OpenRTB response:\n{json}"), + Err(e) => { + log::warn!("Prebid: failed to serialize response for logging: {e}"); + } + } + } + + let mut auction_response = self.parse_openrtb_response(&response_json, response_time_ms); + self.enrich_response_metadata(&response_json, &mut auction_response); + + log::info!( + "Prebid returned {} bids in {}ms", + auction_response.bids.len(), + response_time_ms + ); + + Ok(auction_response) } fn supports_media_type(&self, media_type: &MediaType) -> bool { @@ -1645,22 +1670,25 @@ pub fn register_auction_provider( #[cfg(test)] mod tests { + use std::sync::Arc; + use super::*; use crate::auction::test_support::create_test_auction_context as shared_test_auction_context; use crate::auction::types::{ AdFormat, AdSlot, AuctionContext, AuctionRequest, DeviceInfo, PublisherInfo, UserInfo, }; + use crate::consent::ConsentContext; use crate::geo::GeoInfo; use crate::html_processor::{create_html_processor, HtmlProcessorConfig}; use crate::integrations::{ AttributeRewriteAction, IntegrationDocumentState, IntegrationRegistry, }; + use crate::platform::test_support::{build_services_with_http_client, StubHttpClient}; use crate::settings::Settings; use crate::streaming_processor::{Compression, PipelineConfig, StreamingPipeline}; use crate::test_support::tests::crate_test_settings_str; - use fastly::http::Method; - use fastly::Request; + use http::Method; use serde_json::json; use std::collections::HashMap; use std::io::Cursor; @@ -1717,16 +1745,61 @@ mod tests { } } + fn build_test_request() -> http::Request { + http::Request::builder() + .method(http::Method::GET) + .uri("https://pub.example/auction") + .body(EdgeBody::empty()) + .expect("should build request") + } + + #[test] + fn prebid_provider_uses_platform_http_client_for_bid_request() { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, br#"{"seatbid":[]}"#.to_vec()); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let settings = make_settings(); + let provider = PrebidAuctionProvider::new(base_config()); + let auction_request = create_test_auction_request(); + let http_req = http::Request::builder() + .method(http::Method::POST) + .uri("https://publisher.example/auction") + .body(EdgeBody::empty()) + .expect("should build request"); + let context = AuctionContext { + settings: &settings, + request: &http_req, + timeout_ms: 500, + provider_responses: None, + services: &services, + }; + + let pending = + futures::executor::block_on(provider.request_bids(&auction_request, &context)) + .expect("should start request"); + + assert!( + pending.backend_name().is_some(), + "should preserve backend correlation" + ); + assert_eq!( + stub.recorded_backend_names().len(), + 1, + "should launch one upstream request through PlatformHttpClient" + ); + } + fn create_test_auction_context<'a>( settings: &'a Settings, - request: &'a Request, + request: &'a http::Request, ) -> AuctionContext<'a> { shared_test_auction_context(settings, request, 1000) } fn make_request_info(context: &AuctionContext<'_>) -> RequestInfo { - let http_req = compat::from_fastly_headers_ref(context.request); - RequestInfo::from_request(&http_req, &context.services.client_info) + RequestInfo::from_request(context.request, context.services.client_info()) } fn config_from_settings( @@ -1975,19 +2048,24 @@ server_url = "https://prebid.example" .handle_script_handler() .expect("should return response"); - assert_eq!(response.get_status(), StatusCode::OK); + assert_eq!(response.status(), StatusCode::OK); let content_type = response - .get_header_str(header::CONTENT_TYPE) + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) .expect("should have content-type"); assert_eq!(content_type, "application/javascript; charset=utf-8"); let cache_control = response - .get_header_str(header::CACHE_CONTROL) + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()) .expect("should have cache-control"); assert!(cache_control.contains("max-age=31536000")); - let body = response.into_body_str(); + let body = String::from_utf8(response.into_body().into_bytes().to_vec()) + .expect("should parse script body as utf-8"); assert!(body.contains("// Script overridden by Trusted Server")); } @@ -2149,7 +2227,7 @@ server_url = "https://prebid.example" let provider = PrebidAuctionProvider::new(config); let auction_request = create_test_auction_request(); let settings = make_settings(); - let request = Request::get("https://pub.example/auction"); + let request = build_test_request(); let context = create_test_auction_context(&settings, &request); let openrtb = provider.to_openrtb( @@ -2201,7 +2279,7 @@ server_url = "https://prebid.example" let provider = PrebidAuctionProvider::new(config); let auction_request = create_test_auction_request(); let settings = make_settings(); - let request = Request::get("https://pub.example/auction"); + let request = build_test_request(); let context = create_test_auction_context(&settings, &request); let openrtb = provider.to_openrtb( @@ -2235,7 +2313,7 @@ server_url = "https://prebid.example" geo: None, }); let settings = make_settings(); - let request = Request::get("https://pub.example/auction"); + let request = build_test_request(); let context = create_test_auction_context(&settings, &request); let openrtb = provider.to_openrtb( @@ -2267,7 +2345,7 @@ server_url = "https://prebid.example" let provider = PrebidAuctionProvider::new(base_config()); let auction_request = create_test_auction_request(); let settings = make_settings(); - let request = Request::get("https://pub.example/auction"); + let request = build_test_request(); let context = create_test_auction_context(&settings, &request); let openrtb = provider.to_openrtb( @@ -2322,7 +2400,7 @@ server_url = "https://prebid.example" auction_request.slots[0].floor_price = Some(1.5); let settings = make_settings(); - let request = Request::get("https://pub.example/auction"); + let request = build_test_request(); let context = create_test_auction_context(&settings, &request); let openrtb = provider.to_openrtb( @@ -2347,7 +2425,7 @@ server_url = "https://prebid.example" let auction_request = create_test_auction_request(); // floor_price is None let settings = make_settings(); - let request = Request::get("https://pub.example/auction"); + let request = build_test_request(); let context = create_test_auction_context(&settings, &request); let openrtb = provider.to_openrtb( @@ -2371,7 +2449,7 @@ server_url = "https://prebid.example" let auction_request = create_test_auction_request(); let settings = make_settings(); - let request = Request::get("https://pub.example/auction"); + let request = build_test_request(); let context = create_test_auction_context(&settings, &request); let openrtb = provider.to_openrtb( @@ -2416,7 +2494,7 @@ server_url = "https://prebid.example" }); let settings = make_settings(); - let request = Request::get("https://pub.example/auction"); + let request = build_test_request(); let context = create_test_auction_context(&settings, &request); let openrtb = provider.to_openrtb( @@ -2467,7 +2545,7 @@ server_url = "https://prebid.example" }); let settings = make_settings(); - let request = Request::get("https://pub.example/auction"); + let request = build_test_request(); let context = create_test_auction_context(&settings, &request); let openrtb = provider.to_openrtb( @@ -2506,7 +2584,7 @@ server_url = "https://prebid.example" }); let settings = make_settings(); - let request = Request::get("https://pub.example/auction"); + let request = build_test_request(); let context = create_test_auction_context(&settings, &request); let openrtb = provider.to_openrtb( @@ -2534,7 +2612,7 @@ server_url = "https://prebid.example" // No device/geo let settings = make_settings(); - let request = Request::get("https://pub.example/auction"); + let request = build_test_request(); let context = create_test_auction_context(&settings, &request); let openrtb = provider.to_openrtb( @@ -2557,7 +2635,7 @@ server_url = "https://prebid.example" let auction_request = create_test_auction_request(); // consent=None, no geo let settings = make_settings(); - let request = Request::get("https://pub.example/auction"); + let request = build_test_request(); let context = create_test_auction_context(&settings, &request); let openrtb = provider.to_openrtb( @@ -2583,7 +2661,7 @@ server_url = "https://prebid.example" }); let settings = make_settings(); - let request = Request::get("https://pub.example/auction"); + let request = build_test_request(); let context = create_test_auction_context(&settings, &request); let openrtb = provider.to_openrtb( @@ -2803,8 +2881,10 @@ server_url = "https://prebid.example" }); let settings = make_settings(); - let mut request = Request::get("https://pub.example/auction"); - request.set_header("DNT", "1"); + let mut request = build_test_request(); + request + .headers_mut() + .insert("DNT", http::header::HeaderValue::from_static("1")); let context = create_test_auction_context(&settings, &request); let openrtb = provider.to_openrtb( @@ -2829,8 +2909,11 @@ server_url = "https://prebid.example" }); let settings = make_settings(); - let mut request = Request::get("https://pub.example/auction"); - request.set_header("Accept-Language", "en-US,en;q=0.9,fr;q=0.8"); + let mut request = build_test_request(); + request.headers_mut().insert( + "Accept-Language", + http::header::HeaderValue::from_static("en-US,en;q=0.9,fr;q=0.8"), + ); let context = create_test_auction_context(&settings, &request); let openrtb = provider.to_openrtb( @@ -2859,8 +2942,11 @@ server_url = "https://prebid.example" }); let settings = make_settings(); - let mut request = Request::get("https://pub.example/auction"); - request.set_header("Accept-Language", ""); + let mut request = build_test_request(); + request.headers_mut().insert( + "Accept-Language", + http::header::HeaderValue::from_static(""), + ); let context = create_test_auction_context(&settings, &request); let openrtb = provider.to_openrtb( @@ -2896,7 +2982,7 @@ server_url = "https://prebid.example" }]; let settings = make_settings(); - let request = Request::get("https://pub.example/auction"); + let request = build_test_request(); let context = create_test_auction_context(&settings, &request); let openrtb = provider.to_openrtb( @@ -2932,7 +3018,7 @@ server_url = "https://prebid.example" }); let settings = make_settings(); - let request = Request::get("https://pub.example/auction"); + let request = build_test_request(); let context = create_test_auction_context(&settings, &request); let openrtb = provider.to_openrtb( @@ -2965,7 +3051,7 @@ server_url = "https://prebid.example" let auction_request = create_test_auction_request(); let settings = make_settings(); - let request = Request::get("https://pub.example/auction"); + let request = build_test_request(); let context = create_test_auction_context(&settings, &request); let openrtb = provider.to_openrtb( @@ -2995,7 +3081,7 @@ server_url = "https://prebid.example" let auction_request = create_test_auction_request(); let settings = make_settings(); - let request = Request::get("https://pub.example/auction"); + let request = build_test_request(); let context = create_test_auction_context(&settings, &request); let openrtb = provider.to_openrtb( @@ -3022,7 +3108,7 @@ server_url = "https://prebid.example" }); let settings = make_settings(); - let request = Request::get("https://pub.example/auction"); + let request = build_test_request(); let context = create_test_auction_context(&settings, &request); let openrtb = provider.to_openrtb( @@ -3052,8 +3138,11 @@ server_url = "https://prebid.example" let auction_request = create_test_auction_request(); let settings = make_settings(); - let mut request = Request::get("https://pub.example/auction"); - request.set_header("Referer", "https://google.com/search?q=test"); + let mut request = build_test_request(); + request.headers_mut().insert( + "Referer", + http::header::HeaderValue::from_static("https://google.com/search?q=test"), + ); let context = create_test_auction_context(&settings, &request); let openrtb = provider.to_openrtb( @@ -3077,7 +3166,7 @@ server_url = "https://prebid.example" let auction_request = create_test_auction_request(); let settings = make_settings(); - let request = Request::get("https://pub.example/auction"); + let request = build_test_request(); let context = create_test_auction_context(&settings, &request); let openrtb = provider.to_openrtb( @@ -3123,7 +3212,7 @@ server_url = "https://prebid.example" ]); let settings = make_settings(); - let request = Request::get("https://pub.example/auction"); + let request = build_test_request(); let context = create_test_auction_context(&settings, &request); let openrtb = provider.to_openrtb( @@ -3153,7 +3242,7 @@ server_url = "https://prebid.example" let auction_request = create_test_auction_request(); let settings = make_settings(); - let request = Request::get("https://pub.example/auction"); + let request = build_test_request(); let context = create_test_auction_context(&settings, &request); let openrtb = provider.to_openrtb( @@ -3341,11 +3430,15 @@ server_url = "https://prebid.example" use crate::platform::test_support::noop_services; let provider = PrebidAuctionProvider::new(config); let settings = make_settings(); - let fastly_req = Request::new(Method::POST, "https://example.com/auction"); + let http_req = http::Request::builder() + .method(http::Method::POST) + .uri("https://example.com/auction") + .body(EdgeBody::empty()) + .expect("should build request"); let services = noop_services(); let context = AuctionContext { settings: &settings, - request: &fastly_req, + request: &http_req, timeout_ms: 1000, provider_responses: None, services: &services, @@ -3370,154 +3463,6 @@ server_url = "https://prebid.example" serde_json::from_value(ext["prebid"].clone()).expect("should deserialise ext.prebid") } - #[test] - fn parse_response_non_success_returns_error_with_http_metadata() { - let provider = PrebidAuctionProvider::new(base_config()); - let response = Response::from_status(StatusCode::BAD_REQUEST).with_body("invalid request"); - - let auction_response = provider - .parse_response(response, 58) - .expect("should convert non-success status to provider error"); - - assert_eq!( - auction_response.status, - crate::auction::types::BidStatus::Error, - "should mark non-success upstream responses as errors" - ); - assert_eq!( - auction_response.metadata["error_type"], - json!("http_status"), - "should classify the error source" - ); - assert_eq!( - auction_response.metadata["http_status"], - json!(400), - "should include upstream HTTP status" - ); - assert!( - !auction_response.metadata.contains_key("body_preview"), - "should omit upstream body preview unless Prebid debug is enabled" - ); - } - - #[test] - fn parse_response_non_success_includes_body_preview_when_debug_enabled() { - let mut config = base_config(); - config.debug = true; - let provider = PrebidAuctionProvider::new(config); - let body = "x".repeat(PREBID_ERROR_BODY_PREVIEW_CHARS + 100); - let response = Response::from_status(StatusCode::BAD_REQUEST).with_body(body); - - let auction_response = provider - .parse_response(response, 58) - .expect("should convert non-success status to provider error"); - - let body_preview = auction_response.metadata["body_preview"] - .as_str() - .expect("should include upstream body preview in debug mode"); - assert_eq!( - body_preview.chars().count(), - PREBID_ERROR_BODY_PREVIEW_CHARS, - "should cap debug upstream body preview" - ); - } - - #[test] - fn parse_response_invalid_json_returns_safe_client_error() { - let provider = PrebidAuctionProvider::new(base_config()); - let response = Response::from_status(StatusCode::OK).with_body(r#"{"seatbid":["bid""#); - - let error = provider - .parse_response(response, 42) - .expect_err("should return parse failure for invalid JSON"); - - let message = format!("{error}"); - assert!( - message.contains("Failed to parse Prebid response JSON"), - "should include stable user-safe parse failure message" - ); - assert!( - !message.contains("expected value"), - "should not leak serde parse details" - ); - assert!( - !message.contains("bytes"), - "should not leak response length in the user-safe message" - ); - } - - #[test] - fn parse_response_no_content_returns_no_bid_with_reason() { - let provider = PrebidAuctionProvider::new(base_config()); - let response = Response::from_status(StatusCode::NO_CONTENT); - - let auction_response = provider - .parse_response(response, 42) - .expect("should convert no-content status to no-bid"); - - assert_eq!( - auction_response.status, - crate::auction::types::BidStatus::NoBid, - "should treat 204 as a no-bid response" - ); - assert_eq!( - auction_response.metadata["reason"], - json!("empty_response"), - "should explain why the provider returned no bids" - ); - assert_eq!( - auction_response.metadata["http_status"], - json!(204), - "should include upstream HTTP status" - ); - } - - #[test] - fn parse_response_ok_empty_body_returns_no_bid_with_reason() { - let provider = PrebidAuctionProvider::new(base_config()); - let response = Response::from_status(StatusCode::OK); - - let auction_response = provider - .parse_response(response, 17) - .expect("should convert empty successful response to no-bid"); - - assert_eq!( - auction_response.status, - crate::auction::types::BidStatus::NoBid, - "should treat empty 200 as a no-bid response" - ); - assert_eq!( - auction_response.metadata["reason"], - json!("empty_response"), - "should explain why the provider returned no bids" - ); - assert_eq!( - auction_response.metadata["http_status"], - json!(200), - "should include upstream HTTP status" - ); - } - - #[test] - fn parse_response_valid_json_without_bids_returns_no_bid() { - let provider = PrebidAuctionProvider::new(base_config()); - let response = Response::from_status(StatusCode::OK).with_body(r#"{"seatbid":[]}"#); - - let auction_response = provider - .parse_response(response, 23) - .expect("should parse valid no-bid JSON"); - - assert_eq!( - auction_response.status, - crate::auction::types::BidStatus::NoBid, - "should preserve valid JSON no-bid behavior" - ); - assert!( - auction_response.metadata.is_empty(), - "should not add empty-response metadata for valid no-bid JSON" - ); - } - // ======================================================================== // bid_param_overrides tests // ======================================================================== diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 9c1767ff..ce7cc6fd 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -3,12 +3,11 @@ use std::collections::BTreeMap; use std::sync::{Arc, Mutex}; use async_trait::async_trait; +use edgezero_core::body::Body as EdgeBody; use error_stack::Report; -use fastly::http::Method; -use fastly::{Request, Response}; +use http::{Method, Request, Response}; use matchit::Router; -use crate::compat; use crate::constants::HEADER_X_TS_EC; use crate::ec::kv::KvIdentityGraph; use crate::ec::EcContext; @@ -262,8 +261,8 @@ pub trait IntegrationProxy: Send + Sync { &self, settings: &Settings, services: &RuntimeServices, - req: Request, - ) -> Result>; + req: Request, + ) -> Result, Report>; /// Helper to create a namespaced GET endpoint. /// Automatically prefixes the path with `/integrations/{integration_name()}`. @@ -553,7 +552,7 @@ pub struct ProxyDispatchInput<'a> { pub kv: Option<&'a KvIdentityGraph>, pub ec_context: &'a mut EcContext, pub services: &'a RuntimeServices, - pub req: Request, + pub req: Request, } /// In-memory registry of integrations discovered from settings. @@ -677,7 +676,7 @@ impl IntegrationRegistry { pub async fn handle_proxy( &self, input: ProxyDispatchInput<'_>, - ) -> Option>> { + ) -> Option, Report>> { let ProxyDispatchInput { method, path, @@ -691,8 +690,7 @@ impl IntegrationRegistry { // Organic proxy handler: generate if needed (best effort). // Only generate for document navigations — subresource requests // may lack consent signals such as the Sec-GPC header. - let http_req = compat::from_fastly_headers_ref(&req); - if is_navigation_request(&http_req) { + if is_navigation_request(&req) { if let Err(err) = ec_context.generate_if_needed(settings, kv) { log::warn!("EC generation failed for integration proxy: {err:?}"); } @@ -704,7 +702,7 @@ impl IntegrationRegistry { } // Remove any caller-supplied EC header rather than forwarding it. - req.remove_header(HEADER_X_TS_EC); + req.headers_mut().remove(HEADER_X_TS_EC.clone()); Some(proxy.handle(settings, services, req).await) } else { @@ -982,6 +980,9 @@ impl IntegrationRegistry { #[cfg(test)] mod tests { use super::*; + use crate::constants::COOKIE_TS_EC; + use crate::platform::test_support::noop_services; + use http::{header, HeaderValue, StatusCode}; // Mock integration proxy for testing struct MockProxy; @@ -1000,9 +1001,9 @@ mod tests { &self, _settings: &Settings, _services: &RuntimeServices, - _req: Request, - ) -> Result> { - Ok(Response::new()) + _req: Request, + ) -> Result, Report> { + Ok(Response::new(EdgeBody::empty())) } } @@ -1018,6 +1019,33 @@ mod tests { } } + struct EchoProxy; + + #[async_trait(?Send)] + impl IntegrationProxy for EchoProxy { + fn integration_name(&self) -> &'static str { + "echo" + } + + fn routes(&self) -> Vec { + vec![] + } + + async fn handle( + &self, + _settings: &Settings, + _services: &RuntimeServices, + req: http::Request, + ) -> Result, Report> { + let response = http::Response::builder() + .status(http::StatusCode::OK) + .header("x-echo-path", req.uri().path()) + .body(EdgeBody::empty()) + .expect("should build echo response"); + Ok(response) + } + } + #[test] fn default_html_post_processor_should_process_is_false() { let processor = NoopHtmlPostProcessor; @@ -1035,6 +1063,46 @@ mod tests { ); } + #[test] + fn handle_proxy_passes_http_request_without_fastly_round_trip() { + let settings = create_test_settings(); + let registry = IntegrationRegistry::from_routes(vec![( + http::Method::GET, + "/integrations/test/echo", + (Arc::new(EchoProxy) as Arc, "echo"), + )]); + let req = http::Request::builder() + .method(http::Method::GET) + .uri("https://test.example.com/integrations/test/echo?x=1") + .body(EdgeBody::empty()) + .expect("should build request"); + + let mut ec_context = + EcContext::new_for_test(None, crate::consent::ConsentContext::default()); + let response = futures::executor::block_on(registry.handle_proxy(ProxyDispatchInput { + method: &http::Method::GET, + path: "/integrations/test/echo", + settings: &settings, + kv: None, + ec_context: &mut ec_context, + services: &noop_services(), + req, + })) + .expect("should match route") + .expect("proxy should succeed"); + + assert_eq!( + response.status(), + http::StatusCode::OK, + "should preserve HTTP status" + ); + assert_eq!( + response.headers()["x-echo-path"], + "/integrations/test/echo", + "should expose the HTTP request path to the proxy" + ); + } + #[test] fn test_exact_route_matching() { let routes = vec![( @@ -1255,7 +1323,6 @@ mod tests { // Tests for EC ID header on proxy responses use crate::test_support::tests::create_test_settings; - use fastly::http::header; /// Mock proxy that returns a simple 200 OK response struct EcTestProxy; @@ -1283,15 +1350,17 @@ mod tests { &self, _settings: &Settings, _services: &RuntimeServices, - req: Request, - ) -> Result> { - let mut response = - Response::from_status(fastly::http::StatusCode::OK).with_body("test response"); - - if let Some(ec) = req.get_header(HEADER_X_TS_EC) { - response.set_header("x-echo-ts-ec", ec); + req: Request, + ) -> Result, Report> { + let mut response = Response::builder() + .status(StatusCode::OK) + .body(EdgeBody::from("test response")) + .expect("should build test response"); + if let Some(ec) = req.headers().get(HEADER_X_TS_EC.clone()) { + response + .headers_mut() + .insert(http::HeaderName::from_static("x-echo-ts-ec"), ec.clone()); } - Ok(response) } } @@ -1309,14 +1378,18 @@ mod tests { )]; let registry = IntegrationRegistry::from_routes(routes); - let valid_ec_id = format!("{}.CkEc1", "a".repeat(64)); - let mut req = Request::get("https://test-publisher.com/integrations/test/ec"); - req.set_header(header::COOKIE, format!("ts-ec={valid_ec_id}")); - req.set_header("x-ts-ec", format!("{}.HdrEc1", "b".repeat(64))); + let mut req = Request::builder() + .method(Method::GET) + .uri("https://test-publisher.com/integrations/test/ec") + .body(EdgeBody::empty()) + .expect("should build request"); + req.headers_mut().insert( + HEADER_X_TS_EC.clone(), + HeaderValue::from_static("some-ec-value"), + ); let mut ec_context = - EcContext::read_from_request(&settings, &req).expect("should read EC context"); - - let services = crate::platform::test_support::noop_services(); + EcContext::new_for_test(None, crate::consent::ConsentContext::default()); + let services = noop_services(); // Call handle_proxy (uses futures executor in test environment) let result = futures::executor::block_on(registry.handle_proxy(ProxyDispatchInput { @@ -1337,7 +1410,7 @@ mod tests { let response = response.unwrap(); assert!( - response.get_header("x-echo-ts-ec").is_none(), + response.headers().get("x-echo-ts-ec").is_none(), "should not have x-ts-ec header on integration request" ); } @@ -1355,10 +1428,17 @@ mod tests { )]; let registry = IntegrationRegistry::from_routes(routes); - let mut req = Request::get("https://test-publisher.com/integrations/test/ec"); - req.set_header(HEADER_X_TS_EC, "evil;injected"); + let mut req = Request::builder() + .method(Method::GET) + .uri("https://test-publisher.com/integrations/test/ec") + .body(EdgeBody::empty()) + .expect("should build request"); + req.headers_mut().insert( + HEADER_X_TS_EC.clone(), + HeaderValue::from_static("evil;injected"), + ); let mut ec_context = - EcContext::read_from_request(&settings, &req).expect("should read EC context"); + EcContext::new_for_test(None, crate::consent::ConsentContext::default()); let services = crate::platform::test_support::noop_services(); let result = futures::executor::block_on(registry.handle_proxy(ProxyDispatchInput { @@ -1375,7 +1455,7 @@ mod tests { let response = result.expect("handler should succeed"); assert!( - response.get_header("x-echo-ts-ec").is_none(), + response.headers().get("x-echo-ts-ec").is_none(), "should not reflect the tampered request header to the integration" ); } @@ -1391,13 +1471,22 @@ mod tests { let registry = IntegrationRegistry::from_routes(routes); - let mut req = Request::get("https://test.example.com/integrations/test/ec"); - // Pre-existing cookie with valid EC ID format, but no geo data → - // Unknown jurisdiction → consent denied. - let valid_ec_id = format!("{}.AbCd12", "a".repeat(64)); - req.set_header(header::COOKIE, format!("ts-ec={valid_ec_id}")); + let mut req = Request::builder() + .method(Method::GET) + .uri("https://test.example.com/integrations/test/ec") + .body(EdgeBody::empty()) + .expect("should build request"); + req.headers_mut().insert( + header::COOKIE, + HeaderValue::from_str(&format!( + "{}={}", + COOKIE_TS_EC, + crate::test_support::tests::VALID_SYNTHETIC_ID + )) + .expect("should build Cookie header"), + ); let mut ec_context = - EcContext::read_from_request(&settings, &req).expect("should read EC context"); + EcContext::new_for_test(None, crate::consent::ConsentContext::default()); let services = crate::platform::test_support::noop_services(); let result = futures::executor::block_on(registry.handle_proxy(ProxyDispatchInput { @@ -1414,7 +1503,7 @@ mod tests { let response = result.expect("proxy handle should succeed"); assert!( - response.get_header("x-echo-ts-ec").is_none(), + response.headers().get("x-echo-ts-ec").is_none(), "should not set x-ts-ec on integration request" ); } @@ -1432,13 +1521,17 @@ mod tests { )]; let registry = IntegrationRegistry::from_routes(routes); - let valid_ec_id = format!("{}.CkEc1", "a".repeat(64)); - let mut req = - Request::post("https://test-publisher.com/integrations/test/ec").with_body("test body"); - req.set_header(header::COOKIE, format!("ts-ec={valid_ec_id}")); - req.set_header("x-ts-ec", format!("{}.HdrEc1", "b".repeat(64))); + let mut req = Request::builder() + .method(Method::POST) + .uri("https://test-publisher.com/integrations/test/ec") + .body(EdgeBody::from("test body")) + .expect("should build POST request"); + req.headers_mut().insert( + HEADER_X_TS_EC.clone(), + HeaderValue::from_static("some-ec-value"), + ); let mut ec_context = - EcContext::read_from_request(&settings, &req).expect("should read EC context"); + EcContext::new_for_test(None, crate::consent::ConsentContext::default()); let services = crate::platform::test_support::noop_services(); let result = futures::executor::block_on(registry.handle_proxy(ProxyDispatchInput { @@ -1457,7 +1550,7 @@ mod tests { let response = response.unwrap(); assert!( - response.get_header("x-echo-ts-ec").is_none(), + response.headers().get("x-echo-ts-ec").is_none(), "POST integration request should not include x-ts-ec" ); } diff --git a/crates/trusted-server-core/src/integrations/sourcepoint.rs b/crates/trusted-server-core/src/integrations/sourcepoint.rs index 4d6dac87..9325d84b 100644 --- a/crates/trusted-server-core/src/integrations/sourcepoint.rs +++ b/crates/trusted-server-core/src/integrations/sourcepoint.rs @@ -22,9 +22,10 @@ use std::net::IpAddr; use std::sync::{Arc, LazyLock}; use async_trait::async_trait; +use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; -use fastly::http::{header, Method, StatusCode}; -use fastly::{Request, Response}; +use http::header::{self, HeaderValue}; +use http::{Method, Request, Response, StatusCode}; use regex::Regex; use serde::Deserialize; use url::Url; @@ -33,11 +34,12 @@ use validator::{Validate, ValidationError}; use crate::backend::BackendConfig; use crate::error::TrustedServerError; use crate::integrations::{ - AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, - IntegrationEndpoint, IntegrationHeadInjector, IntegrationHtmlContext, IntegrationProxy, - IntegrationRegistration, + collect_body_bounded, collect_response_bounded, AttributeRewriteAction, + IntegrationAttributeContext, IntegrationAttributeRewriter, IntegrationEndpoint, + IntegrationHeadInjector, IntegrationHtmlContext, IntegrationProxy, IntegrationRegistration, + INTEGRATION_MAX_BODY_BYTES, }; -use crate::platform::RuntimeServices; +use crate::platform::{PlatformHttpRequest, RuntimeServices}; use crate::settings::{IntegrationConfig, Settings}; const SOURCEPOINT_INTEGRATION_ID: &str = "sourcepoint"; @@ -309,11 +311,13 @@ impl SourcepointIntegration { fn copy_headers( &self, client_ip: Option, - original_req: &Request, - proxy_req: &mut Request, + original_req: &Request, + proxy_req: &mut Request, ) -> bool { if let Some(client_ip) = client_ip { - proxy_req.set_header("X-Forwarded-For", client_ip.to_string()); + if let Ok(val) = HeaderValue::from_str(&client_ip.to_string()) { + proxy_req.headers_mut().insert("x-forwarded-for", val); + } } // Accept-Encoding is deliberately omitted here and handled in the @@ -332,22 +336,27 @@ impl SourcepointIntegration { header::HeaderName::from_static("access-control-request-method"), header::HeaderName::from_static("access-control-request-headers"), ] { - if let Some(value) = original_req.get_header(&header_name) { - proxy_req.set_header(&header_name, value); + if let Some(value) = original_req.headers().get(&header_name) { + proxy_req.headers_mut().insert(header_name, value.clone()); } } if let Some(filtered_cookie_header) = self.filtered_sourcepoint_cookie_header(original_req) { - proxy_req.set_header(header::COOKIE, &filtered_cookie_header); + if let Ok(val) = HeaderValue::from_str(&filtered_cookie_header) { + proxy_req.headers_mut().insert(header::COOKIE, val); + } return true; } false } - fn filtered_sourcepoint_cookie_header(&self, original_req: &Request) -> Option { - let cookie_header = original_req.get_header(header::COOKIE)?; + fn filtered_sourcepoint_cookie_header( + &self, + original_req: &Request, + ) -> Option { + let cookie_header = original_req.headers().get(header::COOKIE)?; let cookie_header = match cookie_header.to_str() { Ok(value) => value, Err(_) => { @@ -381,35 +390,39 @@ impl SourcepointIntegration { || self.config.auth_cookie_name.as_deref() == Some(cookie_name) } - fn response_sets_cookie(response: &Response) -> bool { - response.get_header(header::SET_COOKIE).is_some() + fn response_sets_cookie(response: &Response) -> bool { + response.headers().contains_key(header::SET_COOKIE) } - fn apply_cookie_safety(response: &mut Response) -> bool { + fn apply_cookie_safety(response: &mut Response) -> bool { if Self::response_sets_cookie(response) { - response.set_header(header::CACHE_CONTROL, "private, no-store"); + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("private, no-store"), + ); return true; } false } - fn apply_cache_headers(&self, response: &mut Response, forwarded_cookies: bool) { + fn apply_cache_headers(&self, response: &mut Response, forwarded_cookies: bool) { if Self::apply_cookie_safety(response) { return; } - if response.get_header(header::CACHE_CONTROL).is_none() - && response.get_status().is_success() + if response.headers().get(header::CACHE_CONTROL).is_none() && response.status().is_success() { - if forwarded_cookies { - response.set_header(header::CACHE_CONTROL, "private, max-age=0"); + let val = if forwarded_cookies { + HeaderValue::from_static("private, max-age=0") } else { - response.set_header( - header::CACHE_CONTROL, - format!("public, max-age={}", self.config.cache_ttl_seconds), - ); - } + HeaderValue::from_str(&format!( + "public, max-age={}", + self.config.cache_ttl_seconds + )) + .unwrap_or(HeaderValue::from_static("public")) + }; + response.headers_mut().insert(header::CACHE_CONTROL, val); } } @@ -495,22 +508,29 @@ impl SourcepointIntegration { } /// Returns `true` when the response `Content-Type` looks like JavaScript. - fn is_javascript_response(response: &Response) -> bool { + fn is_javascript_response(response: &Response) -> bool { response - .get_header_str(header::CONTENT_TYPE) + .headers() + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) .is_some_and(|ct| ct.contains("javascript") || ct.contains("ecmascript")) } - fn remove_vary_accept_encoding(response: &mut Response) { - let Some(vary) = response.get_header_str(header::VARY) else { - return; + fn remove_vary_accept_encoding(response: &mut Response) { + let vary_owned = match response + .headers() + .get(header::VARY) + .and_then(|v| v.to_str().ok()) + { + Some(v) => v.to_string(), + None => return, }; - if vary.trim() == "*" { + if vary_owned.trim() == "*" { return; } - let kept = vary + let kept = vary_owned .split(',') .map(str::trim) .filter(|value| !value.eq_ignore_ascii_case("accept-encoding")) @@ -519,15 +539,15 @@ impl SourcepointIntegration { .collect::>(); if kept.is_empty() { - response.remove_header(header::VARY); - } else { - response.set_header(header::VARY, kept.join(", ")); + response.headers_mut().remove(header::VARY); + } else if let Ok(val) = HeaderValue::from_str(&kept.join(", ")) { + response.headers_mut().insert(header::VARY, val); } } - fn rewrite_javascript_response(&self, response: &mut Response, rewritten: String) { - response.remove_header(header::CONTENT_ENCODING); - response.remove_header(header::CONTENT_LENGTH); + fn rewrite_javascript_response(&self, response: &mut Response, rewritten: String) { + response.headers_mut().remove(header::CONTENT_ENCODING); + response.headers_mut().remove(header::CONTENT_LENGTH); Self::remove_vary_accept_encoding(response); if !Self::apply_cookie_safety(response) { @@ -536,17 +556,19 @@ impl SourcepointIntegration { // regardless of what upstream sent. This intentionally diverges from the // passthrough path's `apply_cache_headers` (which only sets a default // when upstream omitted Cache-Control). - response.set_header( - header::CACHE_CONTROL, - format!("public, max-age={}", self.config.cache_ttl_seconds), - ); + if let Ok(val) = HeaderValue::from_str(&format!( + "public, max-age={}", + self.config.cache_ttl_seconds + )) { + response.headers_mut().insert(header::CACHE_CONTROL, val); + } } - response.set_header( + response.headers_mut().insert( header::CONTENT_TYPE, - "application/javascript; charset=utf-8", + HeaderValue::from_static("application/javascript; charset=utf-8"), ); - response.set_body(rewritten); + *response.body_mut() = EdgeBody::from(rewritten.into_bytes()); } } @@ -642,64 +664,97 @@ impl IntegrationProxy for SourcepointIntegration { async fn handle( &self, _settings: &Settings, - _services: &RuntimeServices, - req: Request, - ) -> Result> { - let path = req.get_path().to_string(); - let method = req.get_method().clone(); + services: &RuntimeServices, + req: Request, + ) -> Result, Report> { + let path = req.uri().path().to_string(); + let method = req.method().clone(); let target_path = Self::strip_cdn_prefix(&path).ok_or_else(|| { Report::new(Self::error(format!("Unknown Sourcepoint route: {path}"))) })?; let target_url = self - .build_target_url(target_path, req.get_query_str()) + .build_target_url(target_path, req.uri().query()) .change_context(Self::error("Failed to build Sourcepoint target URL"))?; log::info!("Sourcepoint: proxying {method} {path} → {target_url}"); - let mut proxy_req = Request::new(req.get_method().clone(), &target_url); - let forwarded_cookies = self.copy_headers(req.get_client_ip_addr(), &req, &mut proxy_req); + let (req_parts, req_body) = req.into_parts(); + + let request_body = if method == Method::POST { + let bytes = collect_body_bounded( + req_body, + INTEGRATION_MAX_BODY_BYTES, + SOURCEPOINT_INTEGRATION_ID, + ) + .await?; + EdgeBody::from(bytes) + } else { + EdgeBody::empty() + }; + + let mut proxy_req = http::Request::builder() + .method(method.clone()) + .uri(&target_url) + .body(request_body) + .change_context(Self::error("Failed to build Sourcepoint proxy request"))?; + + let source_req = http::Request::from_parts(req_parts, EdgeBody::empty()); + let forwarded_cookies = + self.copy_headers(services.client_info.client_ip, &source_req, &mut proxy_req); // Request uncompressed content only for paths that are likely // JavaScript (the files we need to regex-rewrite). All other CDN // responses (images, JSON API responses, CSS) keep the client's // original Accept-Encoding for efficiency. if self.config.rewrite_sdk && Self::is_likely_javascript_path(target_path) { - proxy_req.set_header(header::ACCEPT_ENCODING, "identity"); - } else if let Some(ae) = req.get_header(header::ACCEPT_ENCODING) { - proxy_req.set_header(header::ACCEPT_ENCODING, ae); + proxy_req.headers_mut().insert( + header::ACCEPT_ENCODING, + HeaderValue::from_static("identity"), + ); + } else if let Some(ae) = source_req.headers().get(header::ACCEPT_ENCODING) { + proxy_req + .headers_mut() + .insert(header::ACCEPT_ENCODING, ae.clone()); } - if matches!(method, Method::POST) { - if let Some(content_type) = req.get_header(header::CONTENT_TYPE) { - proxy_req.set_header(header::CONTENT_TYPE, content_type); + if method == Method::POST { + if let Some(content_type) = source_req.headers().get(header::CONTENT_TYPE) { + proxy_req + .headers_mut() + .insert(header::CONTENT_TYPE, content_type.clone()); } - proxy_req.set_body(req.into_body()); } let backend_name = BackendConfig::from_url(&self.config.cdn_origin, true) .change_context(Self::error("Failed to configure Sourcepoint backend"))?; - let mut response = proxy_req - .send(&backend_name) - .change_context(Self::error("Sourcepoint upstream request failed"))?; + let mut response = services + .http_client() + .send(PlatformHttpRequest::new(proxy_req, backend_name)) + .await + .change_context(Self::error("Sourcepoint upstream request failed"))? + .response; log::info!( "Sourcepoint: upstream responded with status {}", - response.get_status() + response.status() ); // Rewrite Location headers on redirect responses so the browser // follows the redirect through the first-party proxy instead of // leaking the CDN origin to the client. - if response.get_status().is_redirection() { + if response.status().is_redirection() { if let Some(location) = response - .get_header(header::LOCATION) + .headers() + .get(header::LOCATION) .and_then(|h| h.to_str().ok()) { if let Some(rewritten) = Self::rewrite_redirect_location(location, &target_url) { log::info!("Sourcepoint: rewrote redirect Location to {rewritten}"); - response.set_header(header::LOCATION, &rewritten); + if let Ok(val) = HeaderValue::from_str(&rewritten) { + response.headers_mut().insert(header::LOCATION, val); + } } } // Redirects without Set-Cookie intentionally keep upstream cache @@ -711,8 +766,8 @@ impl IntegrationProxy for SourcepointIntegration { // Rewrite CDN URLs inside JavaScript responses so that dynamically // loaded chunks and API calls route through the first-party proxy. - if matches!(method, Method::GET) - && response.get_status() == StatusCode::OK + if method == Method::GET + && response.status() == StatusCode::OK && self.config.rewrite_sdk && Self::is_javascript_response(&response) { @@ -721,7 +776,8 @@ impl IntegrationProxy for SourcepointIntegration { // Guard against unexpectedly large responses to avoid unbounded // memory consumption during rewriting. let content_length = response - .get_header(header::CONTENT_LENGTH) + .headers() + .get(header::CONTENT_LENGTH) .and_then(|v| v.to_str().ok()) .and_then(|s| s.parse::().ok()); @@ -746,7 +802,15 @@ impl IntegrationProxy for SourcepointIntegration { Some(_) => {} } - let body_bytes = response.take_body_bytes(); + let (resp_parts, resp_body) = response.into_parts(); + let body_bytes = collect_response_bounded( + resp_body, + MAX_REWRITE_BODY_SIZE as usize, + SOURCEPOINT_INTEGRATION_ID, + ) + .await?; + let mut response = http::Response::from_parts(resp_parts, EdgeBody::empty()); + let body = match String::from_utf8(body_bytes) { Ok(text) => text, Err(err) => { @@ -755,7 +819,7 @@ impl IntegrationProxy for SourcepointIntegration { at byte offset {}, passing through unmodified", err.utf8_error().valid_up_to() ); - response.set_body(err.into_bytes()); + *response.body_mut() = EdgeBody::from(err.into_bytes()); self.apply_cache_headers(&mut response, forwarded_cookies); return Ok(response); } @@ -870,7 +934,6 @@ mod tests { use super::*; use crate::integrations::{IntegrationDocumentState, IntegrationRegistry}; use crate::test_support::tests::create_test_settings; - use fastly::http::Method; use serde_json::json; fn config(enabled: bool) -> SourcepointConfig { @@ -1341,11 +1404,69 @@ mod tests { ); } + fn make_req(method: Method, url: &str) -> Request { + http::Request::builder() + .method(method) + .uri(url) + .body(EdgeBody::empty()) + .expect("should build test request") + } + + fn make_resp_with_status(status: StatusCode) -> Response { + http::Response::builder() + .status(status) + .body(EdgeBody::empty()) + .expect("should build test response") + } + + fn get_header_str( + resp: &Response, + name: impl http::header::AsHeaderName, + ) -> Option<&str> { + resp.headers().get(name).and_then(|v| v.to_str().ok()) + } + + fn get_req_header_str( + req: &Request, + name: impl http::header::AsHeaderName, + ) -> Option<&str> { + req.headers().get(name).and_then(|v| v.to_str().ok()) + } + + fn set_header( + resp: &mut Response, + name: impl http::header::IntoHeaderName, + value: &str, + ) { + resp.headers_mut().insert( + name, + HeaderValue::from_str(value).expect("should build header value"), + ); + } + + fn set_req_header( + req: &mut Request, + name: impl http::header::IntoHeaderName, + value: &str, + ) { + req.headers_mut().insert( + name, + HeaderValue::from_str(value).expect("should build header value"), + ); + } + + fn take_body_bytes(resp: Response) -> Vec { + match resp.into_body() { + EdgeBody::Once(b) => b.to_vec(), + EdgeBody::Stream(_) => vec![], + } + } + #[test] fn copy_headers_sets_x_forwarded_for_from_runtime_client_ip() { let integration = SourcepointIntegration::new(Arc::new(config(true))); - let original_req = Request::new(Method::GET, "https://publisher.example.com/sourcepoint"); - let mut proxy_req = Request::new(Method::GET, "https://cdn.privacy-mgmt.com/wrapper.js"); + let original_req = make_req(Method::GET, "https://publisher.example.com/sourcepoint"); + let mut proxy_req = make_req(Method::GET, "https://cdn.privacy-mgmt.com/wrapper.js"); let client_ip = "203.0.113.10".parse().expect("should parse test IP"); let forwarded_cookies = @@ -1356,7 +1477,7 @@ mod tests { "should report no forwarded cookies when request has none" ); assert_eq!( - proxy_req.get_header_str("X-Forwarded-For"), + get_req_header_str(&proxy_req, "x-forwarded-for"), Some("203.0.113.10"), "should forward platform-provided client IP" ); @@ -1366,21 +1487,24 @@ mod tests { fn copy_headers_forwards_preflight_headers() { let integration = SourcepointIntegration::new(Arc::new(config(true))); let mut original_req = - Request::new(Method::OPTIONS, "https://publisher.example.com/sourcepoint"); - original_req.set_header("Access-Control-Request-Method", "POST"); - original_req.set_header("Access-Control-Request-Headers", "Content-Type, X-Test"); - let mut proxy_req = - Request::new(Method::OPTIONS, "https://cdn.privacy-mgmt.com/wrapper.js"); + make_req(Method::OPTIONS, "https://publisher.example.com/sourcepoint"); + set_req_header(&mut original_req, "access-control-request-method", "POST"); + set_req_header( + &mut original_req, + "access-control-request-headers", + "Content-Type, X-Test", + ); + let mut proxy_req = make_req(Method::OPTIONS, "https://cdn.privacy-mgmt.com/wrapper.js"); integration.copy_headers(None, &original_req, &mut proxy_req); assert_eq!( - proxy_req.get_header_str("Access-Control-Request-Method"), + get_req_header_str(&proxy_req, "access-control-request-method"), Some("POST"), "should forward requested preflight method" ); assert_eq!( - proxy_req.get_header_str("Access-Control-Request-Headers"), + get_req_header_str(&proxy_req, "access-control-request-headers"), Some("Content-Type, X-Test"), "should forward requested preflight headers" ); @@ -1389,8 +1513,9 @@ mod tests { #[test] fn forwards_only_allowlisted_sourcepoint_cookies() { let integration = SourcepointIntegration::new(Arc::new(config(true))); - let mut req = Request::new(Method::GET, "https://publisher.example.com/sourcepoint"); - req.set_header( + let mut req = make_req(Method::GET, "https://publisher.example.com/sourcepoint"); + set_req_header( + &mut req, header::COOKIE, "consentUUID=uuid123; session_id=secret; euconsent-v2=tcf; _sp_su=1; theme=dark", ); @@ -1409,8 +1534,9 @@ mod tests { let mut cfg = config(true); cfg.auth_cookie_name = Some("sp_auth".to_string()); let integration = SourcepointIntegration::new(Arc::new(cfg)); - let mut req = Request::new(Method::GET, "https://publisher.example.com/sourcepoint"); - req.set_header( + let mut req = make_req(Method::GET, "https://publisher.example.com/sourcepoint"); + set_req_header( + &mut req, header::COOKIE, "sp_auth=token123; session_id=secret; consentUUID=uuid123", ); @@ -1427,8 +1553,8 @@ mod tests { #[test] fn drops_unrelated_publisher_cookies_from_upstream_request() { let integration = SourcepointIntegration::new(Arc::new(config(true))); - let mut req = Request::new(Method::GET, "https://publisher.example.com/sourcepoint"); - req.set_header(header::COOKIE, "session_id=secret; theme=dark"); + let mut req = make_req(Method::GET, "https://publisher.example.com/sourcepoint"); + set_req_header(&mut req, header::COOKIE, "session_id=secret; theme=dark"); assert_eq!( integration.filtered_sourcepoint_cookie_header(&req), @@ -1440,14 +1566,18 @@ mod tests { #[test] fn apply_cache_headers_uses_private_no_store_for_cookie_setting_responses() { let integration = SourcepointIntegration::new(Arc::new(config(true))); - let mut response = Response::from_status(StatusCode::OK); - response.set_header(header::SET_COOKIE, "consentUUID=uuid123; Path=/"); - response.set_header(header::CACHE_CONTROL, "public, max-age=3600"); + let mut response = make_resp_with_status(StatusCode::OK); + set_header( + &mut response, + header::SET_COOKIE, + "consentUUID=uuid123; Path=/", + ); + set_header(&mut response, header::CACHE_CONTROL, "public, max-age=3600"); integration.apply_cache_headers(&mut response, false); assert_eq!( - response.get_header_str(header::CACHE_CONTROL), + get_header_str(&response, header::CACHE_CONTROL), Some("private, no-store"), "should prevent public caching for cookie-setting responses" ); @@ -1456,12 +1586,12 @@ mod tests { #[test] fn apply_cache_headers_uses_private_policy_when_cookies_were_forwarded() { let integration = SourcepointIntegration::new(Arc::new(config(true))); - let mut response = Response::from_status(StatusCode::OK); + let mut response = make_resp_with_status(StatusCode::OK); integration.apply_cache_headers(&mut response, true); assert_eq!( - response.get_header_str(header::CACHE_CONTROL), + get_header_str(&response, header::CACHE_CONTROL), Some("private, max-age=0"), "should not publicly cache responses that may vary by forwarded Cookie" ); @@ -1470,13 +1600,13 @@ mod tests { #[test] fn apply_cache_headers_uses_public_default_without_forwarded_cookies() { let integration = SourcepointIntegration::new(Arc::new(config(true))); - let mut response = Response::from_status(StatusCode::OK); + let mut response = make_resp_with_status(StatusCode::OK); integration.apply_cache_headers(&mut response, false); let expected_cache_control = format!("public, max-age={}", default_cache_ttl()); assert_eq!( - response.get_header_str(header::CACHE_CONTROL), + get_header_str(&response, header::CACHE_CONTROL), Some(expected_cache_control.as_str()), "should keep public default caching for non-personalized responses" ); @@ -1485,36 +1615,40 @@ mod tests { #[test] fn rewrite_javascript_response_preserves_headers() { let integration = SourcepointIntegration::new(Arc::new(config(true))); - let mut response = Response::from_status(StatusCode::OK); + let mut response = make_resp_with_status(StatusCode::OK); - response.set_header(header::VARY, "Accept-Encoding, Origin"); - response.set_header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "https://example.com"); - response.set_header(header::CONTENT_ENCODING, "gzip"); - response.set_header(header::CONTENT_LENGTH, "4"); - response.set_header(header::CACHE_CONTROL, "no-store"); + set_header(&mut response, header::VARY, "Accept-Encoding, Origin"); + set_header( + &mut response, + header::ACCESS_CONTROL_ALLOW_ORIGIN, + "https://example.com", + ); + set_header(&mut response, header::CONTENT_ENCODING, "gzip"); + set_header(&mut response, header::CONTENT_LENGTH, "4"); + set_header(&mut response, header::CACHE_CONTROL, "no-store"); + *response.body_mut() = EdgeBody::from(b"payload".to_vec()); - response.set_body("payload"); integration.rewrite_javascript_response(&mut response, "rewritten".to_string()); - assert_eq!(response.get_status(), StatusCode::OK); + assert_eq!(response.status(), StatusCode::OK); assert_eq!( - response.get_header_str(header::CONTENT_TYPE), + get_header_str(&response, header::CONTENT_TYPE), Some("application/javascript; charset=utf-8") ); let expected_cache_control = format!("public, max-age={}", default_cache_ttl()); assert_eq!( - response.get_header_str(header::CACHE_CONTROL), + get_header_str(&response, header::CACHE_CONTROL), Some(expected_cache_control.as_str()) ); - assert_eq!(response.get_header_str(header::VARY), Some("Origin")); + assert_eq!(get_header_str(&response, header::VARY), Some("Origin")); assert_eq!( - response.get_header_str(header::ACCESS_CONTROL_ALLOW_ORIGIN), + get_header_str(&response, header::ACCESS_CONTROL_ALLOW_ORIGIN), Some("https://example.com") ); - assert!(response.get_header(header::CONTENT_ENCODING).is_none()); - assert!(response.get_header(header::CONTENT_LENGTH).is_none()); + assert!(response.headers().get(header::CONTENT_ENCODING).is_none()); + assert!(response.headers().get(header::CONTENT_LENGTH).is_none()); - let body = response.take_body_bytes(); + let body = take_body_bytes(response); assert_eq!( String::from_utf8(body).expect("should decode rewritten JavaScript response"), "rewritten" @@ -1524,20 +1658,24 @@ mod tests { #[test] fn rewrite_javascript_response_uses_private_no_store_for_cookie_setting_responses() { let integration = SourcepointIntegration::new(Arc::new(config(true))); - let mut response = Response::from_status(StatusCode::OK); - response.set_header(header::SET_COOKIE, "consentUUID=uuid123; Path=/"); - response.set_header(header::CACHE_CONTROL, "public, max-age=3600"); - response.set_body("payload"); + let mut response = make_resp_with_status(StatusCode::OK); + set_header( + &mut response, + header::SET_COOKIE, + "consentUUID=uuid123; Path=/", + ); + set_header(&mut response, header::CACHE_CONTROL, "public, max-age=3600"); + *response.body_mut() = EdgeBody::from(b"payload".to_vec()); integration.rewrite_javascript_response(&mut response, "rewritten".to_string()); assert_eq!( - response.get_header_str(header::CACHE_CONTROL), + get_header_str(&response, header::CACHE_CONTROL), Some("private, no-store"), "should avoid public caching when rewritten response still sets cookies" ); assert_eq!( - response.get_header_str(header::CONTENT_TYPE), + get_header_str(&response, header::CONTENT_TYPE), Some("application/javascript; charset=utf-8") ); } @@ -1545,14 +1683,14 @@ mod tests { #[test] fn rewrite_javascript_response_removes_exact_accept_encoding_vary() { let integration = SourcepointIntegration::new(Arc::new(config(true))); - let mut response = Response::from_status(StatusCode::OK); - response.set_header(header::VARY, "Accept-Encoding"); - response.set_body("payload"); + let mut response = make_resp_with_status(StatusCode::OK); + set_header(&mut response, header::VARY, "Accept-Encoding"); + *response.body_mut() = EdgeBody::from(b"payload".to_vec()); integration.rewrite_javascript_response(&mut response, "rewritten".to_string()); assert!( - response.get_header(header::VARY).is_none(), + response.headers().get(header::VARY).is_none(), "should remove stale Vary: Accept-Encoding after stripping content encoding" ); } diff --git a/crates/trusted-server-core/src/integrations/testlight.rs b/crates/trusted-server-core/src/integrations/testlight.rs index 161a8b7e..f4d1e326 100644 --- a/crates/trusted-server-core/src/integrations/testlight.rs +++ b/crates/trusted-server-core/src/integrations/testlight.rs @@ -1,19 +1,21 @@ use std::sync::Arc; use async_trait::async_trait; +use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; -use fastly::http::{header, HeaderValue}; -use fastly::{Request, Response}; +use http::header::{self, HeaderValue}; +use http::Response; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use validator::Validate; -use crate::compat; -use crate::ec::get_ec_id; +use crate::edge_cookie::get_ec_id; use crate::error::TrustedServerError; use crate::integrations::{ - AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, - IntegrationEndpoint, IntegrationProxy, IntegrationRegistration, + collect_body_bounded, collect_response_bounded, AttributeRewriteAction, + IntegrationAttributeContext, IntegrationAttributeRewriter, IntegrationEndpoint, + IntegrationProxy, IntegrationRegistration, INTEGRATION_MAX_BODY_BYTES, + UPSTREAM_RTB_MAX_RESPONSE_BYTES, }; use crate::platform::RuntimeServices; use crate::proxy::{proxy_request, ProxyRequestConfig}; @@ -44,7 +46,7 @@ impl IntegrationConfig for TestlightConfig { } } -#[derive(Debug, Deserialize, Serialize, Validate)] +#[derive(Debug, Default, Deserialize, Serialize, Validate)] struct TestlightRequestBody { #[validate(nested)] #[serde(default)] @@ -74,7 +76,7 @@ struct TestlightImp { extra: Map, } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Default, Deserialize, Serialize)] struct TestlightResponseBody { #[serde(flatten)] fields: Map, @@ -95,6 +97,38 @@ impl TestlightIntegration { message: message.into(), } } + + fn rewrite_request_body( + payload_bytes: &[u8], + synthetic_id: &str, + ) -> Result, Report> { + let mut payload = serde_json::from_slice::(payload_bytes) + .change_context(Self::error("Failed to parse request body"))?; + payload + .validate() + .map_err(|err| Report::new(Self::error(format!("Invalid request payload: {err}"))))?; + + payload.user.id = Some(synthetic_id.to_string()); + + serde_json::to_vec(&payload).change_context(Self::error("Failed to serialize request body")) + } + + fn rebuild_response( + mut parts: http::response::Parts, + body_bytes: Vec, + json_content_type: bool, + ) -> Result, Report> { + parts.headers.remove(header::CONTENT_LENGTH); + + if json_content_type { + parts.headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + } + + Ok(Response::from_parts(parts, EdgeBody::from(body_bytes))) + } } fn build( @@ -143,13 +177,13 @@ impl IntegrationProxy for TestlightIntegration { &self, settings: &Settings, services: &RuntimeServices, - mut req: Request, - ) -> Result> { - let mut payload = serde_json::from_slice::(&req.take_body_bytes()) - .change_context(Self::error("Failed to parse request body"))?; - payload - .validate() - .map_err(|err| Report::new(Self::error(format!("Invalid request payload: {err}"))))?; + req: http::Request, + ) -> Result, Report> { + let (parts, body) = req.into_parts(); + let payload_bytes = + collect_body_bounded(body, INTEGRATION_MAX_BODY_BYTES, TESTLIGHT_INTEGRATION_ID) + .await?; + let req = http::Request::from_parts(parts, EdgeBody::empty()); // Read EC ID from the ts-ec cookie forwarded by the client. // The registry strips x-ts-ec before dispatching, so only the cookie is available here. @@ -161,10 +195,7 @@ impl IntegrationProxy for TestlightIntegration { )) })?; - payload.user.id = Some(ec_id); - - let payload_bytes = serde_json::to_vec(&payload) - .change_context(Self::error("Failed to serialize request body"))?; + let payload_bytes = Self::rewrite_request_body(&payload_bytes, &ec_id)?; let mut proxy_config = ProxyRequestConfig::new(&self.config.endpoint); proxy_config.forward_ec_id = false; @@ -175,32 +206,29 @@ impl IntegrationProxy for TestlightIntegration { HeaderValue::from_static("application/json"), )); - let mut response = compat::to_fastly_response( - proxy_request( - settings, - compat::from_fastly_request(req), - proxy_config, - services, - ) + let response = proxy_request(settings, req, proxy_config, services) .await - .change_context(Self::error("Failed to contact upstream integration"))?, - ); + .change_context(Self::error("Failed to contact upstream integration"))?; + let (parts, body) = response.into_parts(); // Attempt to parse response into structured form for logging/future transforms. - let response_body = response.take_body_bytes(); + let response_body = collect_response_bounded( + body, + UPSTREAM_RTB_MAX_RESPONSE_BYTES, + TESTLIGHT_INTEGRATION_ID, + ) + .await?; match serde_json::from_slice::(&response_body) { Ok(body) => { - response - .set_body_json(&body) + let response_body = serde_json::to_vec(&body) .change_context(Self::error("Failed to serialize integration response body"))?; + Self::rebuild_response(parts, response_body, true) } Err(_) => { // Preserve original body if the integration responded with non-JSON content. - response.set_body(response_body); + Self::rebuild_response(parts, response_body, false) } } - - Ok(response) } } @@ -246,28 +274,15 @@ fn default_enabled() -> bool { false } -impl Default for TestlightRequestBody { - fn default() -> Self { - Self { - user: TestlightUserSection::default(), - imp: Vec::new(), - extra: Map::new(), - } - } -} - -impl Default for TestlightResponseBody { - fn default() -> Self { - Self { fields: Map::new() } - } -} - #[cfg(test)] mod tests { use super::*; - use crate::{test_support::tests::create_test_settings, tsjs}; - use fastly::http::Method; + use crate::platform::test_support::{build_services_with_http_client, StubHttpClient}; + use crate::test_support::tests::{create_test_settings, VALID_SYNTHETIC_ID}; + use crate::tsjs; + use http::Method; use serde_json::json; + use std::sync::Arc; #[test] fn build_requires_config() { @@ -359,4 +374,95 @@ mod tests { "Integration should register POST /integrations/testlight/auction" ); } + + #[test] + fn rewrite_request_body_injects_synthetic_id_without_fastly_types() { + let payload = br#"{"imp":[{"id":"slot-1"}]}"#; + + let rewritten = TestlightIntegration::rewrite_request_body(payload, "abc123.XyZ789") + .expect("should rewrite Testlight payload"); + let rewritten_json: serde_json::Value = + serde_json::from_slice(&rewritten).expect("should parse rewritten payload"); + + assert_eq!( + rewritten_json["user"]["id"], "abc123.XyZ789", + "should inject the synthetic ID into the Testlight user payload" + ); + } + + #[test] + fn rebuild_response_drops_stale_content_length_when_body_changes() { + let response = http::Response::builder() + .status(http::StatusCode::OK) + .header(header::CONTENT_LENGTH, "99") + .body(EdgeBody::from(br#"{ "ok" : true }"#.to_vec())) + .expect("should build Testlight response"); + let (parts, _) = response.into_parts(); + + let rebuilt = + TestlightIntegration::rebuild_response(parts, br#"{"ok":true}"#.to_vec(), true) + .expect("should rebuild Testlight response"); + + assert!( + rebuilt.headers().get(header::CONTENT_LENGTH).is_none(), + "should drop stale Content-Length when rebuilding the response body" + ); + assert_eq!( + rebuilt + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("application/json"), + "should normalize JSON responses to application/json" + ); + } + + #[tokio::test] + async fn handle_uses_platform_http_client_with_http_request() { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, br#"{"ok":true}"#.to_vec()); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let settings = create_test_settings(); + let integration = TestlightIntegration::new(TestlightConfig { + enabled: true, + endpoint: "https://example.com/openrtb".to_string(), + timeout_ms: 1000, + shim_src: tsjs::tsjs_unified_script_src(), + rewrite_scripts: true, + }); + let mut req = http::Request::builder() + .method(Method::POST) + .uri("https://edge.example.com/integrations/testlight/auction") + .body(EdgeBody::from(br#"{"imp":[{"id":"slot-1"}]}"#.to_vec())) + .expect("should build request"); + req.headers_mut().insert( + crate::constants::HEADER_X_TS_EC.clone(), + http::HeaderValue::from_static(VALID_SYNTHETIC_ID), + ); + + let response = integration + .handle(&settings, &services, req) + .await + .expect("should proxy Testlight request"); + + assert_eq!( + response.status(), + http::StatusCode::OK, + "should return stubbed upstream status" + ); + assert_eq!( + stub.recorded_backend_names(), + vec!["stub-backend".to_string()], + "should route outbound request through PlatformHttpClient" + ); + let response_json: serde_json::Value = + serde_json::from_slice(&response.into_body().into_bytes()) + .expect("should parse JSON response"); + assert_eq!( + response_json["ok"], true, + "should preserve the upstream JSON response body" + ); + } } diff --git a/crates/trusted-server-core/src/platform/http.rs b/crates/trusted-server-core/src/platform/http.rs index b6efe1b4..cdfc9ae5 100644 --- a/crates/trusted-server-core/src/platform/http.rs +++ b/crates/trusted-server-core/src/platform/http.rs @@ -145,7 +145,16 @@ pub struct PlatformSelectResult { /// Completed response, or the error returned by the ready request. pub ready: Result>, /// Requests still in flight after the ready result is removed. + /// + /// Note: entries in this list may have `backend_name == None` on some + /// adapters (e.g., Fastly), because the adapter cannot preserve input + /// identifiers across the platform `select()` call. Orchestrators must + /// not rely on `backend_name()` being set on remaining entries. pub remaining: Vec, + /// Backend name of the request that became ready with an error, when + /// `ready` is `Err`. `None` on the success path and when the adapter + /// cannot identify the failed backend. + pub failed_backend_name: Option, } /// Outbound HTTP client abstraction. diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index 1dec9c60..cca55a8e 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -216,6 +216,8 @@ pub(crate) struct StubHttpClient { responses: Mutex)>>, // Headers captured per send call, stored as (name, value) string pairs. request_headers: Mutex>>, + // Queued select() errors — each pop makes the next select() return ready: Err. + select_errors: Mutex>, } impl StubHttpClient { @@ -224,6 +226,7 @@ impl StubHttpClient { calls: Mutex::new(Vec::new()), responses: Mutex::new(VecDeque::new()), request_headers: Mutex::new(Vec::new()), + select_errors: Mutex::new(VecDeque::new()), } } @@ -235,6 +238,16 @@ impl StubHttpClient { .push_back((status, body)); } + /// Inject a `select()` error: the next call to `select()` will return + /// `ready: Err(...)` with the failed request's backend name in + /// `failed_backend_name`. The corresponding queued response is consumed. + pub fn push_select_error(&self) { + self.select_errors + .lock() + .expect("should lock select_errors") + .push_back(()); + } + /// Return backend names recorded across all `send` calls, in order. pub fn recorded_backend_names(&self) -> Vec { self.calls.lock().expect("should lock calls").clone() @@ -356,16 +369,47 @@ impl PlatformHttpClient for StubHttpClient { .attach("unexpected inner type in StubHttpClient::select") })?; + let ready_backend_name = stub.backend_name.clone(); + + // Strip backend names from remaining to match Fastly production behavior: + // Fastly's select() rebuilds remaining with PlatformPendingRequest::new() + // (no backend_name) — orchestrators must not rely on names being set. + let remaining: Vec = pending_requests + .into_iter() + .map(|r| match r.downcast::() { + Ok(inner) => PlatformPendingRequest::new(inner), + Err(r) => r, + }) + .collect(); + + let should_error = self + .select_errors + .lock() + .expect("should lock select_errors") + .pop_front() + .is_some(); + + if should_error { + return Ok(PlatformSelectResult { + ready: Err(Report::new(PlatformError::HttpClient).attach(format!( + "injected select error for backend '{ready_backend_name}'" + ))), + remaining, + failed_backend_name: Some(ready_backend_name), + }); + } + let edge_response = edgezero_core::http::response_builder() .status(stub.status) .body(edgezero_core::body::Body::from(stub.body)) .change_context(PlatformError::HttpClient)?; - let ready = Ok(PlatformResponse::new(edge_response).with_backend_name(stub.backend_name)); + let ready = Ok(PlatformResponse::new(edge_response).with_backend_name(ready_backend_name)); Ok(PlatformSelectResult { ready, - remaining: pending_requests, + remaining, + failed_backend_name: None, }) } } @@ -591,8 +635,8 @@ mod tests { ); assert_eq!( result.remaining[0].backend_name(), - Some("backend-b"), - "should preserve backend name on remaining request" + None, + "should strip backend name from remaining (matches Fastly production behavior)" ); let names = stub.recorded_backend_names(); diff --git a/crates/trusted-server-core/src/test_support.rs b/crates/trusted-server-core/src/test_support.rs index 25c918b2..6a0ccff3 100644 --- a/crates/trusted-server-core/src/test_support.rs +++ b/crates/trusted-server-core/src/test_support.rs @@ -49,4 +49,8 @@ pub mod tests { let toml_str = crate_test_settings_str(); Settings::from_toml(&toml_str).expect("Invalid config") } + + /// A valid EC ID in `{64-hex}.{6-alnum}` format for use in tests. + pub const VALID_SYNTHETIC_ID: &str = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.Ab1234"; }