diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index a14338ba..fef7f629 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -1,6 +1,10 @@ +use edgezero_core::body::Body as EdgeBody; +use edgezero_core::http::{ + header, HeaderName, HeaderValue, Method, Request as HttpRequest, Response as HttpResponse, +}; use error_stack::Report; -use fastly::http::Method; -use fastly::{Request, Response}; +use fastly::http::Method as FastlyMethod; +use fastly::{Request as FastlyRequest, Response as FastlyResponse}; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{build_orchestrator, AuctionOrchestrator}; @@ -10,7 +14,7 @@ use trusted_server_core::constants::{ ENV_FASTLY_IS_STAGING, ENV_FASTLY_SERVICE_VERSION, HEADER_X_GEO_INFO_AVAILABLE, HEADER_X_TS_ENV, HEADER_X_TS_VERSION, }; -use trusted_server_core::error::TrustedServerError; +use trusted_server_core::error::{IntoHttpResponse, TrustedServerError}; use trusted_server_core::geo::GeoInfo; use trusted_server_core::integrations::IntegrationRegistry; use trusted_server_core::platform::RuntimeServices; @@ -41,20 +45,17 @@ use crate::platform::{build_runtime_services, open_kv_store, UnavailableKvStore} /// Entry point for the Fastly Compute program. /// -/// Uses an undecorated `main()` with `Request::from_client()` instead of -/// `#[fastly::main]` so we can call `stream_to_client()` or `send_to_client()` -/// explicitly. `#[fastly::main]` is syntactic sugar that auto-calls -/// `send_to_client()` on the returned `Response`, which is incompatible with -/// streaming. +/// Uses an undecorated `main()` with `FastlyRequest::from_client()` instead of +/// `#[fastly::main]` so we can call `send_to_client()` explicitly when needed. fn main() { init_logger(); - let req = Request::from_client(); + let mut req = FastlyRequest::from_client(); // Keep the health probe independent from settings loading and routing so // readiness checks still get a cheap liveness response during startup. - if req.get_method() == Method::GET && req.get_path() == "/health" { - Response::from_status(200) + if req.get_method() == FastlyMethod::GET && req.get_path() == "/health" { + FastlyResponse::from_status(200) .with_body_text_plain("ok") .send_to_client(); return; @@ -89,24 +90,39 @@ fn main() { } }; - // Start with an unavailable KV slot. Consent-dependent routes lazily - // replace it with the configured store at dispatch time so unrelated - // routes stay available when consent persistence is misconfigured. let kv_store = std::sync::Arc::new(UnavailableKvStore) as std::sync::Arc; + // Strip client-spoofable forwarded headers at the edge before building + // any request-derived context or converting to the core HTTP types. + compat::sanitize_fastly_forwarded_headers(&mut req); + let runtime_services = build_runtime_services(&req, kv_store); + let http_req = compat::from_fastly_request(req); - // route_request may send the response directly (streaming path) or - // return it for us to send (buffered path). - if let Some(response) = futures::executor::block_on(route_request( + let mut response = futures::executor::block_on(route_request( &settings, &orchestrator, &integration_registry, &runtime_services, - req, - )) { - response.send_to_client(); - } + http_req, + )) + .unwrap_or_else(|e| http_error_response(&e)); + + let geo_info = if response.status() == edgezero_core::http::StatusCode::UNAUTHORIZED { + None + } else { + runtime_services + .geo() + .lookup(runtime_services.client_info().client_ip) + .unwrap_or_else(|e| { + log::warn!("geo lookup failed: {e}"); + None + }) + }; + + finalize_response(&settings, geo_info.as_ref(), &mut response); + + compat::to_fastly_response(response).send_to_client(); } async fn route_request( @@ -114,48 +130,23 @@ async fn route_request( orchestrator: &AuctionOrchestrator, integration_registry: &IntegrationRegistry, runtime_services: &RuntimeServices, - mut req: Request, -) -> Option { - // Strip client-spoofable forwarded headers at the edge. - // On Fastly this service IS the first proxy — these headers from - // clients are untrusted and can hijack URL rewriting (see #409). - compat::sanitize_fastly_forwarded_headers(&mut req); - - // Look up geo info via the platform abstraction using the client IP - // already captured in RuntimeServices at the entry point. - let geo_info = runtime_services - .geo() - .lookup(runtime_services.client_info().client_ip) - .unwrap_or_else(|e| { - log::warn!("geo lookup failed: {e}"); - None - }); - + req: HttpRequest, +) -> Result> { // `get_settings()` should already have rejected invalid handler regexes. // Keep this fallback so manually-constructed or otherwise unprepared // settings still become an error response instead of panicking. - let auth_req = compat::from_fastly_headers_ref(&req); - match enforce_basic_auth(settings, &auth_req) { - Ok(Some(response)) => { - let mut response = compat::to_fastly_response(response); - finalize_response(settings, geo_info.as_ref(), &mut response); - return Some(response); - } + match enforce_basic_auth(settings, &req) { + Ok(Some(response)) => return Ok(response), Ok(None) => {} - Err(e) => { - log::error!("Failed to evaluate basic auth: {:?}", e); - let mut response = to_error_response(&e); - finalize_response(settings, geo_info.as_ref(), &mut response); - return Some(response); - } + Err(e) => return Err(e), } // Get path and method for routing - let path = req.get_path().to_string(); - let method = req.get_method().clone(); + let path = req.uri().path().to_string(); + let method = req.method().clone(); // Match known routes and handle them - let result = match (method, path.as_str()) { + match (method, path.as_str()) { // Serve the tsjs library (Method::GET, path) if path.starts_with("/static/tsjs=") => { handle_tsjs_dynamic(&req, integration_registry) @@ -201,14 +192,24 @@ async fn route_request( (Method::POST, "/first-party/proxy-rebuild") => { handle_first_party_proxy_rebuild(settings, runtime_services, req).await } - (m, path) if integration_registry.has_route(&m, path) => integration_registry - .handle_proxy(&m, path, settings, runtime_services, req) - .await - .unwrap_or_else(|| { - Err(Report::new(TrustedServerError::BadRequest { - message: format!("Unknown integration route: {path}"), - })) - }), + (m, path) if integration_registry.has_route(&m, path) => { + // TODO(PR13): migrate integration trait to http types here + integration_registry + .handle_proxy( + &m, + path, + settings, + runtime_services, + compat::to_fastly_request(req), + ) + .await + .unwrap_or_else(|| { + Err(Report::new(TrustedServerError::BadRequest { + message: format!("Unknown integration route: {path}"), + })) + }) + .map(compat::from_fastly_response) + } // No known route matched, proxy to publisher origin as fallback _ => { @@ -218,64 +219,48 @@ async fn route_request( ); match runtime_services_for_consent_route(settings, runtime_services) { - Ok(publisher_services) => { - match handle_publisher_request( - settings, - integration_registry, - &publisher_services, - req, - ) { - Ok(PublisherResponse::Stream { - mut response, - body, - params, - }) => { - // Streaming path: finalize headers, then stream body to client. - finalize_response(settings, geo_info.as_ref(), &mut response); - let mut streaming_body = response.stream_to_client(); - if let Err(e) = stream_publisher_body( - body, - &mut streaming_body, - ¶ms, - settings, - integration_registry, - ) { - // Headers already committed. Log and abort — client - // sees a truncated response. Standard proxy behavior. - log::error!("Streaming processing failed: {e:?}"); - drop(streaming_body); - } else if let Err(e) = streaming_body.finish() { - log::error!("Failed to finish streaming body: {e}"); - } - // Response already sent via stream_to_client() - return None; - } - Ok(PublisherResponse::PassThrough { mut response, body }) => { - // Binary pass-through: reattach body and send via send_to_client(). - // This preserves Content-Length and avoids chunked encoding overhead. - // Fastly streams the body from its internal buffer — no WASM - // memory buffering occurs. - response.set_body(body); - Ok(response) - } - Ok(PublisherResponse::Buffered(response)) => Ok(response), - Err(e) => { - log::error!("Failed to proxy to publisher origin: {:?}", e); - Err(e) - } - } - } + Ok(publisher_services) => handle_publisher_request( + settings, + integration_registry, + &publisher_services, + req, + ) + .await + .and_then(|pub_response| { + resolve_publisher_response(pub_response, settings, integration_registry) + }), Err(e) => Err(e), } } - }; - - // Convert any errors to HTTP error responses - let mut response = result.unwrap_or_else(|e| to_error_response(&e)); - - finalize_response(settings, geo_info.as_ref(), &mut response); + } +} - Some(response) +fn resolve_publisher_response( + publisher_response: PublisherResponse, + settings: &Settings, + integration_registry: &IntegrationRegistry, +) -> Result> { + match publisher_response { + PublisherResponse::Buffered(response) => Ok(response), + PublisherResponse::Stream { + mut response, + body, + params, + } => { + let mut output = Vec::new(); + stream_publisher_body(body, &mut output, ¶ms, settings, integration_registry)?; + response.headers_mut().insert( + header::CONTENT_LENGTH, + HeaderValue::from(output.len() as u64), + ); + *response.body_mut() = EdgeBody::from(output); + Ok(response) + } + PublisherResponse::PassThrough { mut response, body } => { + *response.body_mut() = body; + Ok(response) + } + } } fn runtime_services_for_consent_route( @@ -304,21 +289,48 @@ fn runtime_services_for_consent_route( /// Header precedence (last write wins): geo headers are set first, then /// version/staging, then operator-configured `settings.response_headers`. /// This means operators can intentionally override any managed header. -fn finalize_response(settings: &Settings, geo_info: Option<&GeoInfo>, response: &mut Response) { +fn finalize_response(settings: &Settings, geo_info: Option<&GeoInfo>, response: &mut HttpResponse) { if let Some(geo) = geo_info { geo.set_response_headers(response); } else { - response.set_header(HEADER_X_GEO_INFO_AVAILABLE, "false"); + response.headers_mut().insert( + HEADER_X_GEO_INFO_AVAILABLE, + HeaderValue::from_static("false"), + ); } if let Ok(v) = ::std::env::var(ENV_FASTLY_SERVICE_VERSION) { - response.set_header(HEADER_X_TS_VERSION, v); + if let Ok(value) = HeaderValue::from_str(&v) { + response.headers_mut().insert(HEADER_X_TS_VERSION, value); + } else { + log::warn!("Skipping invalid FASTLY_SERVICE_VERSION response header value"); + } } if ::std::env::var(ENV_FASTLY_IS_STAGING).as_deref() == Ok("1") { - response.set_header(HEADER_X_TS_ENV, "staging"); + response + .headers_mut() + .insert(HEADER_X_TS_ENV, HeaderValue::from_static("staging")); } for (key, value) in &settings.response_headers { - response.set_header(key, value); + let header_name = HeaderName::from_bytes(key.as_bytes()) + .expect("settings.response_headers validated at load time"); + let header_value = + HeaderValue::from_str(value).expect("settings.response_headers validated at load time"); + response.headers_mut().insert(header_name, header_value); } } + +fn http_error_response(report: &Report) -> HttpResponse { + let root_error = report.current_context(); + log::error!("Error occurred: {:?}", report); + + let mut response = + HttpResponse::new(EdgeBody::from(format!("{}\n", root_error.user_message()))); + *response.status_mut() = root_error.status_code(); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + response +} diff --git a/crates/trusted-server-adapter-fastly/src/route_tests.rs b/crates/trusted-server-adapter-fastly/src/route_tests.rs index 0fd0113f..aa1843a2 100644 --- a/crates/trusted-server-adapter-fastly/src/route_tests.rs +++ b/crates/trusted-server-adapter-fastly/src/route_tests.rs @@ -6,6 +6,8 @@ use error_stack::Report; use fastly::http::StatusCode; use fastly::Request; use trusted_server_core::auction::build_orchestrator; +use trusted_server_core::compat; +use trusted_server_core::error::IntoHttpResponse; use trusted_server_core::integrations::IntegrationRegistry; use trusted_server_core::platform::{ ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, @@ -185,66 +187,73 @@ fn configured_missing_consent_store_only_breaks_consent_routes() { let integration_registry = IntegrationRegistry::new(&settings).expect("should create integration registry"); - let discovery_req = Request::get("https://test.com/.well-known/trusted-server.json"); - let discovery_services = test_runtime_services(&discovery_req); + let discovery_fastly_req = Request::get("https://test.com/.well-known/trusted-server.json"); + let discovery_services = test_runtime_services(&discovery_fastly_req); let discovery_resp = futures::executor::block_on(route_request( &settings, &orchestrator, &integration_registry, &discovery_services, - discovery_req, + compat::from_fastly_request(discovery_fastly_req), )) .expect("should route discovery request"); assert_eq!( - discovery_resp.get_status(), + discovery_resp.status(), StatusCode::OK, "should keep discovery available when the consent store is unavailable" ); - let admin_req = Request::post("https://test.com/admin/keys/rotate"); - let admin_services = test_runtime_services(&admin_req); + let admin_fastly_req = Request::post("https://test.com/admin/keys/rotate"); + let admin_services = test_runtime_services(&admin_fastly_req); let admin_resp = futures::executor::block_on(route_request( &settings, &orchestrator, &integration_registry, &admin_services, - admin_req, + compat::from_fastly_request(admin_fastly_req), )) .expect("should route admin request"); assert_eq!( - admin_resp.get_status(), + admin_resp.status(), StatusCode::UNAUTHORIZED, "should keep admin auth behavior unchanged when the consent store is unavailable" ); - let auction_req = Request::post("https://test.com/auction").with_body(r#"{"adUnits":[]}"#); - let auction_services = test_runtime_services(&auction_req); - let auction_resp = futures::executor::block_on(route_request( + let auction_fastly_req = + Request::post("https://test.com/auction").with_body(r#"{"adUnits":[]}"#); + let auction_services = test_runtime_services(&auction_fastly_req); + let auction_result = futures::executor::block_on(route_request( &settings, &orchestrator, &integration_registry, &auction_services, - auction_req, - )) - .expect("should return an error response for auction requests"); + compat::from_fastly_request(auction_fastly_req), + )); + let auction_status = match auction_result { + Ok(resp) => resp.status(), + Err(ref e) => e.current_context().status_code(), + }; assert_eq!( - auction_resp.get_status(), + auction_status, StatusCode::SERVICE_UNAVAILABLE, "should fail auction requests when consent persistence is configured but unavailable" ); - let publisher_req = Request::get("https://test.com/articles/example"); - let publisher_services = test_runtime_services(&publisher_req); - let publisher_resp = futures::executor::block_on(route_request( + let publisher_fastly_req = Request::get("https://test.com/articles/example"); + let publisher_services = test_runtime_services(&publisher_fastly_req); + let publisher_result = futures::executor::block_on(route_request( &settings, &orchestrator, &integration_registry, &publisher_services, - publisher_req, - )) - .expect("should return an error response for publisher fallback"); + compat::from_fastly_request(publisher_fastly_req), + )); + let publisher_status = match publisher_result { + Ok(resp) => resp.status(), + Err(ref e) => e.current_context().status_code(), + }; assert_eq!( - publisher_resp.get_status(), + publisher_status, StatusCode::SERVICE_UNAVAILABLE, "should scope consent store failures to the consent-dependent routes" ); diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index df51a8fe..62d7e292 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -1,7 +1,8 @@ //! HTTP endpoint handlers for auction requests. +use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; -use fastly::{Request, Response}; +use http::{Request, Response}; use crate::auction::formats::AdRequest; use crate::compat; @@ -16,6 +17,8 @@ use super::formats::{convert_to_openrtb_response, convert_tsjs_to_auction_reques use super::types::AuctionContext; use super::AuctionOrchestrator; +const AUCTION_MAX_BODY_BYTES: usize = 65536; + /// Handle auction request from /auction endpoint. /// /// This is the main entry point for running header bidding auctions. @@ -33,35 +36,45 @@ pub async fn handle_auction( settings: &Settings, orchestrator: &AuctionOrchestrator, services: &RuntimeServices, - mut req: Request, -) -> Result> { + req: Request, +) -> Result, Report> { + let (parts, body) = req.into_parts(); + // Parse request body - let body: AdRequest = serde_json::from_slice(&req.take_body_bytes()).change_context( - TrustedServerError::Auction { + let body_bytes = body.into_bytes(); + if body_bytes.len() > AUCTION_MAX_BODY_BYTES { + return Err(Report::new(TrustedServerError::RequestTooLarge { + message: format!( + "auction payload {} exceeds limit of {}", + body_bytes.len(), + AUCTION_MAX_BODY_BYTES, + ), + })); + } + let body: AdRequest = + serde_json::from_slice(&body_bytes).change_context(TrustedServerError::Auction { message: "Failed to parse auction request body".to_string(), - }, - )?; + })?; log::info!( "Auction request received for {} ad units", body.ad_units.len() ); - let http_req = compat::from_fastly_headers_ref(&req); + let http_req = Request::from_parts(parts, EdgeBody::empty()); // Generate EC ID early so the consent pipeline can use it for // KV Store fallback/write operations. - let ec_id = get_or_generate_ec_id(settings, services, &req).change_context( + let ec_id = get_or_generate_ec_id(settings, services, &http_req).change_context( TrustedServerError::Auction { message: "Failed to generate EC ID".to_string(), }, )?; - // Extract consent from request cookies, headers, and geo. let cookie_jar = handle_request_cookies(&http_req)?; let geo = services .geo() - .lookup(services.client_info.client_ip) + .lookup(services.client_info().client_ip) .unwrap_or_else(|e| { log::warn!("geo lookup failed: {e}"); None @@ -84,17 +97,20 @@ pub async fn handle_auction( &body, settings, services, - &req, + &http_req, consent_context, &ec_id, geo, )?; + // Body already parsed above; provider context only needs request metadata. + let fastly_req = compat::to_fastly_request_ref(&http_req); + // Create auction context let context = AuctionContext { settings, - request: &req, - client_info: &services.client_info, + request: &fastly_req, + client_info: services.client_info(), timeout_ms: settings.auction.timeout_ms, provider_responses: None, services, @@ -118,3 +134,36 @@ pub async fn handle_auction( // Convert to OpenRTB response format with inline creative HTML convert_to_openrtb_response(&result, settings, &auction_request) } + +#[cfg(test)] +mod tests { + use edgezero_core::body::Body as EdgeBody; + use http::{Method, Request as HttpRequest, StatusCode}; + + use crate::auction::build_orchestrator; + use crate::error::IntoHttpResponse; + use crate::platform::test_support::noop_services; + use crate::test_support::tests::create_test_settings; + + use super::*; + + #[tokio::test] + async fn auction_rejects_oversized_body() { + let settings = create_test_settings(); + let orchestrator = build_orchestrator(&settings).expect("should build orchestrator"); + let oversized = vec![b'x'; AUCTION_MAX_BODY_BYTES + 1]; + let req = HttpRequest::builder() + .method(Method::POST) + .uri("https://test.com/auction") + .body(EdgeBody::from(oversized)) + .expect("should build request"); + let err = handle_auction(&settings, &orchestrator, &noop_services(), req) + .await + .expect_err("should reject oversized body"); + assert_eq!( + err.current_context().status_code(), + StatusCode::PAYLOAD_TOO_LARGE, + "should return 413 for auction body over limit" + ); + } +} diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index 5237921a..e3d3e075 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -4,9 +4,9 @@ //! - Parsing incoming tsjs/Prebid.js format requests //! - Converting internal auction results to `OpenRTB` 2.x responses +use edgezero_core::body::Body as EdgeBody; use error_stack::{ensure, Report, ResultExt}; -use fastly::http::{header, StatusCode}; -use fastly::{Request, Response}; +use http::{header, HeaderValue, Request, Response, StatusCode}; use serde::Deserialize; use serde_json::Value as JsonValue; use std::collections::HashMap; @@ -84,7 +84,7 @@ pub fn convert_tsjs_to_auction_request( body: &AdRequest, settings: &Settings, services: &RuntimeServices, - req: &Request, + req: &Request, consent: ConsentContext, ec_id: &str, geo: Option, @@ -138,8 +138,10 @@ pub fn convert_tsjs_to_auction_request( // Build device info with user-agent (always) and geo (if available) let device = Some(DeviceInfo { user_agent: req - .get_header_str("user-agent") - .map(std::string::ToString::to_string), + .headers() + .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()), geo, }); @@ -212,7 +214,7 @@ pub fn convert_to_openrtb_response( result: &OrchestrationResult, settings: &Settings, auction_request: &AuctionRequest, -) -> Result> { +) -> Result, Report> { // Build OpenRTB-style seatbid array let mut seatbids = Vec::with_capacity(result.winning_bids.len()); @@ -312,9 +314,170 @@ pub fn convert_to_openrtb_response( message: "Failed to serialize auction response".to_string(), })?; - Ok(Response::from_status(StatusCode::OK) - .with_header(header::CONTENT_TYPE, "application/json") - .with_header(HEADER_X_TS_EC, &auction_request.user.id) - .with_header(HEADER_X_TS_EC_FRESH, &auction_request.user.fresh_id) - .with_body(body_bytes)) + let mut response = Response::new(EdgeBody::from(body_bytes)); + *response.status_mut() = StatusCode::OK; + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + insert_response_header( + response.headers_mut(), + &HEADER_X_TS_EC, + auction_request.user.id.as_str(), + )?; + insert_response_header( + response.headers_mut(), + &HEADER_X_TS_EC_FRESH, + auction_request.user.fresh_id.as_str(), + )?; + + Ok(response) +} + +fn insert_response_header( + headers: &mut http::HeaderMap, + name: &http::HeaderName, + value: &str, +) -> Result<(), Report> { + let header_value = + HeaderValue::from_str(value).change_context(TrustedServerError::InvalidHeaderValue { + message: format!("Invalid response header value for {name}"), + })?; + headers.insert(name.clone(), header_value); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auction::{AuctionResponse, Bid}; + use crate::consent::ConsentContext; + use crate::platform::test_support::noop_services; + use crate::test_support::tests::{create_test_settings, VALID_SYNTHETIC_ID}; + use edgezero_core::body::Body as EdgeBody; + use http::{header, Method, Request as HttpRequest, StatusCode}; + + fn sample_ad_request() -> AdRequest { + AdRequest { + ad_units: vec![AdUnit { + code: "slot-1".to_string(), + media_types: Some(MediaTypes { + banner: Some(BannerUnit { + sizes: vec![vec![300, 250]], + }), + }), + bids: Some(vec![BidConfig { + bidder: "prebid".to_string(), + params: serde_json::json!({"placementId": "abc"}), + }]), + }], + config: None, + } + } + + fn sample_auction_request() -> AuctionRequest { + AuctionRequest { + id: "auction-123".to_string(), + slots: vec![AdSlot { + id: "slot-1".to_string(), + formats: vec![AdFormat { + media_type: MediaType::Banner, + width: 300, + height: 250, + }], + floor_price: None, + targeting: HashMap::new(), + bidders: HashMap::new(), + }], + publisher: PublisherInfo { + domain: "publisher.example".to_string(), + page_url: Some("https://publisher.example/page".to_string()), + }, + user: UserInfo { + id: VALID_SYNTHETIC_ID.to_string(), + fresh_id: "fresh-123".to_string(), + consent: Some(ConsentContext::default()), + }, + device: None, + site: Some(SiteInfo { + domain: "publisher.example".to_string(), + page: "https://publisher.example/page".to_string(), + }), + context: HashMap::new(), + } + } + + #[test] + fn convert_tsjs_to_auction_request_accepts_http_request() { + let settings = create_test_settings(); + let req = HttpRequest::builder() + .method(Method::POST) + .uri("https://publisher.example/auction") + .header(header::USER_AGENT, "test-agent") + .body(EdgeBody::empty()) + .expect("should build request"); + + let auction_request = convert_tsjs_to_auction_request( + &sample_ad_request(), + &settings, + &noop_services(), + &req, + ConsentContext::default(), + VALID_SYNTHETIC_ID, + None, + ) + .expect("should convert auction request"); + + assert_eq!(auction_request.slots.len(), 1, "should create one slot"); + assert_eq!( + auction_request.slots[0].id, "slot-1", + "should preserve slot code" + ); + } + + #[test] + fn convert_to_openrtb_response_returns_http_response_with_ec_headers() { + let settings = create_test_settings(); + let bid = Bid { + slot_id: "slot-1".to_string(), + price: Some(1.25), + currency: "USD".to_string(), + creative: Some("
creative
".to_string()), + adomain: Some(vec!["advertiser.example".to_string()]), + bidder: "prebid".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + metadata: HashMap::new(), + }; + let result = OrchestrationResult { + provider_responses: vec![AuctionResponse::success("prebid", vec![bid.clone()], 12)], + mediator_response: None, + winning_bids: HashMap::from([(String::from("slot-1"), bid)]), + total_time_ms: 12, + metadata: HashMap::new(), + }; + + let response = convert_to_openrtb_response(&result, &settings, &sample_auction_request()) + .expect("should convert to openrtb response"); + + assert_eq!(response.status(), StatusCode::OK, "should return 200 OK"); + assert_eq!( + response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("application/json"), + "should return json content type" + ); + assert_eq!( + response + .headers() + .get(&crate::constants::HEADER_X_TS_EC) + .and_then(|value| value.to_str().ok()), + Some(VALID_SYNTHETIC_ID), + "should include EC ID header" + ); + } } diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 9cbcd2b9..7c3ed6c0 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -5,9 +5,9 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::{Duration, Instant}; +use crate::compat::platform_response_to_fastly; use crate::error::TrustedServerError; use crate::platform::{PlatformPendingRequest, RuntimeServices}; -use crate::proxy::platform_response_to_fastly; use super::config::AuctionConfig; use super::provider::AuctionProvider; diff --git a/crates/trusted-server-core/src/compat.rs b/crates/trusted-server-core/src/compat.rs index 4c6d5372..24922226 100644 --- a/crates/trusted-server-core/src/compat.rs +++ b/crates/trusted-server-core/src/compat.rs @@ -6,10 +6,13 @@ //! # PR 15 removal target use edgezero_core::body::Body as EdgeBody; +use error_stack::Report; use fastly::http::header; use crate::constants::INTERNAL_HEADERS; +use crate::error::TrustedServerError; use crate::http_util::SPOOFABLE_FORWARDED_HEADERS; +use crate::platform::PlatformResponse; fn build_http_request(req: &fastly::Request, body: EdgeBody) -> http::Request { let uri: http::Uri = req.get_url_str().parse().unwrap_or_else(|_| { @@ -35,6 +38,20 @@ fn build_http_request(req: &fastly::Request, body: EdgeBody) -> http::Request`. +/// +/// # PR 15 removal target +/// +/// # Panics +/// +/// Does not panic in practice — URL parse failure falls back to `"/"` (logged +/// as a warning), and the subsequent `builder.body()` cannot fail given a valid +/// method and URI. Listed here only because clippy cannot prove it statically. +pub fn from_fastly_request(mut req: fastly::Request) -> http::Request { + let body = EdgeBody::from(req.take_body_bytes()); + build_http_request(&req, body) +} + /// Convert a borrowed `fastly::Request` into an `http::Request` for reading. /// /// Headers are copied; the body is empty. @@ -50,6 +67,64 @@ pub fn from_fastly_headers_ref(req: &fastly::Request) -> http::Request build_http_request(req, EdgeBody::empty()) } +/// Convert an `http::Request` into a `fastly::Request`. +/// +/// # PR 15 removal target +pub fn to_fastly_request(req: http::Request) -> fastly::Request { + let (parts, body) = req.into_parts(); + let mut fastly_req = fastly::Request::new(parts.method, parts.uri.to_string()); + for (name, value) in &parts.headers { + fastly_req.append_header(name.as_str(), value.as_bytes()); + } + + match body { + EdgeBody::Once(bytes) => { + if !bytes.is_empty() { + fastly_req.set_body(bytes.to_vec()); + } + } + EdgeBody::Stream(_) => { + log::warn!("streaming body in compat::to_fastly_request; body will be empty"); + } + } + + fastly_req +} + +/// Convert a borrowed `http::Request` into a `fastly::Request`. +/// +/// Headers, method, and URI are copied; the body is empty. +/// +/// # PR 15 removal target +pub fn to_fastly_request_ref(req: &http::Request) -> fastly::Request { + let mut fastly_req = fastly::Request::new(req.method().clone(), req.uri().to_string()); + for (name, value) in req.headers() { + fastly_req.append_header(name.as_str(), value.as_bytes()); + } + + fastly_req +} + +/// Convert a `fastly::Response` into an `http::Response`. +/// +/// # PR 15 removal target +/// +/// # Panics +/// +/// Panics if the copied Fastly response parts cannot form a valid +/// `http::Response`. +pub fn from_fastly_response(mut resp: fastly::Response) -> http::Response { + let status = resp.get_status(); + let mut builder = http::Response::builder().status(status); + for (name, value) in resp.get_headers() { + builder = builder.header(name.as_str(), value.as_bytes()); + } + + builder + .body(EdgeBody::from(resp.take_body_bytes())) + .expect("should build http response from fastly response") +} + /// Convert an `http::Response` into a `fastly::Response`. /// /// # PR 15 removal target @@ -172,6 +247,33 @@ pub fn expire_fastly_ec_cookie( ); } +/// Converts a [`PlatformResponse`] into a [`fastly::Response`]. +/// +/// # Errors +/// +/// - [`TrustedServerError::Proxy`] if `platform_resp` carries a streaming body, +/// which this Fastly-only conversion path cannot materialise. +pub(crate) fn platform_response_to_fastly( + platform_resp: PlatformResponse, +) -> Result> { + let (parts, body) = platform_resp.response.into_parts(); + let body_bytes = match body { + EdgeBody::Once(bytes) => bytes.to_vec(), + EdgeBody::Stream(_) => { + return Err(Report::new(TrustedServerError::Proxy { + message: "streaming platform response body is not supported by Fastly response conversion" + .to_string(), + })); + } + }; + let mut resp = fastly::Response::from_status(parts.status.as_u16()); + for (name, value) in parts.headers.iter() { + resp.append_header(name.as_str(), value.as_bytes()); + } + resp.set_body(body_bytes); + Ok(resp) +} + #[cfg(test)] mod tests { use super::*; @@ -234,6 +336,104 @@ mod tests { assert_once_body_eq(http_req.into_body(), b""); } + #[test] + fn from_fastly_request_copies_body() { + let mut fastly_req = + fastly::Request::new(fastly::http::Method::POST, "https://example.com/path"); + fastly_req.set_header("content-type", "application/json"); + fastly_req.set_body(r#"{"ok":true}"#); + + let http_req = from_fastly_request(fastly_req); + let (parts, body) = http_req.into_parts(); + + assert_eq!(parts.method, http::Method::POST, "should copy method"); + assert_eq!(parts.uri.path(), "/path", "should copy uri path"); + assert_eq!( + parts + .headers + .get("content-type") + .and_then(|v| v.to_str().ok()), + Some("application/json"), + "should copy headers" + ); + assert_once_body_eq(body, br#"{"ok":true}"#); + } + + #[test] + fn to_fastly_request_copies_headers_and_body() { + let http_req = http::Request::builder() + .method(http::Method::POST) + .uri("https://example.com/submit") + .header("x-custom", "value") + .body(EdgeBody::from(b"payload".as_ref())) + .expect("should build request"); + + let mut fastly_req = to_fastly_request(http_req); + + assert_eq!( + fastly_req.get_method(), + &fastly::http::Method::POST, + "should copy method" + ); + assert_eq!( + fastly_req + .get_header("x-custom") + .and_then(|v| v.to_str().ok()), + Some("value"), + "should copy headers" + ); + assert_eq!( + fastly_req.take_body_bytes().as_slice(), + b"payload", + "should copy body bytes" + ); + } + + #[test] + fn to_fastly_request_preserves_duplicate_headers() { + let http_req = http::Request::builder() + .method(http::Method::GET) + .uri("https://example.com/") + .header("x-custom", "first") + .header("x-custom", "second") + .body(EdgeBody::empty()) + .expect("should build request"); + + let fastly_req = to_fastly_request(http_req); + + let values: Vec<_> = fastly_req + .get_headers() + .filter(|(name, _)| name.as_str() == "x-custom") + .map(|(_, value)| value.to_str().expect("should be valid utf8")) + .collect(); + assert_eq!( + values, + vec!["first", "second"], + "should preserve duplicate headers" + ); + } + + #[test] + fn from_fastly_response_copies_status_headers_and_body() { + let mut fastly_resp = fastly::Response::from_status(202); + fastly_resp.set_header("content-type", "application/json"); + fastly_resp.set_body(r#"{"ok":true}"#); + + let http_resp = from_fastly_response(fastly_resp); + let (parts, body) = http_resp.into_parts(); + + assert_eq!(parts.status.as_u16(), 202, "should copy status"); + assert_eq!( + parts + .headers + .get("content-type") + .and_then(|v| v.to_str().ok()), + Some("application/json"), + "should copy headers" + ); + assert_once_body_eq(body, br#"{"ok":true}"#); + } + #[test] fn to_fastly_response_copies_status_and_headers() { let http_resp = http::Response::builder() @@ -251,6 +451,64 @@ mod tests { ); } + #[test] + fn to_fastly_request_ref_copies_method_uri_and_headers_without_body() { + let http_req = http::Request::builder() + .method(http::Method::POST) + .uri("https://example.com/path?q=1") + .header("x-custom", "value") + .body(EdgeBody::from(b"payload".as_ref())) + .expect("should build request"); + + let mut fastly_req = to_fastly_request_ref(&http_req); + + assert_eq!( + fastly_req.get_method(), + &fastly::http::Method::POST, + "should copy method" + ); + assert_eq!( + fastly_req.get_url_str(), + "https://example.com/path?q=1", + "should copy URI" + ); + assert_eq!( + fastly_req + .get_header("x-custom") + .and_then(|v| v.to_str().ok()), + Some("value"), + "should copy headers" + ); + assert!( + fastly_req.take_body_bytes().is_empty(), + "borrowed conversion should not copy body bytes" + ); + } + + #[test] + fn to_fastly_request_ref_preserves_duplicate_headers() { + let http_req = http::Request::builder() + .method(http::Method::GET) + .uri("https://example.com/") + .header("x-custom", "first") + .header("x-custom", "second") + .body(EdgeBody::empty()) + .expect("should build request"); + + let fastly_req = to_fastly_request_ref(&http_req); + + let values: Vec<_> = fastly_req + .get_headers() + .filter(|(name, _)| name.as_str() == "x-custom") + .map(|(_, value)| value.to_str().expect("should be valid utf8")) + .collect(); + assert_eq!( + values, + vec!["first", "second"], + "should preserve duplicate headers" + ); + } + #[test] fn sanitize_fastly_forwarded_headers_strips_spoofable() { let mut req = fastly::Request::new(fastly::http::Method::GET, "https://example.com"); @@ -391,4 +649,52 @@ mod tests { "should set expected expiry cookie" ); } + + #[test] + fn to_fastly_request_with_streaming_body_produces_empty_body() { + // Stream bodies cannot cross the compat boundary: the Fastly SDK has no + // streaming body API, so the shim drops the stream and logs a warning. + // This test pins that silent-drop behaviour so it cannot become + // accidentally load-bearing. (Removal target: PR 15.) + let body = EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::from_static( + b"data", + )])); + let http_req = http::Request::builder() + .method(http::Method::POST) + .uri("https://example.com/") + .body(body) + .expect("should build request"); + + let mut fastly_req = to_fastly_request(http_req); + + assert!( + fastly_req.take_body_bytes().is_empty(), + "streaming body should be silently dropped; compat shim produces empty body" + ); + } + + #[test] + fn to_fastly_response_with_streaming_body_produces_empty_body() { + // Same constraint as to_fastly_request: streaming bodies are dropped at + // the compat boundary. (Removal target: PR 15.) + let body = EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::from_static( + b"data", + )])); + let http_resp = http::Response::builder() + .status(200) + .body(body) + .expect("should build response"); + + let mut fastly_resp = to_fastly_response(http_resp); + + assert_eq!( + fastly_resp.get_status().as_u16(), + 200, + "should copy status code" + ); + assert!( + fastly_resp.take_body_bytes().is_empty(), + "streaming body should be silently dropped; compat shim produces empty body" + ); + } } diff --git a/crates/trusted-server-core/src/edge_cookie.rs b/crates/trusted-server-core/src/edge_cookie.rs index 565d9531..c1b45165 100644 --- a/crates/trusted-server-core/src/edge_cookie.rs +++ b/crates/trusted-server-core/src/edge_cookie.rs @@ -5,13 +5,13 @@ use std::net::IpAddr; +use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; -use fastly::Request; use hmac::{Hmac, Mac}; +use http::Request; use rand::Rng; use sha2::Sha256; -use crate::compat; use crate::constants::{COOKIE_TS_EC, HEADER_X_TS_EC}; use crate::cookies::{ec_id_has_only_allowed_chars, handle_request_cookies}; use crate::error::TrustedServerError; @@ -108,8 +108,12 @@ pub fn generate_ec_id( /// # Errors /// /// - [`TrustedServerError::InvalidHeaderValue`] if cookie parsing fails -pub fn get_ec_id(req: &Request) -> Result, Report> { - if let Some(ec_id) = req.get_header(HEADER_X_TS_EC).and_then(|h| h.to_str().ok()) { +pub fn get_ec_id(req: &Request) -> Result, Report> { + if let Some(ec_id) = req + .headers() + .get(HEADER_X_TS_EC) + .and_then(|h| h.to_str().ok()) + { if ec_id_has_only_allowed_chars(ec_id) { log::trace!("Using existing EC ID from header: {}", ec_id); return Ok(Some(ec_id.to_string())); @@ -117,8 +121,7 @@ pub fn get_ec_id(req: &Request) -> Result, Report { if let Some(cookie) = jar.get(COOKIE_TS_EC) { let value = cookie.value(); @@ -151,7 +154,7 @@ pub fn get_ec_id(req: &Request) -> Result, Report, ) -> Result> { if let Some(id) = get_ec_id(req)? { return Ok(id); @@ -166,7 +169,8 @@ pub fn get_or_generate_ec_id( #[cfg(test)] mod tests { use super::*; - use fastly::http::{HeaderName, HeaderValue}; + use edgezero_core::body::Body as EdgeBody; + use http::{header, HeaderName}; use std::net::{Ipv4Addr, Ipv6Addr}; use crate::platform::test_support::{noop_services, noop_services_with_client_ip}; @@ -202,16 +206,14 @@ mod tests { assert_eq!(normalize_ip(ipv6_a), "2001:db8:abcd:1::"); } - fn create_test_request(headers: Vec<(HeaderName, &str)>) -> Request { - let mut req = Request::new("GET", "http://example.com"); + fn create_test_request(headers: &[(HeaderName, &str)]) -> Request { + let mut builder = Request::builder().method("GET").uri("http://example.com"); for (key, value) in headers { - req.set_header( - key, - HeaderValue::from_str(value).expect("should create valid header value"), - ); + builder = builder.header(key, *value); } - - req + builder + .body(EdgeBody::empty()) + .expect("should build test request") } fn is_ec_id_format(value: &str) -> bool { @@ -309,7 +311,7 @@ mod tests { #[test] fn test_get_ec_id_with_header() { let settings = create_test_settings(); - let req = create_test_request(vec![(HEADER_X_TS_EC, "existing_ec_id")]); + let req = create_test_request(&[(HEADER_X_TS_EC, "existing_ec_id")]); let ec_id = get_ec_id(&req).expect("should get EC ID"); assert_eq!(ec_id, Some("existing_ec_id".to_string())); @@ -322,8 +324,8 @@ mod tests { #[test] fn test_get_ec_id_with_cookie() { let settings = create_test_settings(); - let req = create_test_request(vec![( - fastly::http::header::COOKIE, + let req = create_test_request(&[( + header::COOKIE, &format!("{}=existing_cookie_id", COOKIE_TS_EC), )]); @@ -337,7 +339,7 @@ mod tests { #[test] fn test_get_ec_id_none() { - let req = create_test_request(vec![]); + let req = create_test_request(&[]); let ec_id = get_ec_id(&req).expect("should handle missing ID"); assert!(ec_id.is_none()); } @@ -345,7 +347,7 @@ mod tests { #[test] fn test_get_or_generate_ec_id_generate_new() { let settings = create_test_settings(); - let req = create_test_request(vec![]); + let req = create_test_request(&[]); let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) .expect("should get or generate EC ID"); @@ -354,12 +356,9 @@ mod tests { #[test] fn test_get_ec_id_rejects_invalid_header_and_falls_back_to_cookie() { - let req = create_test_request(vec![ + let req = create_test_request(&[ (HEADER_X_TS_EC, "evil;injected"), - ( - fastly::http::header::COOKIE, - &format!("{}=valid_cookie_id", COOKIE_TS_EC), - ), + (header::COOKIE, &format!("{}=valid_cookie_id", COOKIE_TS_EC)), ]); let ec_id = get_ec_id(&req).expect("should handle invalid header gracefully"); @@ -373,7 +372,7 @@ mod tests { #[test] fn test_get_or_generate_ec_id_replaces_invalid_header() { let settings = create_test_settings(); - let req = create_test_request(vec![(HEADER_X_TS_EC, "evil;injected")]); + let req = create_test_request(&[(HEADER_X_TS_EC, "evil;injected")]); let ec_id = get_or_generate_ec_id(&settings, &noop_services(), &req) .expect("should generate fresh ID on invalid header"); @@ -389,8 +388,8 @@ mod tests { #[test] fn test_get_ec_id_rejects_invalid_cookie() { - let req = create_test_request(vec![( - fastly::http::header::COOKIE, + let req = create_test_request(&[( + header::COOKIE, &format!("{}=bad