diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 292b72bb..d30b360c 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::{Error, Request, Response}; +use fastly::http::Method as FastlyMethod; +use fastly::{Error, Request as FastlyRequest, Response as FastlyResponse}; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{build_orchestrator, AuctionOrchestrator}; @@ -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; @@ -35,13 +39,13 @@ use crate::error::to_error_response; use crate::platform::{build_runtime_services, open_kv_store, UnavailableKvStore}; #[fastly::main] -fn main(req: Request) -> Result { +fn main(mut req: FastlyRequest) -> Result { logging::init_logger(); // 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" { - return Ok(Response::from_status(200).with_body_text_plain("ok")); + if req.get_method() == FastlyMethod::GET && req.get_path() == "/health" { + return Ok(FastlyResponse::from_status(200).with_body_text_plain("ok")); } let settings = match get_settings() { @@ -85,15 +89,37 @@ fn main(req: Request) -> Result { 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); - futures::executor::block_on(route_request( + let mut response = futures::executor::block_on(route_request( &settings, &orchestrator, &integration_registry, &runtime_services, - req, + 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); + + Ok(compat::to_fastly_response(response)) } async fn route_request( @@ -101,48 +127,23 @@ async fn route_request( orchestrator: &AuctionOrchestrator, integration_registry: &IntegrationRegistry, runtime_services: &RuntimeServices, - mut req: Request, -) -> Result { - // 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 Ok(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 Ok(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) @@ -183,14 +184,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 _ => { @@ -199,22 +210,9 @@ async fn route_request( path ); - match handle_publisher_request(settings, integration_registry, runtime_services, req) { - Ok(response) => Ok(response), - Err(e) => { - log::error!("Failed to proxy to publisher origin: {:?}", e); - Err(e) - } - } + handle_publisher_request(settings, integration_registry, runtime_services, req).await } - }; - - // 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); - - Ok(response) + } } /// Applies all standard response headers: geo, version, staging, and configured headers. @@ -225,21 +223,48 @@ async fn route_request( /// 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-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 9293ceed..ea275a01 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; @@ -33,21 +34,22 @@ 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: AdRequest = + serde_json::from_slice(&body.into_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 synthetic ID early so the consent pipeline can use it for // KV Store fallback/write operations. @@ -57,11 +59,10 @@ pub async fn handle_auction( }, )?; - // 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 @@ -79,17 +80,20 @@ pub async fn handle_auction( &body, settings, services, - &req, + &http_req, consent_context, &synthetic_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, diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index 64ad65f5..37103e2e 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -4,16 +4,15 @@ //! - 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; use uuid::Uuid; use crate::auction::context::ContextValue; -use crate::compat; use crate::consent::ConsentContext; use crate::creative; use crate::error::TrustedServerError; @@ -84,16 +83,13 @@ pub fn convert_tsjs_to_auction_request( body: &AdRequest, settings: &Settings, services: &RuntimeServices, - req: &Request, + req: &Request, consent: ConsentContext, synthetic_id: &str, geo: Option, ) -> Result> { let synthetic_id = synthetic_id.to_owned(); - // TODO(PR 15): Remove this conversion once the auction hot path is migrated to http::Request. - // endpoints.rs already converts this, but we do it again here as a temporary bridge. - let http_req = compat::from_fastly_headers_ref(req); - let fresh_id = generate_synthetic_id(settings, services, &http_req).change_context( + let fresh_id = generate_synthetic_id(settings, services, req).change_context( TrustedServerError::Auction { message: "Failed to generate fresh ID".to_string(), }, @@ -142,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, }); @@ -216,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()); @@ -316,10 +314,175 @@ 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("X-Synthetic-ID", &auction_request.user.id) - .with_header("X-Synthetic-Fresh", &auction_request.user.fresh_id) - .with_header("X-Synthetic-Trusted-Server", &auction_request.user.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(), + "X-Synthetic-ID", + auction_request.user.id.as_str(), + )?; + insert_response_header( + response.headers_mut(), + "X-Synthetic-Fresh", + auction_request.user.fresh_id.as_str(), + )?; + insert_response_header( + response.headers_mut(), + "X-Synthetic-Trusted-Server", + auction_request.user.id.as_str(), + )?; + + Ok(response) +} + +fn insert_response_header( + headers: &mut http::HeaderMap, + name: &'static str, + 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, 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_synthetic_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("X-Synthetic-ID") + .and_then(|value| value.to_str().ok()), + Some(VALID_SYNTHETIC_ID), + "should include synthetic id header" + ); + } } diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index d5281c7a..077a8763 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -7,12 +7,38 @@ use std::time::{Duration, Instant}; use crate::error::TrustedServerError; use crate::platform::{PlatformPendingRequest, RuntimeServices}; -use crate::proxy::platform_response_to_fastly; use super::config::AuctionConfig; use super::provider::AuctionProvider; use super::types::{AuctionContext, AuctionRequest, AuctionResponse, Bid, BidStatus}; +// # PR 15 removal target +// +// Mirrors compat::to_fastly_response — both should stay in sync until PR 15 +// removes the compat layer entirely. +fn platform_response_to_fastly( + platform_resp: crate::platform::PlatformResponse, +) -> fastly::Response { + let (parts, body) = platform_resp.response.into_parts(); + debug_assert!( + matches!(&body, edgezero_core::body::Body::Once(_)), + "unexpected Body::Stream in platform response conversion: body will be empty" + ); + let body_bytes = match body { + edgezero_core::body::Body::Once(bytes) => bytes.to_vec(), + edgezero_core::body::Body::Stream(_) => { + log::warn!("streaming platform response body; body will be empty"); + vec![] + } + }; + 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); + resp +} + /// Compute the remaining time budget from a deadline. /// /// Returns the number of milliseconds left before `timeout_ms` is exceeded, diff --git a/crates/trusted-server-core/src/compat.rs b/crates/trusted-server-core/src/compat.rs index 1390f94d..e249e936 100644 --- a/crates/trusted-server-core/src/compat.rs +++ b/crates/trusted-server-core/src/compat.rs @@ -79,6 +79,20 @@ pub fn to_fastly_request(req: http::Request) -> fastly::Request { fastly_req } +/// Convert a borrowed `http::Request` into a `fastly::Request`. +/// +/// Headers, method, and URI are copied; the body is empty. +/// +/// # PR 15 removal target +pub fn to_fastly_request_ref(req: &http::Request) -> fastly::Request { + let mut fastly_req = fastly::Request::new(req.method().clone(), req.uri().to_string()); + for (name, value) in req.headers() { + fastly_req.set_header(name.as_str(), value.as_bytes()); + } + + fastly_req +} + /// Convert a `fastly::Response` into an `http::Response`. /// /// # PR 15 removal target @@ -391,6 +405,40 @@ 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 sanitize_fastly_forwarded_headers_strips_spoofable() { let mut req = fastly::Request::new(fastly::http::Method::GET, "https://example.com"); @@ -531,4 +579,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/error.rs b/crates/trusted-server-core/src/error.rs index ca9e1e81..8249c8c4 100644 --- a/crates/trusted-server-core/src/error.rs +++ b/crates/trusted-server-core/src/error.rs @@ -37,6 +37,10 @@ pub enum TrustedServerError { #[display("Invalid UTF-8 data: {message}")] InvalidUtf8 { message: String }, + /// Request payload exceeded maximum allowed size. + #[display("Request payload too large: {message}")] + RequestTooLarge { message: String }, + /// HTTP header value creation failed. #[display("Invalid HTTP header value: {message}")] InvalidHeaderValue { message: String }, @@ -107,6 +111,7 @@ impl IntoHttpResponse for TrustedServerError { Self::Prebid { .. } => StatusCode::BAD_GATEWAY, Self::Integration { .. } => StatusCode::BAD_GATEWAY, Self::Proxy { .. } => StatusCode::BAD_GATEWAY, + Self::RequestTooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE, Self::Forbidden { .. } => StatusCode::FORBIDDEN, Self::AllowlistViolation { .. } => StatusCode::FORBIDDEN, Self::SyntheticId { .. } => StatusCode::INTERNAL_SERVER_ERROR, diff --git a/crates/trusted-server-core/src/geo.rs b/crates/trusted-server-core/src/geo.rs index 3e1e13bc..cf0d8851 100644 --- a/crates/trusted-server-core/src/geo.rs +++ b/crates/trusted-server-core/src/geo.rs @@ -1,14 +1,15 @@ //! Geographic location utilities for the trusted server. //! -//! This module provides Fastly-specific helpers for extracting geographic -//! information from incoming requests and writing geo headers to responses. +//! This module provides a Fastly-to-core geo mapping helper and response-header +//! injection for the platform-neutral [`GeoInfo`] type. //! //! The [`GeoInfo`] data type is defined in [`crate::platform`] as platform- -//! neutral data; this module re-exports it and adds Fastly-coupled `impl` -//! blocks for construction and response header injection. +//! neutral data; this module re-exports it and adds helper methods for HTTP +//! response header injection. -use fastly::geo::{geo_lookup, Geo}; -use fastly::{Request, Response}; +use edgezero_core::body::Body as EdgeBody; +use fastly::geo::Geo; +use http::{HeaderValue, Response}; pub use crate::platform::GeoInfo; @@ -19,8 +20,8 @@ use crate::constants::{ /// Convert a Fastly [`Geo`] value into a platform-neutral [`GeoInfo`]. /// -/// Shared by `GeoInfo::from_request` (legacy) and `FastlyPlatformGeo::lookup` in -/// `trusted-server-adapter-fastly` so that field mapping is never duplicated. +/// Shared by `FastlyPlatformGeo::lookup` in `trusted-server-adapter-fastly` so +/// that field mapping is never duplicated. pub fn geo_from_fastly(geo: &Geo) -> GeoInfo { GeoInfo { city: geo.city().to_string(), @@ -34,51 +35,44 @@ pub fn geo_from_fastly(geo: &Geo) -> GeoInfo { } impl GeoInfo { - /// Creates a new `GeoInfo` from a request by performing a geo lookup. - /// - /// # Legacy - /// - /// This is a Fastly-coupled convenience method that predates the - /// `platform` abstraction. New code should use - /// `RuntimeServices::geo.lookup(client_info.client_ip)` instead, which - /// goes through [`crate::platform::PlatformGeo`] and does not require - /// direct access to the Fastly request. - /// - /// # Returns - /// - /// `Some(GeoInfo)` if geo data is available, `None` otherwise - /// - /// # Example - /// - /// ```ignore - /// if let Some(geo_info) = GeoInfo::from_request(&req) { - /// println!("User is in {} ({})", geo_info.city, geo_info.country); - /// } - /// ``` - #[deprecated(note = "Use RuntimeServices::geo().lookup() instead")] - pub fn from_request(req: &Request) -> Option { - req.get_client_ip_addr() - .and_then(geo_lookup) - .map(|geo| geo_from_fastly(&geo)) - } - /// Sets geo information headers on the response. /// /// Adds `x-geo-city`, `x-geo-country`, `x-geo-continent`, `x-geo-coordinates`, /// `x-geo-metro-code`, `x-geo-region` (when available), and /// `x-geo-info-available: true` to the given response. - pub fn set_response_headers(&self, response: &mut Response) { - response.set_header(HEADER_X_GEO_CITY, &self.city); - response.set_header(HEADER_X_GEO_COUNTRY, &self.country); - response.set_header(HEADER_X_GEO_CONTINENT, &self.continent); - response.set_header(HEADER_X_GEO_COORDINATES, self.coordinates_string()); + pub fn set_response_headers(&self, response: &mut Response) { + let headers = response.headers_mut(); + + insert_geo_header(headers, HEADER_X_GEO_CITY, &self.city); + insert_geo_header(headers, HEADER_X_GEO_COUNTRY, &self.country); + insert_geo_header(headers, HEADER_X_GEO_CONTINENT, &self.continent); + insert_geo_header( + headers, + HEADER_X_GEO_COORDINATES, + &self.coordinates_string(), + ); if self.has_metro_code() { - response.set_header(HEADER_X_GEO_METRO_CODE, self.metro_code.to_string()); + let metro_code = self.metro_code.to_string(); + insert_geo_header(headers, HEADER_X_GEO_METRO_CODE, &metro_code); } if let Some(ref region) = self.region { - response.set_header(HEADER_X_GEO_REGION, region); + insert_geo_header(headers, HEADER_X_GEO_REGION, region); + } + headers.insert( + HEADER_X_GEO_INFO_AVAILABLE, + HeaderValue::from_static("true"), + ); + } +} + +fn insert_geo_header(headers: &mut http::HeaderMap, name: http::header::HeaderName, value: &str) { + match HeaderValue::from_str(value) { + Ok(header_value) => { + headers.insert(name, header_value); + } + Err(_) => { + log::warn!("Skipping invalid geo header value for {}", name.as_str()); } - response.set_header(HEADER_X_GEO_INFO_AVAILABLE, "true"); } } @@ -118,7 +112,8 @@ pub fn is_gdpr_country(country_code: &str) -> bool { #[cfg(test)] mod tests { use super::*; - use fastly::Response; + use edgezero_core::body::Body as EdgeBody; + use http::Response as HttpResponse; fn sample_geo_info() -> GeoInfo { GeoInfo { @@ -132,16 +127,24 @@ mod tests { } } + fn build_response() -> HttpResponse { + HttpResponse::builder() + .status(http::StatusCode::OK) + .body(EdgeBody::empty()) + .expect("should build response") + } + #[test] fn set_response_headers_sets_all_geo_headers() { let geo = sample_geo_info(); - let mut response = Response::new(); + let mut response = build_response(); geo.set_response_headers(&mut response); assert_eq!( response - .get_header(HEADER_X_GEO_CITY) + .headers() + .get(HEADER_X_GEO_CITY) .expect("should have city header") .to_str() .expect("should be valid str"), @@ -150,7 +153,8 @@ mod tests { ); assert_eq!( response - .get_header(HEADER_X_GEO_COUNTRY) + .headers() + .get(HEADER_X_GEO_COUNTRY) .expect("should have country header") .to_str() .expect("should be valid str"), @@ -159,7 +163,8 @@ mod tests { ); assert_eq!( response - .get_header(HEADER_X_GEO_CONTINENT) + .headers() + .get(HEADER_X_GEO_CONTINENT) .expect("should have continent header") .to_str() .expect("should be valid str"), @@ -168,7 +173,8 @@ mod tests { ); assert_eq!( response - .get_header(HEADER_X_GEO_COORDINATES) + .headers() + .get(HEADER_X_GEO_COORDINATES) .expect("should have coordinates header") .to_str() .expect("should be valid str"), @@ -177,7 +183,8 @@ mod tests { ); assert_eq!( response - .get_header(HEADER_X_GEO_METRO_CODE) + .headers() + .get(HEADER_X_GEO_METRO_CODE) .expect("should have metro code header") .to_str() .expect("should be valid str"), @@ -186,7 +193,8 @@ mod tests { ); assert_eq!( response - .get_header(HEADER_X_GEO_REGION) + .headers() + .get(HEADER_X_GEO_REGION) .expect("should have region header") .to_str() .expect("should be valid str"), @@ -195,7 +203,8 @@ mod tests { ); assert_eq!( response - .get_header(HEADER_X_GEO_INFO_AVAILABLE) + .headers() + .get(HEADER_X_GEO_INFO_AVAILABLE) .expect("should have info available header") .to_str() .expect("should be valid str"), @@ -210,16 +219,16 @@ mod tests { metro_code: 0, ..sample_geo_info() }; - let mut response = Response::new(); + let mut response = build_response(); geo.set_response_headers(&mut response); assert!( - response.get_header(HEADER_X_GEO_METRO_CODE).is_none(), + response.headers().get(HEADER_X_GEO_METRO_CODE).is_none(), "should not set metro code header when metro_code is 0" ); assert!( - response.get_header(HEADER_X_GEO_CITY).is_some(), + response.headers().get(HEADER_X_GEO_CITY).is_some(), "should still set city header" ); } @@ -263,21 +272,22 @@ mod tests { region: None, ..sample_geo_info() }; - let mut response = Response::new(); + let mut response = build_response(); geo.set_response_headers(&mut response); assert!( - response.get_header(HEADER_X_GEO_REGION).is_none(), + response.headers().get(HEADER_X_GEO_REGION).is_none(), "should not set region header when region is None" ); assert!( - response.get_header(HEADER_X_GEO_CITY).is_some(), + response.headers().get(HEADER_X_GEO_CITY).is_some(), "should still set city header" ); assert_eq!( response - .get_header(HEADER_X_GEO_INFO_AVAILABLE) + .headers() + .get(HEADER_X_GEO_INFO_AVAILABLE) .expect("should have info available header") .to_str() .expect("should be valid str"), 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 64c41ec3..a125410a 100644 --- a/crates/trusted-server-core/src/integrations/google_tag_manager.rs +++ b/crates/trusted-server-core/src/integrations/google_tag_manager.rs @@ -22,6 +22,7 @@ use regex::Regex; use serde::{Deserialize, Serialize}; use validator::Validate; +use crate::compat; use crate::error::TrustedServerError; use crate::integrations::{ AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, @@ -429,9 +430,16 @@ impl IntegrationProxy for GoogleTagManagerIntegration { } }; - let mut response = proxy_request(settings, req, proxy_config, services) + let mut response = compat::to_fastly_response( + proxy_request( + settings, + compat::from_fastly_request(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) { diff --git a/crates/trusted-server-core/src/integrations/gpt.rs b/crates/trusted-server-core/src/integrations/gpt.rs index 1a68962e..190ce5bf 100644 --- a/crates/trusted-server-core/src/integrations/gpt.rs +++ b/crates/trusted-server-core/src/integrations/gpt.rs @@ -42,6 +42,7 @@ 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::{ @@ -245,9 +246,11 @@ impl GptIntegration { context: &str, ) -> Result> { let config = Self::build_proxy_config(target_url, &req); - let response = proxy_request(settings, req, config, services) - .await - .change_context(Self::error(context))?; + let response = compat::to_fastly_response( + proxy_request(settings, compat::from_fastly_request(req), config, services) + .await + .change_context(Self::error(context))?, + ); Self::ensure_successful_gpt_asset_response(&response, context)?; Ok(self.finalize_gpt_asset_response(response)) diff --git a/crates/trusted-server-core/src/integrations/testlight.rs b/crates/trusted-server-core/src/integrations/testlight.rs index d8c29b81..a887edd5 100644 --- a/crates/trusted-server-core/src/integrations/testlight.rs +++ b/crates/trusted-server-core/src/integrations/testlight.rs @@ -176,9 +176,16 @@ impl IntegrationProxy for TestlightIntegration { HeaderValue::from_static("application/json"), )); - let mut response = proxy_request(settings, req, proxy_config, services) + let mut response = compat::to_fastly_response( + proxy_request( + settings, + compat::from_fastly_request(req), + proxy_config, + services, + ) .await - .change_context(Self::error("Failed to contact upstream integration"))?; + .change_context(Self::error("Failed to contact upstream integration"))?, + ); // Attempt to parse response into structured form for logging/future transforms. let response_body = response.take_body_bytes(); diff --git a/crates/trusted-server-core/src/migration_guards.rs b/crates/trusted-server-core/src/migration_guards.rs index 370ce5a0..b02be522 100644 --- a/crates/trusted-server-core/src/migration_guards.rs +++ b/crates/trusted-server-core/src/migration_guards.rs @@ -23,33 +23,39 @@ fn strip_line_comments(source: &str) -> String { } #[test] -fn migrated_utility_modules_do_not_depend_on_fastly_request_response_types() { +fn migrated_utility_and_handler_modules_do_not_depend_on_fastly_request_response_types() { let sources = [ ("auth.rs", include_str!("auth.rs")), ("cookies.rs", include_str!("cookies.rs")), ("synthetic.rs", include_str!("synthetic.rs")), ("http_util.rs", include_str!("http_util.rs")), + ("geo.rs", include_str!("geo.rs")), + ("publisher.rs", include_str!("publisher.rs")), + ("proxy.rs", include_str!("proxy.rs")), + ("auction/formats.rs", include_str!("auction/formats.rs")), + ("auction/endpoints.rs", include_str!("auction/endpoints.rs")), + ( + "request_signing/endpoints.rs", + include_str!("request_signing/endpoints.rs"), + ), ( "consent/extraction.rs", include_str!("consent/extraction.rs"), ), ("consent/mod.rs", include_str!("consent/mod.rs")), ]; - let banned_patterns = [ - "fastly::Request", - "fastly::Response", - "fastly::http::Method", - "fastly::http::StatusCode", - "fastly::mime::APPLICATION_JSON", - ]; + // Word-boundary regex prevents false positives from doc comments or string + // literals that merely mention Fastly type names without importing them. + let banned = regex::Regex::new( + r"\bfastly::(Request|Response|http::(Method|StatusCode)|mime::APPLICATION_JSON)\b", + ) + .expect("should compile migration guard regex"); for (path, source) in sources { let uncommented = strip_line_comments(source); - for banned in banned_patterns { - assert!( - !uncommented.contains(banned), - "{path} should not reference `{banned}` after PR11 migration" - ); - } + assert!( + !banned.is_match(&uncommented), + "{path} should not reference fastly Request/Response types after PR11 migration" + ); } } diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 57200336..ee67e0a0 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -1,11 +1,10 @@ -use crate::compat; use crate::http_util::{compute_encrypted_sha256_token, ct_str_eq}; use edgezero_core::body::Body as EdgeBody; use edgezero_core::http::{request_builder as edge_request_builder, Uri as EdgeUri}; use error_stack::{Report, ResultExt}; -use fastly::http::{header, HeaderValue, Method, StatusCode}; -use fastly::{Request, Response}; +use http::{header, HeaderValue, Method, Request, Response, StatusCode}; use serde::{Deserialize, Serialize}; +use std::io::Cursor; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use crate::constants::{ @@ -14,9 +13,7 @@ use crate::constants::{ }; use crate::creative::{CreativeCssProcessor, CreativeHtmlProcessor}; use crate::error::TrustedServerError; -use crate::platform::{ - PlatformBackendSpec, PlatformHttpRequest, PlatformResponse, RuntimeServices, -}; +use crate::platform::{PlatformBackendSpec, PlatformHttpRequest, RuntimeServices}; use crate::settings::Settings; use crate::streaming_processor::{Compression, PipelineConfig, StreamProcessor, StreamingPipeline}; use crate::synthetic::get_synthetic_id; @@ -24,6 +21,10 @@ use crate::synthetic::get_synthetic_id; /// Chunk size used for streaming content through the rewrite pipeline. const STREAMING_CHUNK_SIZE: usize = 8192; +fn body_as_reader(body: EdgeBody) -> Cursor { + Cursor::new(body.into_bytes()) +} + /// Headers copied from the original client request to the upstream proxy request. const PROXY_FORWARD_HEADERS: [header::HeaderName; 5] = [ HEADER_USER_AGENT, @@ -33,37 +34,6 @@ const PROXY_FORWARD_HEADERS: [header::HeaderName; 5] = [ HEADER_X_FORWARDED_FOR, ]; -/// Convert a platform-neutral response into a [`fastly::Response`] for downstream processing. -/// -/// Shared with `auction/orchestrator.rs`. Both files will migrate off `fastly::Response` -/// entirely in Phase 2, at which point this conversion helper will be removed. -/// -/// # Panics (debug builds only) -/// -/// Panics when `platform_resp` carries a `Body::Stream` body, which indicates a -/// programming error — all outbound proxy bodies are built from byte slices and -/// are therefore always `Body::Once`. -pub(crate) fn platform_response_to_fastly(platform_resp: PlatformResponse) -> Response { - let (parts, body) = platform_resp.response.into_parts(); - debug_assert!( - matches!(&body, EdgeBody::Once(_)), - "unexpected Body::Stream in platform response conversion: body will be empty" - ); - let body_bytes = match body { - EdgeBody::Once(bytes) => bytes.to_vec(), - EdgeBody::Stream(_) => { - log::warn!("streaming platform response body; body will be empty"); - vec![] - } - }; - let mut resp = Response::from_status(parts.status.as_u16()); - for (name, value) in parts.headers.iter() { - resp.set_header(name.as_str(), value.as_bytes()); - } - resp.set_body(body_bytes); - resp -} - #[derive(Deserialize)] struct ProxySignReq { url: String, @@ -156,31 +126,27 @@ const SUPPORTED_ENCODINGS: &str = "gzip, deflate, br"; /// If `preserve_encoding` is true, the Content-Encoding header is kept (for compressed responses). /// If false, Content-Encoding is stripped (for decompressed responses). fn rebuild_response_with_body( - beresp: &Response, + beresp: Response, content_type: &'static str, body: Vec, preserve_encoding: bool, -) -> Response { - let status = beresp.get_status(); - let headers: Vec<(header::HeaderName, HeaderValue)> = beresp - .get_headers() - .map(|(name, value)| (name.clone(), value.clone())) - .collect(); - let mut resp = Response::from_status(status); - for (name, value) in headers { - // Always skip Content-Length (size changed) and Content-Type (we set it) - if name == header::CONTENT_LENGTH || name == header::CONTENT_TYPE { - continue; - } - // Skip Content-Encoding only if we're not preserving it - if name == header::CONTENT_ENCODING && !preserve_encoding { - continue; - } - resp.set_header(name, value); +) -> Response { + let (mut parts, _) = beresp.into_parts(); + + // Always skip Content-Length (size changed) and Content-Type (we set it) + parts.headers.remove(header::CONTENT_LENGTH); + parts.headers.remove(header::CONTENT_TYPE); + + // Skip Content-Encoding only if we're not preserving it + if !preserve_encoding { + parts.headers.remove(header::CONTENT_ENCODING); } - resp.set_header(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); - resp.set_body(body); - resp + + parts + .headers + .insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); + + Response::from_parts(parts, EdgeBody::from(body)) } /// Process a response body through a streaming pipeline with the given processor. @@ -188,29 +154,29 @@ fn rebuild_response_with_body( /// Handles decompression, content processing, and re-compression while preserving /// the response status and headers. fn process_response_with_pipeline( - mut beresp: Response, + mut beresp: Response, processor: P, compression: Compression, content_type: &'static str, error_context: &'static str, -) -> Result> { +) -> Result, Report> { let config = PipelineConfig { input_compression: compression, output_compression: compression, chunk_size: STREAMING_CHUNK_SIZE, }; - let body = beresp.take_body(); + let body = std::mem::replace(beresp.body_mut(), EdgeBody::empty()); let mut output = Vec::new(); let mut pipeline = StreamingPipeline::new(config, processor); pipeline - .process(body, &mut output) + .process(body_as_reader(body), &mut output) .change_context(TrustedServerError::Proxy { message: error_context.to_string(), })?; Ok(rebuild_response_with_body( - &beresp, + beresp, content_type, output, compression != Compression::None, @@ -219,28 +185,32 @@ fn process_response_with_pipeline( fn finalize_proxied_response( settings: &Settings, - req: &Request, + req: &Request, target_url: &str, - mut beresp: Response, -) -> Result> { + mut beresp: Response, +) -> Result, Report> { // Determine content-type and content-encoding from response headers - let status_code = beresp.get_status().as_u16(); + let status_code = beresp.status().as_u16(); let ct_raw = beresp - .get_header(header::CONTENT_TYPE) + .headers() + .get(header::CONTENT_TYPE) .and_then(|h| h.to_str().ok()) .unwrap_or("") .to_string(); let content_encoding = beresp - .get_header(header::CONTENT_ENCODING) + .headers() + .get(header::CONTENT_ENCODING) .and_then(|h| h.to_str().ok()) .unwrap_or("") .to_string(); let cl_raw = beresp - .get_header(header::CONTENT_LENGTH) + .headers() + .get(header::CONTENT_LENGTH) .and_then(|h| h.to_str().ok()) .unwrap_or("-"); let accept_raw = req - .get_header(HEADER_ACCEPT) + .headers() + .get(HEADER_ACCEPT) .and_then(|h| h.to_str().ok()) .unwrap_or("-"); @@ -287,20 +257,24 @@ fn finalize_proxied_response( // Image handling: set generic content-type if missing and log pixel heuristics let req_accept_images = req - .get_header(HEADER_ACCEPT) + .headers() + .get(HEADER_ACCEPT) .and_then(|h| h.to_str().ok()) .map(|s| s.to_ascii_lowercase().contains("image/")) .unwrap_or(false); if ct.starts_with("image/") || req_accept_images { - if beresp.get_header(header::CONTENT_TYPE).is_none() { - beresp.set_header(header::CONTENT_TYPE, "image/*"); + if beresp.headers().get(header::CONTENT_TYPE).is_none() { + beresp + .headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static("image/*")); } // Heuristics to log likely tracking pixels without altering response let mut is_pixel = false; if let Some(cl) = beresp - .get_header(header::CONTENT_LENGTH) + .headers() + .get(header::CONTENT_LENGTH) .and_then(|h| h.to_str().ok()) .and_then(|s| s.parse::().ok()) { @@ -334,22 +308,25 @@ fn finalize_proxied_response( } fn finalize_proxied_response_streaming( - req: &Request, + req: &Request, target_url: &str, - mut beresp: Response, -) -> Response { - let status_code = beresp.get_status().as_u16(); + mut beresp: Response, +) -> Response { + let status_code = beresp.status().as_u16(); let ct_raw = beresp - .get_header(header::CONTENT_TYPE) + .headers() + .get(header::CONTENT_TYPE) .and_then(|h| h.to_str().ok()) .unwrap_or("") .to_string(); let cl_raw = beresp - .get_header(header::CONTENT_LENGTH) + .headers() + .get(header::CONTENT_LENGTH) .and_then(|h| h.to_str().ok()) .unwrap_or("-"); let accept_raw = req - .get_header(HEADER_ACCEPT) + .headers() + .get(HEADER_ACCEPT) .and_then(|h| h.to_str().ok()) .unwrap_or("-"); @@ -366,19 +343,23 @@ fn finalize_proxied_response_streaming( let ct = ct_raw.to_ascii_lowercase(); let req_accept_images = req - .get_header(HEADER_ACCEPT) + .headers() + .get(HEADER_ACCEPT) .and_then(|h| h.to_str().ok()) .map(|s| s.to_ascii_lowercase().contains("image/")) .unwrap_or(false); if ct.starts_with("image/") || req_accept_images { - if beresp.get_header(header::CONTENT_TYPE).is_none() { - beresp.set_header(header::CONTENT_TYPE, "image/*"); + if beresp.headers().get(header::CONTENT_TYPE).is_none() { + beresp + .headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static("image/*")); } let mut is_pixel = false; if let Some(cl) = beresp - .get_header(header::CONTENT_LENGTH) + .headers() + .get(header::CONTENT_LENGTH) .and_then(|h| h.to_str().ok()) .and_then(|s| s.parse::().ok()) { @@ -414,11 +395,11 @@ fn finalize_proxied_response_streaming( /// content processing based on the `stream_passthrough` flag. fn finalize_response( settings: &Settings, - req: &Request, + req: &Request, url: &str, - beresp: Response, + beresp: Response, stream_passthrough: bool, -) -> Result> { +) -> Result, Report> { if stream_passthrough { Ok(finalize_proxied_response_streaming(req, url, beresp)) } else { @@ -444,10 +425,10 @@ struct ProxyRequestHeaders<'a> { /// scheme, lacks a host, or the upstream fetch fails pub async fn proxy_request( settings: &Settings, - req: Request, + req: Request, config: ProxyRequestConfig<'_>, services: &RuntimeServices, -) -> Result> { +) -> Result, Report> { let ProxyRequestConfig { target_url, follow_redirects, @@ -485,9 +466,8 @@ pub async fn proxy_request( .await } -fn append_synthetic_id(req: &Request, target_url_parsed: &mut url::Url) { - let http_req = compat::from_fastly_headers_ref(req); - let synthetic_id_param = match get_synthetic_id(&http_req) { +fn append_synthetic_id(req: &Request, target_url_parsed: &mut url::Url) { + let synthetic_id_param = match get_synthetic_id(req) { Ok(id) => id, Err(e) => { log::warn!("failed to extract synthetic ID for forwarding: {:?}", e); @@ -557,17 +537,17 @@ fn is_host_allowed(host: &str, pattern: &str) -> bool { async fn proxy_with_redirects( settings: &Settings, - req: &Request, + req: &Request, target_url_parsed: url::Url, follow_redirects: bool, body: Option<&[u8]>, request_headers: ProxyRequestHeaders<'_>, stream_passthrough: bool, -) -> Result> { +) -> Result, Report> { const MAX_REDIRECTS: usize = 4; let mut current_url = target_url_parsed.to_string(); - let mut current_method: Method = req.get_method().clone(); + let mut current_method: Method = req.method().clone(); for redirect_attempt in 0..=MAX_REDIRECTS { let parsed_url = url::Url::parse(¤t_url).map_err(|_| { @@ -624,7 +604,7 @@ async fn proxy_with_redirects( if request_headers.copy_request_headers { for header_name in PROXY_FORWARD_HEADERS { - if let Some(v) = req.get_header(&header_name) { + if let Some(v) = req.headers().get(&header_name) { builder = builder.header(header_name.as_str(), v.as_bytes()); } } @@ -653,13 +633,13 @@ async fn proxy_with_redirects( message: "Failed to proxy".to_string(), })?; - let beresp = platform_response_to_fastly(platform_resp); + let beresp = platform_resp.response; if !follow_redirects { return finalize_response(settings, req, ¤t_url, beresp, stream_passthrough); } - let status = beresp.get_status(); + let status = beresp.status(); let is_redirect = matches!( status, StatusCode::MOVED_PERMANENTLY @@ -674,7 +654,8 @@ async fn proxy_with_redirects( } let Some(location) = beresp - .get_header(header::LOCATION) + .headers() + .get(header::LOCATION) .and_then(|h| h.to_str().ok()) .filter(|value| !value.is_empty()) else { @@ -757,11 +738,11 @@ async fn proxy_with_redirects( pub async fn handle_first_party_proxy( settings: &Settings, services: &RuntimeServices, - req: Request, -) -> Result> { + req: Request, +) -> Result, Report> { // Parse, reconstruct, and validate the signed target URL let SignedTarget { target_url, .. } = - reconstruct_and_validate_signed_target(settings, req.get_url_str())?; + reconstruct_and_validate_signed_target(settings, &req.uri().to_string())?; proxy_request( settings, @@ -794,16 +775,15 @@ pub async fn handle_first_party_proxy( pub async fn handle_first_party_click( settings: &Settings, _services: &RuntimeServices, - req: Request, -) -> Result> { + req: Request, +) -> Result, Report> { let SignedTarget { target_url: full_for_token, tsurl, had_params, - } = reconstruct_and_validate_signed_target(settings, req.get_url_str())?; + } = reconstruct_and_validate_signed_target(settings, &req.uri().to_string())?; - let http_req = compat::from_fastly_headers_ref(&req); - let synthetic_id = match get_synthetic_id(&http_req) { + let synthetic_id = match get_synthetic_id(&req) { Ok(id) => id, Err(e) => { log::warn!("failed to extract synthetic ID for forwarding: {:?}", e); @@ -847,11 +827,13 @@ pub async fn handle_first_party_click( // Log click metadata for observability let ua = req - .get_header(HEADER_USER_AGENT) + .headers() + .get(HEADER_USER_AGENT) .and_then(|h| h.to_str().ok()) .unwrap_or(""); let referer = req - .get_header(HEADER_REFERER) + .headers() + .get(HEADER_REFERER) .and_then(|h| h.to_str().ok()) .unwrap_or(""); log::info!( @@ -865,9 +847,19 @@ pub async fn handle_first_party_click( ); // 302 redirect to target URL - Ok(Response::from_status(fastly::http::StatusCode::FOUND) - .with_header(header::LOCATION, &redirect_target) - .with_header(header::CACHE_CONTROL, "no-store, private")) + let location = HeaderValue::from_str(&redirect_target).map_err(|_| { + Report::new(TrustedServerError::InvalidHeaderValue { + message: "invalid redirect target".to_string(), + }) + })?; + let mut response = Response::new(EdgeBody::empty()); + *response.status_mut() = StatusCode::FOUND; + response.headers_mut().insert(header::LOCATION, location); + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("no-store, private"), + ); + Ok(response) } /// Sign an arbitrary asset URL so creatives can request first-party proxying at runtime. @@ -882,20 +874,33 @@ pub async fn handle_first_party_click( pub async fn handle_first_party_proxy_sign( settings: &Settings, _services: &RuntimeServices, - mut req: Request, -) -> Result> { - let method = req.get_method().clone(); - - let payload = if method == fastly::http::Method::POST { - let body = req.take_body_str(); + req: Request, +) -> Result, Report> { + let method = req.method().clone(); + let req_url = req.uri().to_string(); + + let payload = if method == Method::POST { + let body_bytes = req.into_body().into_bytes(); + if body_bytes.len() > 65536 { + return Err(Report::new(TrustedServerError::RequestTooLarge { + message: format!( + "payload size {} exceeds limit of 65536 bytes", + body_bytes.len() + ), + })); + } + let body = String::from_utf8(body_bytes.to_vec()).change_context( + TrustedServerError::InvalidUtf8 { + message: "first-party sign request body should be valid UTF-8".to_string(), + }, + )?; serde_json::from_str::(&body).change_context(TrustedServerError::Proxy { message: "invalid JSON".to_string(), })? } else { - let parsed = - url::Url::parse(req.get_url_str()).change_context(TrustedServerError::Proxy { - message: "Invalid URL".to_string(), - })?; + let parsed = url::Url::parse(&req_url).change_context(TrustedServerError::Proxy { + message: "Invalid URL".to_string(), + })?; let url = parsed .query_pairs() .find(|(k, _)| k == "url") @@ -910,7 +915,7 @@ pub async fn handle_first_party_proxy_sign( let trimmed = payload.url.trim(); let abs = if trimmed.starts_with("//") { - let default_scheme = url::Url::parse(req.get_url_str()) + let default_scheme = url::Url::parse(&req_url) .ok() .map(|u| u.scheme().to_ascii_lowercase()) .filter(|scheme| !scheme.is_empty()) @@ -953,12 +958,15 @@ pub async fn handle_first_party_proxy_sign( base: base.to_string(), }; - let mut response = Response::from_status(fastly::http::StatusCode::OK); - response.set_header(header::CONTENT_TYPE, "application/json; charset=utf-8"); - response.set_body( + let mut response = Response::new(EdgeBody::from( serde_json::to_string(&resp).change_context(TrustedServerError::Proxy { message: "failed to serialize".to_string(), })?, + )); + *response.status_mut() = StatusCode::OK; + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json; charset=utf-8"), ); Ok(response) } @@ -990,11 +998,25 @@ struct ProxyRebuildResp { pub async fn handle_first_party_proxy_rebuild( settings: &Settings, _services: &RuntimeServices, - mut req: Request, -) -> Result> { - let method = req.get_method().clone(); - let payload = if method == fastly::http::Method::POST { - let body = req.take_body_str(); + req: Request, +) -> Result, Report> { + let method = req.method().clone(); + let req_url = req.uri().to_string(); + let payload = if method == Method::POST { + let body_bytes = req.into_body().into_bytes(); + if body_bytes.len() > 65536 { + return Err(Report::new(TrustedServerError::RequestTooLarge { + message: format!( + "payload size {} exceeds limit of 65536 bytes", + body_bytes.len() + ), + })); + } + let body = String::from_utf8(body_bytes.to_vec()).change_context( + TrustedServerError::InvalidUtf8 { + message: "first-party rebuild request body should be valid UTF-8".to_string(), + }, + )?; serde_json::from_str::(&body).change_context( TrustedServerError::Proxy { message: "invalid JSON".to_string(), @@ -1002,10 +1024,9 @@ pub async fn handle_first_party_proxy_rebuild( )? } else { // Support GET: /first-party/proxy-rebuild?tsclick=...&add=...&del=... - let parsed = - url::Url::parse(req.get_url_str()).change_context(TrustedServerError::Proxy { - message: "Invalid URL".to_string(), - })?; + let parsed = url::Url::parse(&req_url).change_context(TrustedServerError::Proxy { + message: "Invalid URL".to_string(), + })?; let mut tsclick: Option = None; let mut add: Option> = None; let mut del: Option> = None; @@ -1122,11 +1143,21 @@ pub async fn handle_first_party_proxy_rebuild( } } - if method == fastly::http::Method::GET { + if method == Method::GET { // Redirect for GET usage to streamline navigation - Ok(Response::from_status(fastly::http::StatusCode::FOUND) - .with_header(header::LOCATION, href) - .with_header(header::CACHE_CONTROL, "no-store, private")) + let location = HeaderValue::from_str(&href).map_err(|_| { + Report::new(TrustedServerError::InvalidHeaderValue { + message: "invalid rebuild redirect target".to_string(), + }) + })?; + let mut response = Response::new(EdgeBody::empty()); + *response.status_mut() = StatusCode::FOUND; + response.headers_mut().insert(header::LOCATION, location); + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("no-store, private"), + ); + Ok(response) } else { let json = serde_json::to_string(&ProxyRebuildResp { href, @@ -1134,11 +1165,20 @@ pub async fn handle_first_party_proxy_rebuild( added, removed, }) - .unwrap_or_else(|_| "{}".to_string()); - Ok(Response::from_status(fastly::http::StatusCode::OK) - .with_header(header::CONTENT_TYPE, "application/json; charset=utf-8") - .with_header(header::CACHE_CONTROL, "no-store, private") - .with_body(json)) + .change_context(TrustedServerError::Proxy { + message: "failed to serialize rebuild response".to_string(), + })?; + let mut response = Response::new(EdgeBody::from(json)); + *response.status_mut() = StatusCode::OK; + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json; charset=utf-8"), + ); + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("no-store, private"), + ); + Ok(response) } } @@ -1254,7 +1294,7 @@ fn reconstruct_and_validate_signed_target( mod tests { use super::{ handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, - handle_first_party_proxy_sign, is_host_allowed, proxy_request, + handle_first_party_proxy_sign, is_host_allowed, proxy_request, rebuild_response_with_body, reconstruct_and_validate_signed_target, redirect_is_permitted, ProxyRequestConfig, }; use crate::constants::HEADER_ACCEPT; @@ -1262,14 +1302,86 @@ mod tests { use crate::error::{IntoHttpResponse, TrustedServerError}; use crate::platform::test_support::noop_services; use crate::test_support::tests::create_test_settings; + use edgezero_core::body::Body as EdgeBody; use error_stack::Report; - use fastly::http::{header, HeaderValue, Method, StatusCode}; - use fastly::{Request, Response}; + use http::{header, HeaderValue, Method, Request as HttpRequest, Response, StatusCode}; + + #[test] + fn test_rebuild_response_with_body_preserves_multiple_headers() { + let mut response = Response::new(EdgeBody::empty()); + response + .headers_mut() + .append(header::SET_COOKIE, HeaderValue::from_static("session=123")); + response + .headers_mut() + .append(header::SET_COOKIE, HeaderValue::from_static("tracker=456")); + response + .headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static("text/html")); + + let rebuilt = + rebuild_response_with_body(response, "application/json", b"{}".to_vec(), false); + + let cookies: Vec<_> = rebuilt + .headers() + .get_all(header::SET_COOKIE) + .iter() + .filter_map(|h| h.to_str().ok()) + .collect(); + assert_eq!(cookies, vec!["session=123", "tracker=456"]); + assert_eq!( + rebuilt + .headers() + .get(header::CONTENT_TYPE) + .unwrap() + .to_str() + .unwrap(), + "application/json" + ); + } + + fn build_http_request(method: Method, uri: impl AsRef) -> HttpRequest { + HttpRequest::builder() + .method(method) + .uri(uri.as_ref()) + .body(EdgeBody::empty()) + .expect("should build http request") + } + + fn build_http_post_json_request( + uri: impl AsRef, + body: &serde_json::Value, + ) -> HttpRequest { + HttpRequest::builder() + .method(Method::POST) + .uri(uri.as_ref()) + .header(http::header::CONTENT_TYPE, "application/json") + .body(EdgeBody::from(body.to_string())) + .expect("should build http post request") + } + + fn response_body_string(response: http::Response) -> String { + String::from_utf8(response.into_body().into_bytes().to_vec()) + .expect("response body should be valid UTF-8") + } + + fn build_http_response(status: StatusCode, body: EdgeBody) -> Response { + let mut response = Response::new(body); + *response.status_mut() = status; + response + } + + fn response_header(response: &Response, name: header::HeaderName) -> Option<&str> { + response + .headers() + .get(name) + .and_then(|value| value.to_str().ok()) + } #[tokio::test] async fn proxy_missing_param_returns_400() { let settings = create_test_settings(); - let req = Request::new(Method::GET, "https://example.com/first-party/proxy"); + let req = build_http_request(Method::GET, "https://example.com/first-party/proxy"); let err: Report = handle_first_party_proxy(&settings, &noop_services(), req) .await @@ -1281,7 +1393,7 @@ mod tests { async fn proxy_missing_or_invalid_token_returns_400() { let settings = create_test_settings(); // missing tstoken should 400 - let req = Request::new( + let req = build_http_request( Method::GET, "https://example.com/first-party/proxy?tsurl=https%3A%2F%2Fcdn.example%2Fa.png", ); @@ -1298,13 +1410,12 @@ mod tests { let body = serde_json::json!({ "url": "https://cdn.example/asset.js?c=3&b=2", }); - let mut req = Request::new(Method::POST, "https://edge.example/first-party/sign"); - req.set_body(body.to_string()); - let mut resp = handle_first_party_proxy_sign(&settings, &noop_services(), req) + let req = build_http_post_json_request("https://edge.example/first-party/sign", &body); + let resp = handle_first_party_proxy_sign(&settings, &noop_services(), req) .await .expect("sign ok"); - assert_eq!(resp.get_status(), StatusCode::OK); - let json = resp.take_body_str(); + assert_eq!(resp.status(), StatusCode::OK); + let json = response_body_string(resp); assert!(json.contains("/first-party/proxy?tsurl="), "{}", json); assert!(json.contains("tsexp"), "{}", json); assert!( @@ -1320,8 +1431,7 @@ mod tests { let body = serde_json::json!({ "url": "data:image/png;base64,AAAA", }); - let mut req = Request::new(Method::POST, "https://edge.example/first-party/sign"); - req.set_body(body.to_string()); + let req = build_http_post_json_request("https://edge.example/first-party/sign", &body); let err: Report = handle_first_party_proxy_sign(&settings, &noop_services(), req) .await @@ -1335,17 +1445,12 @@ mod tests { let body = serde_json::json!({ "url": "https://cdn.example.com:9443/img/300x250.svg", }); - let mut req = Request::new(Method::POST, "https://edge.example/first-party/sign"); - req.set_body(body.to_string()); - let mut resp = handle_first_party_proxy_sign(&settings, &noop_services(), req) + let req = build_http_post_json_request("https://edge.example/first-party/sign", &body); + let resp = handle_first_party_proxy_sign(&settings, &noop_services(), req) .await .expect("should sign URL with non-standard port"); - assert_eq!( - resp.get_status(), - StatusCode::OK, - "should return 200 for valid sign request" - ); - let json = resp.take_body_str(); + assert_eq!(resp.status(), StatusCode::OK); + let json = response_body_string(resp); // Port 9443 should be preserved (URL-encoded as %3A9443) assert!( json.contains("%3A9443"), @@ -1446,7 +1551,7 @@ mod tests { #[tokio::test] async fn click_missing_params_returns_400() { let settings = create_test_settings(); - let req = Request::new(Method::GET, "https://edge.example/first-party/click"); + let req = build_http_request(Method::GET, "https://edge.example/first-party/click"); let err: Report = handle_first_party_click(&settings, &noop_services(), req) .await @@ -1461,7 +1566,7 @@ mod tests { let params = "foo=1&bar=2"; let full = format!("{}?{}", tsurl, params); let sig = crate::http_util::compute_encrypted_sha256_token(&settings, &full); - let req = Request::new( + let req = build_http_request( Method::GET, format!( "https://edge.example/first-party/click?tsurl={}&{}&tstoken={}", @@ -1473,9 +1578,10 @@ mod tests { let resp = handle_first_party_click(&settings, &noop_services(), req) .await .expect("should redirect"); - assert_eq!(resp.get_status(), StatusCode::FOUND); + assert_eq!(resp.status(), StatusCode::FOUND); let loc = resp - .get_header(header::LOCATION) + .headers() + .get(http::header::LOCATION) .and_then(|h| h.to_str().ok()) .unwrap_or(""); assert_eq!(loc, full); @@ -1488,7 +1594,7 @@ mod tests { let params = "foo=1"; let full = format!("{}?{}", tsurl, params); let sig = crate::http_util::compute_encrypted_sha256_token(&settings, &full); - let mut req = Request::new( + let mut req = build_http_request( Method::GET, format!( "https://edge.example/first-party/click?tsurl={}&{}&tstoken={}", @@ -1498,14 +1604,18 @@ mod tests { ), ); let valid_synthetic_id = crate::test_support::tests::VALID_SYNTHETIC_ID; - req.set_header(crate::constants::HEADER_X_SYNTHETIC_ID, valid_synthetic_id); + req.headers_mut().insert( + crate::constants::HEADER_X_SYNTHETIC_ID, + HeaderValue::from_static(valid_synthetic_id), + ); let resp = handle_first_party_click(&settings, &noop_services(), req) .await .expect("should redirect"); let loc = resp - .get_header(header::LOCATION) + .headers() + .get(header::LOCATION) .and_then(|h| h.to_str().ok()) .expect("Location header should be present and valid"); let parsed = url::Url::parse(loc).expect("Location should be a valid URL"); @@ -1531,16 +1641,18 @@ mod tests { "add": {"y": "2"}, "del": ["x"], }); - let mut req = Request::new( - Method::POST, - "https://edge.example/first-party/proxy-rebuild", - ); - req.set_body(serde_json::to_string(&body).expect("test JSON should serialize")); - let mut resp = handle_first_party_proxy_rebuild(&settings, &noop_services(), req) + let req = HttpRequest::builder() + .method(Method::POST) + .uri("https://edge.example/first-party/proxy-rebuild") + .body(EdgeBody::from( + serde_json::to_string(&body).expect("test JSON should serialize"), + )) + .expect("should build proxy rebuild request"); + let resp = handle_first_party_proxy_rebuild(&settings, &noop_services(), req) .await .expect("rebuild ok"); - assert_eq!(resp.get_status(), StatusCode::OK); - let json = resp.take_body_str(); + assert_eq!(resp.status(), StatusCode::OK); + let json = response_body_string(resp); assert!(json.contains("/first-party/click?tsurl=")); assert!(json.contains("tstoken")); // Diagnostics @@ -1613,7 +1725,7 @@ mod tests { let clear = "ftp://cdn.example/file.gif"; // Build a first-party proxy URL with a token for the unsupported scheme let first_party = creative::build_proxy_url(&settings, clear); - let req = Request::new(Method::GET, format!("https://edge.example{}", first_party)); + let req = build_http_request(Method::GET, format!("https://edge.example{}", first_party)); let err: Report = handle_first_party_proxy(&settings, &noop_services(), req) .await @@ -1634,7 +1746,7 @@ mod tests { url::form_urlencoded::byte_serialize(tsurl.as_bytes()).collect::(), sig ); - let req = Request::new(Method::GET, &url); + let req = build_http_request(Method::GET, &url); let err: Report = handle_first_party_proxy(&settings, &noop_services(), req) .await @@ -1647,15 +1759,12 @@ mod tests { let settings = create_test_settings(); let clear = "https://cdn.example/landing.html?x=1"; let first_party = creative::build_click_url(&settings, clear); - let req = Request::new(Method::GET, format!("https://edge.example{}", first_party)); + let req = build_http_request(Method::GET, format!("https://edge.example{}", first_party)); let resp = handle_first_party_click(&settings, &noop_services(), req) .await .expect("should redirect"); - assert_eq!(resp.get_status(), StatusCode::FOUND); - let cc = resp - .get_header(header::CACHE_CONTROL) - .and_then(|h| h.to_str().ok()) - .unwrap_or(""); + assert_eq!(resp.status(), StatusCode::FOUND); + let cc = response_header(&resp, header::CACHE_CONTROL).unwrap_or(""); assert!(cc.contains("no-store")); assert!(cc.contains("private")); } @@ -1670,38 +1779,40 @@ mod tests { let settings = create_test_settings(); // HTML with an external image that should be proxied in rewrite let html = r#""#; - let beresp = Response::from_status(StatusCode::OK) - .with_header(header::CONTENT_TYPE, "text/html; charset=utf-8") - .with_header(header::CACHE_CONTROL, "public, max-age=60") - .with_header(header::SET_COOKIE, "a=1; Path=/; Secure") - .with_body(html); + let mut beresp = build_http_response(StatusCode::OK, EdgeBody::from(html)); + beresp.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); + beresp.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("public, max-age=60"), + ); + beresp.headers_mut().insert( + header::SET_COOKIE, + HeaderValue::from_static("a=1; Path=/; Secure"), + ); // Sanity: header present and creative rewrite works directly - let ct_pre = beresp - .get_header(header::CONTENT_TYPE) - .and_then(|h| h.to_str().ok()) + let ct_pre = response_header(&beresp, header::CONTENT_TYPE) .unwrap_or("") .to_string(); assert!(ct_pre.contains("text/html"), "ct_pre={}", ct_pre); let direct = creative::rewrite_creative_html(&settings, html); assert!(direct.contains("/first-party/proxy?tsurl="), "{}", direct); - let req = Request::new(Method::GET, "https://edge.example/first-party/proxy"); + let req = build_http_request(Method::GET, "https://edge.example/first-party/proxy"); let out = finalize(&settings, &req, "https://cdn.example/a.png", beresp) .expect("finalize should succeed"); - let ct = out - .get_header(header::CONTENT_TYPE) + let ct = response_header(&out, header::CONTENT_TYPE) .expect("Content-Type header should be present") - .to_str() - .expect("Content-Type should be valid UTF-8"); + .to_string(); assert_eq!(ct, "text/html; charset=utf-8"); - let cc = out - .get_header(header::CACHE_CONTROL) - .and_then(|h| h.to_str().ok()) - .unwrap_or(""); + let cc = response_header(&out, header::CACHE_CONTROL) + .unwrap_or("") + .to_string(); assert_eq!(cc, "public, max-age=60"); - let cookie = out - .get_header(header::SET_COOKIE) - .and_then(|h| h.to_str().ok()) - .unwrap_or(""); + let cookie = response_header(&out, header::SET_COOKIE) + .unwrap_or("") + .to_string(); assert!(cookie.contains("a=1")); } @@ -1709,19 +1820,18 @@ mod tests { fn css_response_is_rewritten_and_content_type_set() { let settings = create_test_settings(); let css = "body{background:url(https://cdn.example/bg.png)}"; - let beresp = Response::from_status(StatusCode::OK) - .with_header(header::CONTENT_TYPE, "text/css") - .with_body(css); - let req = Request::new(Method::GET, "https://edge.example/first-party/proxy"); - let mut out = finalize(&settings, &req, "https://cdn.example/bg.png", beresp) + let mut beresp = build_http_response(StatusCode::OK, EdgeBody::from(css)); + beresp + .headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static("text/css")); + let req = build_http_request(Method::GET, "https://edge.example/first-party/proxy"); + let out = finalize(&settings, &req, "https://cdn.example/bg.png", beresp) .expect("finalize should succeed"); - let body = out.take_body_str(); - assert!(body.contains("/first-party/proxy?tsurl="), "{}", body); - let ct = out - .get_header(header::CONTENT_TYPE) + let ct = response_header(&out, header::CONTENT_TYPE) .expect("Content-Type header should be present") - .to_str() - .expect("Content-Type should be valid UTF-8"); + .to_string(); + let body = response_body_string(out); + assert!(body.contains("/first-party/proxy?tsurl="), "{}", body); assert_eq!(ct, "text/css; charset=utf-8"); } @@ -1741,12 +1851,14 @@ mod tests { "#; - let beresp = Response::from_status(StatusCode::OK) - .with_header(header::CONTENT_TYPE, "text/html; charset=utf-8") - .with_body(html); + let mut beresp = build_http_response(StatusCode::OK, EdgeBody::from(html)); + beresp.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); - let req = Request::new(Method::GET, "https://edge.example/first-party/proxy"); - let mut out = finalize( + let req = build_http_request(Method::GET, "https://edge.example/first-party/proxy"); + let out = finalize( &settings, &req, "https://cdn.example.com:9443/creatives/300x250.html", @@ -1754,7 +1866,7 @@ mod tests { ) .expect("should finalize HTML response with non-standard port URL"); - let body = out.take_body_str(); + let body = response_body_string(out); // Port 9443 should be preserved (URL-encoded as %3A9443) assert!( @@ -1767,38 +1879,36 @@ mod tests { #[test] fn image_accept_sets_generic_content_type_when_missing() { let settings = create_test_settings(); - let beresp = Response::from_status(StatusCode::OK).with_body("PNG"); - let mut req = Request::new(Method::GET, "https://edge.example/first-party/proxy"); - req.set_header(HEADER_ACCEPT, "image/*"); + let beresp = build_http_response(StatusCode::OK, EdgeBody::from("PNG")); + let mut req = build_http_request(Method::GET, "https://edge.example/first-party/proxy"); + req.headers_mut() + .insert(HEADER_ACCEPT, HeaderValue::from_static("image/*")); let out = finalize(&settings, &req, "https://cdn.example/pixel.gif", beresp) .expect("finalize should succeed"); // Since CT was missing and Accept indicates image, it should set generic image/* - let ct = out - .get_header(header::CONTENT_TYPE) - .expect("Content-Type header should be present") - .to_str() - .expect("Content-Type should be valid UTF-8"); + let ct = response_header(&out, header::CONTENT_TYPE) + .expect("Content-Type header should be present"); assert_eq!(ct, "image/*"); } #[test] fn non_image_non_html_passthrough() { let settings = create_test_settings(); - let beresp = Response::from_status(StatusCode::ACCEPTED) - .with_header(header::CONTENT_TYPE, "application/json") - .with_body("{\"ok\":true}"); - let req = Request::new(Method::GET, "https://edge.example/first-party/proxy"); - let mut out = finalize(&settings, &req, "https://api.example/ok", beresp) + let mut beresp = build_http_response(StatusCode::ACCEPTED, EdgeBody::from("{\"ok\":true}")); + beresp.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + let req = build_http_request(Method::GET, "https://edge.example/first-party/proxy"); + let out = finalize(&settings, &req, "https://api.example/ok", beresp) .expect("finalize should succeed"); // Should not rewrite, preserve status and content-type - assert_eq!(out.get_status(), StatusCode::ACCEPTED); - let ct = out - .get_header(header::CONTENT_TYPE) + assert_eq!(out.status(), StatusCode::ACCEPTED); + let ct = response_header(&out, header::CONTENT_TYPE) .expect("Content-Type header should be present") - .to_str() - .expect("Content-Type should be valid UTF-8"); + .to_string(); assert_eq!(ct, "application/json"); - let body = out.take_body_str(); + let body = response_body_string(out); assert_eq!(body, "{\"ok\":true}"); } @@ -1819,28 +1929,28 @@ mod tests { .expect("gzip write should succeed"); let compressed = encoder.finish().expect("gzip finish should succeed"); - let beresp = Response::from_status(StatusCode::OK) - .with_header(header::CONTENT_TYPE, "text/html; charset=utf-8") - .with_header(header::CONTENT_ENCODING, "gzip") - .with_body(compressed); + let mut beresp = build_http_response(StatusCode::OK, EdgeBody::from(compressed)); + beresp.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); + beresp + .headers_mut() + .insert(header::CONTENT_ENCODING, HeaderValue::from_static("gzip")); - let req = Request::new(Method::GET, "https://edge.example/first-party/proxy"); + let req = build_http_request(Method::GET, "https://edge.example/first-party/proxy"); let out = finalize(&settings, &req, "https://cdn.example/a.png", beresp) .expect("finalize should process and succeed"); // Content-Encoding should be preserved (gzip in -> gzip out) - let ce = out - .get_header(header::CONTENT_ENCODING) + let ce = response_header(&out, header::CONTENT_ENCODING) .expect("Content-Encoding should be preserved") - .to_str() - .expect("Content-Encoding should be valid UTF-8"); + .to_string(); assert_eq!(ce, "gzip"); - let ct = out - .get_header(header::CONTENT_TYPE) + let ct = response_header(&out, header::CONTENT_TYPE) .expect("Content-Type header should be present") - .to_str() - .expect("Content-Type should be valid UTF-8"); + .to_string(); assert_eq!(ct, "text/html; charset=utf-8"); // Decompress output to verify content was rewritten @@ -1876,28 +1986,27 @@ mod tests { .expect("brotli write should succeed"); } - let beresp = Response::from_status(StatusCode::OK) - .with_header(header::CONTENT_TYPE, "text/css") - .with_header(header::CONTENT_ENCODING, "br") - .with_body(compressed); + let mut beresp = build_http_response(StatusCode::OK, EdgeBody::from(compressed)); + beresp + .headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static("text/css")); + beresp + .headers_mut() + .insert(header::CONTENT_ENCODING, HeaderValue::from_static("br")); - let req = Request::new(Method::GET, "https://edge.example/first-party/proxy"); + let req = build_http_request(Method::GET, "https://edge.example/first-party/proxy"); let out = finalize(&settings, &req, "https://cdn.example/bg.png", beresp) .expect("finalize should process brotli and succeed"); // Content-Encoding should be preserved (br in -> br out) - let ce = out - .get_header(header::CONTENT_ENCODING) + let ce = response_header(&out, header::CONTENT_ENCODING) .expect("Content-Encoding should be preserved") - .to_str() - .expect("Content-Encoding should be valid UTF-8"); + .to_string(); assert_eq!(ce, "br"); - let ct = out - .get_header(header::CONTENT_TYPE) + let ct = response_header(&out, header::CONTENT_TYPE) .expect("Content-Type header should be present") - .to_str() - .expect("Content-Type should be valid UTF-8"); + .to_string(); assert_eq!(ct, "text/css; charset=utf-8"); // Decompress output to verify content was rewritten @@ -1920,28 +2029,28 @@ mod tests { let settings = create_test_settings(); let html = r#""#; - let beresp = Response::from_status(StatusCode::OK) - .with_header(header::CONTENT_TYPE, "text/html; charset=utf-8") - .with_body(html); + let mut beresp = build_http_response(StatusCode::OK, EdgeBody::from(html)); + beresp.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); - let req = Request::new(Method::GET, "https://edge.example/first-party/proxy"); - let mut out = finalize(&settings, &req, "https://cdn.example/a.png", beresp) + let req = build_http_request(Method::GET, "https://edge.example/first-party/proxy"); + let out = finalize(&settings, &req, "https://cdn.example/a.png", beresp) .expect("finalize should succeed"); // No Content-Encoding since input was uncompressed assert!( - out.get_header(header::CONTENT_ENCODING).is_none(), + response_header(&out, header::CONTENT_ENCODING).is_none(), "Content-Encoding should not be set for uncompressed input" ); - let ct = out - .get_header(header::CONTENT_TYPE) + let ct = response_header(&out, header::CONTENT_TYPE) .expect("Content-Type header should be present") - .to_str() - .expect("Content-Type should be valid UTF-8"); + .to_string(); assert_eq!(ct, "text/html; charset=utf-8"); - let body = out.take_body_str(); + let body = response_body_string(out); assert!( body.contains("/first-party/proxy?tsurl="), "HTML should be rewritten: {}", @@ -1962,7 +2071,7 @@ mod tests { Arc::clone(&stub) as Arc ); let settings = create_test_settings(); - let req = Request::new(Method::GET, "https://example.com/"); + let req = build_http_request(Method::GET, "https://example.com/"); let result = proxy_request( &settings, @@ -2226,7 +2335,7 @@ mod tests { urlencoding::encode(target), token, ); - let req = Request::new(Method::GET, url); + let req = build_http_request(Method::GET, url); let services = crate::platform::test_support::noop_services(); let err = handle_first_party_proxy(&settings, &services, req) .await diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 62106c78..57dd1145 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -1,16 +1,16 @@ +use std::time::Duration; + +use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; -use fastly::http::{header, StatusCode}; -use fastly::{Body, Request, Response}; +use http::{header, HeaderValue, Request, Response, StatusCode, Uri}; -use crate::backend::BackendConfig; -use crate::compat; use crate::consent::{allows_ssc_creation, build_consent_context, ConsentPipelineInput}; use crate::constants::{COOKIE_SYNTHETIC_ID, HEADER_X_COMPRESS_HINT, HEADER_X_SYNTHETIC_ID}; use crate::cookies::handle_request_cookies; use crate::error::TrustedServerError; use crate::http_util::{serve_static_with_etag, RequestInfo}; use crate::integrations::IntegrationRegistry; -use crate::platform::RuntimeServices; +use crate::platform::{PlatformBackendSpec, PlatformHttpRequest, RuntimeServices}; use crate::rsc_flight::RscFlightUrlRewriter; use crate::settings::Settings; use crate::streaming_processor::{Compression, PipelineConfig, StreamProcessor, StreamingPipeline}; @@ -18,21 +18,35 @@ use crate::streaming_replacer::create_url_replacer; use crate::synthetic::{get_or_generate_synthetic_id, is_valid_synthetic_id}; const SUPPORTED_ENCODING_VALUES: [&str; 3] = ["gzip", "deflate", "br"]; +const DEFAULT_PUBLISHER_FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(15); + +fn body_as_reader(body: EdgeBody) -> std::io::Cursor { + std::io::Cursor::new(body.into_bytes()) +} + +fn not_found_response() -> Response { + let mut response = Response::new(EdgeBody::from("Not Found")); + *response.status_mut() = StatusCode::NOT_FOUND; + response +} -fn restrict_accept_encoding(req: &mut Request) { +fn restrict_accept_encoding(req: &mut Request) { // If the client sent no Accept-Encoding, leave the request unchanged so the // origin responds without compression. Adding encodings here would cause the // origin to compress its response even though the client never asked for it, // and the client would then receive content it cannot decode. let Some(current) = req - .get_header(header::ACCEPT_ENCODING) + .headers() + .get(header::ACCEPT_ENCODING) .and_then(|value| value.to_str().ok()) + .map(str::to_owned) else { return; }; - req.set_header( + req.headers_mut().insert( header::ACCEPT_ENCODING, - select_supported_accept_encoding(current), + HeaderValue::from_str(&select_supported_accept_encoding(¤t)) + .expect("supported accept-encoding should be a valid header value"), ); } @@ -105,27 +119,25 @@ fn accept_encoding_qvalue(header_value: &str, target: &str) -> Option { /// /// This function never returns an error; the Result type is for API consistency. pub fn handle_tsjs_dynamic( - req: &Request, + req: &Request, integration_registry: &IntegrationRegistry, -) -> Result> { +) -> Result, Report> { const PREFIX: &str = "/static/tsjs="; const UNIFIED_FILENAMES: &[&str] = &["tsjs-unified.js", "tsjs-unified.min.js"]; - let path = req.get_path(); + let path = req.uri().path(); if !path.starts_with(PREFIX) { - return Ok(Response::from_status(StatusCode::NOT_FOUND).with_body("Not Found")); + return Ok(not_found_response()); } let filename = &path[PREFIX.len()..]; - let http_req = compat::from_fastly_headers_ref(req); if UNIFIED_FILENAMES.contains(&filename) { // Serve core + immediate modules (excludes deferred like prebid) let module_ids = integration_registry.js_module_ids_immediate(); let body = trusted_server_js::concatenate_modules(&module_ids); - let http_resp = - serve_static_with_etag(&body, &http_req, "application/javascript; charset=utf-8"); - let mut resp = compat::to_fastly_response(http_resp); - resp.set_header(HEADER_X_COMPRESS_HINT, "on"); + let mut resp = serve_static_with_etag(&body, req, "application/javascript; charset=utf-8"); + resp.headers_mut() + .insert(HEADER_X_COMPRESS_HINT, HeaderValue::from_static("on")); return Ok(resp); } @@ -133,18 +145,18 @@ pub fn handle_tsjs_dynamic( // Only serve if the deferred module is actually enabled let deferred_ids = integration_registry.js_module_ids_deferred(); if !deferred_ids.contains(&module_id) { - return Ok(Response::from_status(StatusCode::NOT_FOUND).with_body("Not Found")); + return Ok(not_found_response()); } if let Some(content) = trusted_server_js::module_bundle(module_id) { - let http_resp = - serve_static_with_etag(content, &http_req, "application/javascript; charset=utf-8"); - let mut resp = compat::to_fastly_response(http_resp); - resp.set_header(HEADER_X_COMPRESS_HINT, "on"); + let mut resp = + serve_static_with_etag(content, req, "application/javascript; charset=utf-8"); + resp.headers_mut() + .insert(HEADER_X_COMPRESS_HINT, HeaderValue::from_static("on")); return Ok(resp); } } - Ok(Response::from_status(StatusCode::NOT_FOUND).with_body("Not Found")) + Ok(not_found_response()) } /// Extract a module ID from a deferred-module filename like `tsjs-prebid.min.js`. @@ -177,9 +189,9 @@ struct ProcessResponseParams<'a> { /// Process response body in streaming fashion with compression preservation fn process_response_streaming( - body: Body, + body: EdgeBody, params: &ProcessResponseParams, -) -> Result> { +) -> Result> { // Check if this is HTML content let is_html = params.content_type.contains("text/html"); let is_rsc_flight = params.content_type.contains("text/x-component"); @@ -216,7 +228,7 @@ fn process_response_streaming( }; let mut pipeline = StreamingPipeline::new(config, processor); - pipeline.process(body, &mut output)?; + pipeline.process(body_as_reader(body), &mut output)?; } else if is_rsc_flight { // RSC Flight responses are length-prefixed (T rows). A naive string replacement will // corrupt the stream by changing byte lengths without updating the prefixes. @@ -234,7 +246,7 @@ fn process_response_streaming( }; let mut pipeline = StreamingPipeline::new(config, processor); - pipeline.process(body, &mut output)?; + pipeline.process(body_as_reader(body), &mut output)?; } else { // Use simple text replacer for non-HTML content let replacer = create_url_replacer( @@ -251,14 +263,14 @@ fn process_response_streaming( }; let mut pipeline = StreamingPipeline::new(config, replacer); - pipeline.process(body, &mut output)?; + pipeline.process(body_as_reader(body), &mut output)?; } log::debug!( "Streaming processing complete - output size: {} bytes", output.len() ); - Ok(Body::from(output)) + Ok(EdgeBody::from(output)) } /// Create a unified HTML stream processor @@ -288,26 +300,29 @@ fn create_html_stream_processor( /// preserving headers and request body. It's used as a fallback for routes /// not explicitly handled by the trusted server. /// +/// This is `async` because it uses `services.http_client().send(...).await` rather +/// than the synchronous Fastly SDK `req.send()`. The only caller wraps the entire +/// route handler in `block_on`, so behavior is equivalent — the change reflects the +/// migration to the platform-agnostic HTTP client. +/// /// # Errors /// /// Returns a [`TrustedServerError`] if: /// - The proxy request fails /// - The origin backend is unreachable -pub fn handle_publisher_request( +pub async fn handle_publisher_request( settings: &Settings, integration_registry: &IntegrationRegistry, services: &RuntimeServices, - mut req: Request, -) -> Result> { + mut req: Request, +) -> Result, Report> { log::debug!("Proxying request to publisher_origin"); // Prebid.js requests are not intercepted here anymore. The HTML processor removes // publisher-supplied Prebid scripts; the unified TSJS bundle includes Prebid.js when enabled. - let http_req = compat::from_fastly_headers_ref(&req); - // Extract request host and scheme (uses Host header and TLS detection after edge sanitization) - let request_info = RequestInfo::from_request(&http_req, &services.client_info); + let request_info = RequestInfo::from_request(&req, services.client_info()); let request_host = &request_info.host; let request_scheme = &request_info.scheme; @@ -315,13 +330,13 @@ pub fn handle_publisher_request( "Request info: host={}, scheme={} (X-Forwarded-Host: {:?}, Host: {:?}, X-Forwarded-Proto: {:?})", request_host, request_scheme, - req.get_header("x-forwarded-host"), - req.get_header(header::HOST), - req.get_header("x-forwarded-proto"), + req.headers().get("x-forwarded-host"), + req.headers().get(header::HOST), + req.headers().get("x-forwarded-proto"), ); // Parse cookies once for reuse by both consent extraction and synthetic ID logic. - let cookie_jar = handle_request_cookies(&http_req)?; + let cookie_jar = handle_request_cookies(&req)?; // Capture the current SSC cookie value for revocation handling. // This must come from the cookie itself (not the x-synthetic-id header) @@ -334,7 +349,7 @@ pub fn handle_publisher_request( // Generate synthetic identifiers before the request body is consumed. // Always generated for internal use (KV lookups, logging) even when // consent is absent — the cookie is only *set* when consent allows it. - let synthetic_id = get_or_generate_synthetic_id(settings, services, &http_req)?; + let synthetic_id = get_or_generate_synthetic_id(settings, services, &req)?; // Extract, decode, and log consent signals (TCF, GPP, US Privacy, GPC) // from the incoming request. The ConsentContext carries both raw strings @@ -343,14 +358,14 @@ pub fn handle_publisher_request( // and falls back to stored consent when cookies are absent. 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 }); let consent_context = build_consent_context(&ConsentPipelineInput { jar: cookie_jar.as_ref(), - req: &http_req, + req: &req, config: &settings.consent, geo: geo.as_ref(), synthetic_id: Some(synthetic_id.as_str()), @@ -362,11 +377,40 @@ pub fn handle_publisher_request( ssc_allowed, ); - let backend_name = BackendConfig::from_url( - &settings.publisher.origin_url, - settings.proxy.certificate_check, + let parsed_origin = url::Url::parse(&settings.publisher.origin_url).change_context( + TrustedServerError::Proxy { + message: format!("Invalid origin_url: {}", settings.publisher.origin_url), + }, )?; + let origin_scheme = parsed_origin.scheme().to_string(); + let origin_host_without_port = parsed_origin.host_str().ok_or_else(|| { + Report::new(TrustedServerError::Proxy { + message: "Missing host in origin_url".to_string(), + }) + })?; + let backend_name = services + .backend() + .ensure(&PlatformBackendSpec { + scheme: origin_scheme.clone(), + host: origin_host_without_port.to_string(), + port: parsed_origin.port(), + certificate_check: settings.proxy.certificate_check, + first_byte_timeout: DEFAULT_PUBLISHER_FIRST_BYTE_TIMEOUT, + }) + .change_context(TrustedServerError::Proxy { + message: "backend registration failed".to_string(), + })?; let origin_host = settings.publisher.origin_host(); + let origin_path_and_query = req + .uri() + .path_and_query() + .map(http::uri::PathAndQuery::as_str) + .unwrap_or("/"); + let target_uri = format!("{origin_scheme}://{origin_host}{origin_path_and_query}") + .parse::() + .change_context(TrustedServerError::Proxy { + message: "invalid publisher origin uri".to_string(), + })?; log::debug!( "Proxying to dynamic backend: {} (from {})", @@ -375,23 +419,33 @@ pub fn handle_publisher_request( ); // Only advertise encodings the rewrite pipeline can decode and re-encode. restrict_accept_encoding(&mut req); - req.set_header("host", &origin_host); + *req.uri_mut() = target_uri; + req.headers_mut().insert( + header::HOST, + HeaderValue::from_str(&origin_host).change_context(TrustedServerError::Proxy { + message: "invalid publisher origin host header".to_string(), + })?, + ); - let mut response = req - .send(&backend_name) + let mut response = services + .http_client() + .send(PlatformHttpRequest::new(req, backend_name)) + .await .change_context(TrustedServerError::Proxy { message: "Failed to proxy request to origin".to_string(), - })?; + })? + .response; // Log all response headers for debugging log::debug!("Response headers:"); - for (name, value) in response.get_headers() { + for (name, value) in response.headers() { log::debug!(" {}: {:?}", name, value); } // Check if the response has a text-based content type that we should process let content_type = response - .get_header(header::CONTENT_TYPE) + .headers() + .get(header::CONTENT_TYPE) .map(|h| h.to_str().unwrap_or_default()) .unwrap_or_default() .to_string(); @@ -403,7 +457,8 @@ pub fn handle_publisher_request( if should_process && !request_host.is_empty() { // Check if the response is compressed let content_encoding = response - .get_header(header::CONTENT_ENCODING) + .headers() + .get(header::CONTENT_ENCODING) .map(|h| h.to_str().unwrap_or_default()) .unwrap_or_default() .to_lowercase(); @@ -415,7 +470,7 @@ pub fn handle_publisher_request( ); // Take the response body for streaming processing - let body = response.take_body(); + let body = std::mem::replace(response.body_mut(), EdgeBody::empty()); // Process the body using streaming approach let params = ProcessResponseParams { @@ -431,10 +486,10 @@ pub fn handle_publisher_request( match process_response_streaming(body, ¶ms) { Ok(processed_body) => { // Set the processed body back - response.set_body(processed_body); + *response.body_mut() = processed_body; // Remove Content-Length as the size has likely changed - response.remove_header(header::CONTENT_LENGTH); + response.headers_mut().remove(header::CONTENT_LENGTH); // Keep Content-Encoding header since we're returning compressed content log::debug!( @@ -463,16 +518,26 @@ pub fn handle_publisher_request( // - Consent absent + existing cookie → revoke (expire cookie + delete KV entry). // - Consent absent + no cookie → do nothing. if ssc_allowed { - // Fastly's HeaderValue API rejects \r, \n, and \0, so the synthetic ID - // cannot inject additional response headers. - response.set_header(HEADER_X_SYNTHETIC_ID, synthetic_id.as_str()); + match HeaderValue::from_str(synthetic_id.as_str()) { + Ok(header_value) => { + response + .headers_mut() + .insert(HEADER_X_SYNTHETIC_ID, header_value); + } + Err(_) => { + log::warn!( + "Rejecting synthetic ID response header: value of {} bytes is not a valid header value", + synthetic_id.len() + ); + } + } // Cookie persistence is skipped if the synthetic ID contains RFC 6265-illegal // characters. The header is still emitted when consent allows it. - compat::set_fastly_synthetic_cookie(settings, &mut response, synthetic_id.as_str()); + crate::cookies::set_synthetic_cookie(settings, &mut response, synthetic_id.as_str()); } else if let Some(cookie_synthetic_id) = existing_ssc_cookie.as_deref() { // Always expire the cookie — consent is withdrawn regardless of whether the // stored value is well-formed. - compat::expire_fastly_synthetic_cookie(settings, &mut response); + crate::cookies::expire_synthetic_cookie(settings, &mut response); if is_valid_synthetic_id(cookie_synthetic_id) { log::info!( "SSC revoked: consent withdrawn (jurisdiction={})", @@ -502,9 +567,26 @@ pub fn handle_publisher_request( mod tests { use super::*; use crate::integrations::IntegrationRegistry; - use crate::platform::test_support::noop_services; + use crate::platform::test_support::{ + build_services_with_http_client, noop_services, StubHttpClient, + }; use crate::test_support::tests::{create_test_settings, VALID_SYNTHETIC_ID}; - use fastly::http::{header, Method, StatusCode}; + use edgezero_core::body::Body as EdgeBody; + use http::{header, Method, Request as HttpRequest, StatusCode}; + use std::sync::Arc; + + fn build_request(method: Method, uri: &str) -> HttpRequest { + HttpRequest::builder() + .method(method) + .uri(uri) + .body(EdgeBody::empty()) + .expect("should build test request") + } + + fn response_body_string(response: http::Response) -> String { + String::from_utf8(response.into_body().into_bytes().to_vec()) + .expect("response body should be valid UTF-8") + } #[test] fn test_content_type_detection() { @@ -602,15 +684,23 @@ mod tests { let test_encodings = vec!["gzip", "deflate", "br", "identity", ""]; for encoding in test_encodings { - let mut req = Request::new(Method::GET, "https://test.example.com/page"); - req.set_header("accept-encoding", "gzip, deflate, br"); + let mut req = build_request(Method::GET, "https://test.example.com/page"); + req.headers_mut().insert( + header::ACCEPT_ENCODING, + http::HeaderValue::from_static("gzip, deflate, br"), + ); if !encoding.is_empty() { - req.set_header("content-encoding", encoding); + req.headers_mut().insert( + header::CONTENT_ENCODING, + http::HeaderValue::from_str(encoding) + .expect("content encoding should be valid"), + ); } let content_encoding = req - .get_header("content-encoding") + .headers() + .get(header::CONTENT_ENCODING) .map(|h| h.to_str().unwrap_or_default()) .unwrap_or_default(); @@ -620,13 +710,13 @@ mod tests { #[test] fn publisher_proxy_does_not_add_accept_encoding_when_absent() { - let mut req = Request::new(Method::GET, "https://test.example.com/page"); + let mut req = build_request(Method::GET, "https://test.example.com/page"); // No Accept-Encoding header set by the client. restrict_accept_encoding(&mut req); assert_eq!( - req.get_header_str(header::ACCEPT_ENCODING), + req.headers().get(header::ACCEPT_ENCODING), None, "publisher proxy should not inject Accept-Encoding when the client sent none" ); @@ -634,13 +724,18 @@ mod tests { #[test] fn publisher_proxy_limits_accept_encoding_to_supported_values() { - let mut req = Request::new(Method::GET, "https://test.example.com/page"); - req.set_header(header::ACCEPT_ENCODING, "gzip, deflate, br, zstd"); + let mut req = build_request(Method::GET, "https://test.example.com/page"); + req.headers_mut().insert( + header::ACCEPT_ENCODING, + http::HeaderValue::from_static("gzip, deflate, br, zstd"), + ); restrict_accept_encoding(&mut req); assert_eq!( - req.get_header_str(header::ACCEPT_ENCODING), + req.headers() + .get(header::ACCEPT_ENCODING) + .and_then(|value| value.to_str().ok()), Some("gzip, deflate, br"), "publisher fallback should only advertise encodings the rewrite pipeline supports" ); @@ -648,13 +743,18 @@ mod tests { #[test] fn publisher_proxy_preserves_identity_only_accept_encoding() { - let mut req = Request::new(Method::GET, "https://test.example.com/page"); - req.set_header(header::ACCEPT_ENCODING, "identity"); + let mut req = build_request(Method::GET, "https://test.example.com/page"); + req.headers_mut().insert( + header::ACCEPT_ENCODING, + http::HeaderValue::from_static("identity"), + ); restrict_accept_encoding(&mut req); assert_eq!( - req.get_header_str(header::ACCEPT_ENCODING), + req.headers() + .get(header::ACCEPT_ENCODING) + .and_then(|value| value.to_str().ok()), Some("identity"), "publisher fallback should preserve identity-only clients" ); @@ -662,13 +762,18 @@ mod tests { #[test] fn publisher_proxy_respects_supported_client_subset() { - let mut req = Request::new(Method::GET, "https://test.example.com/page"); - req.set_header(header::ACCEPT_ENCODING, "br, gzip;q=0, zstd"); + let mut req = build_request(Method::GET, "https://test.example.com/page"); + req.headers_mut().insert( + header::ACCEPT_ENCODING, + http::HeaderValue::from_static("br, gzip;q=0, zstd"), + ); restrict_accept_encoding(&mut req); assert_eq!( - req.get_header_str(header::ACCEPT_ENCODING), + req.headers() + .get(header::ACCEPT_ENCODING) + .and_then(|value| value.to_str().ok()), Some("br"), "publisher fallback should only advertise the supported encodings the client accepts" ); @@ -676,13 +781,18 @@ mod tests { #[test] fn publisher_proxy_falls_back_to_identity_for_unsupported_client_encodings() { - let mut req = Request::new(Method::GET, "https://test.example.com/page"); - req.set_header(header::ACCEPT_ENCODING, "zstd"); + let mut req = build_request(Method::GET, "https://test.example.com/page"); + req.headers_mut().insert( + header::ACCEPT_ENCODING, + http::HeaderValue::from_static("zstd"), + ); restrict_accept_encoding(&mut req); assert_eq!( - req.get_header_str(header::ACCEPT_ENCODING), + req.headers() + .get(header::ACCEPT_ENCODING) + .and_then(|value| value.to_str().ok()), Some("identity"), "publisher fallback should request identity when the client only accepts unsupported encodings" ); @@ -693,23 +803,27 @@ mod tests { let settings = create_test_settings(); let cookie_synthetic_id = "b2a1c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0b1a2.Zx98y7"; - let mut req = Request::new(Method::GET, "https://test.example.com/page"); - req.set_header(HEADER_X_SYNTHETIC_ID, VALID_SYNTHETIC_ID); - req.set_header( + let mut req = build_request(Method::GET, "https://test.example.com/page"); + req.headers_mut().insert( + header::HeaderName::from_static("x-synthetic-id"), + http::HeaderValue::from_static(VALID_SYNTHETIC_ID), + ); + req.headers_mut().insert( header::COOKIE, - format!("synthetic_id={cookie_synthetic_id}; other=value"), + http::HeaderValue::from_str(&format!( + "synthetic_id={cookie_synthetic_id}; other=value" + )) + .expect("cookie header should be valid"), ); - let http_req = compat::from_fastly_headers_ref(&req); - let cookie_jar = handle_request_cookies(&http_req).expect("should parse cookies"); + let cookie_jar = handle_request_cookies(&req).expect("should parse cookies"); let existing_ssc_cookie = cookie_jar .as_ref() .and_then(|jar| jar.get(COOKIE_SYNTHETIC_ID)) .map(|cookie| cookie.value().to_owned()); - let resolved_synthetic_id = - get_or_generate_synthetic_id(&settings, &noop_services(), &http_req) - .expect("should resolve synthetic id"); + let resolved_synthetic_id = get_or_generate_synthetic_id(&settings, &noop_services(), &req) + .expect("should resolve synthetic id"); assert_eq!( existing_ssc_cookie.as_deref(), @@ -727,13 +841,13 @@ mod tests { let settings = create_test_settings(); let registry = IntegrationRegistry::new(&settings).expect("should create integration registry"); - let req = Request::new( + let req = build_request( Method::GET, "https://publisher.example/static/tsjs=unknown.js", ); let response = handle_tsjs_dynamic(&req, ®istry).expect("should handle tsjs request"); - assert_eq!(response.get_status(), StatusCode::NOT_FOUND); + assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[test] @@ -741,13 +855,13 @@ mod tests { let settings = create_test_settings(); let registry = IntegrationRegistry::new(&settings).expect("should create integration registry"); - let req = Request::new( + let req = build_request( Method::GET, "https://publisher.example/static/tsjs=tsjs-unified.min.js", ); let response = handle_tsjs_dynamic(&req, ®istry).expect("should handle tsjs request"); - assert_eq!(response.get_status(), StatusCode::OK); + assert_eq!(response.status(), StatusCode::OK); } #[test] @@ -794,14 +908,14 @@ mod tests { let settings = create_test_settings(); let registry = IntegrationRegistry::new(&settings).expect("should create integration registry"); - let req = Request::new( + let req = build_request( Method::GET, "https://publisher.example/static/tsjs=tsjs-prebid.min.js", ); let response = handle_tsjs_dynamic(&req, ®istry).expect("should handle tsjs request"); assert_eq!( - response.get_status(), + response.status(), StatusCode::OK, "should serve deferred prebid module when enabled" ); @@ -822,14 +936,14 @@ mod tests { .expect("should update prebid config"); let registry = IntegrationRegistry::new(&settings).expect("should create integration registry"); - let req = Request::new( + let req = build_request( Method::GET, "https://publisher.example/static/tsjs=tsjs-prebid.min.js", ); let response = handle_tsjs_dynamic(&req, ®istry).expect("should handle tsjs request"); assert_eq!( - response.get_status(), + response.status(), StatusCode::NOT_FOUND, "should return 404 for disabled deferred module" ); @@ -840,16 +954,46 @@ mod tests { let settings = create_test_settings(); let registry = IntegrationRegistry::new(&settings).expect("should create integration registry"); - let req = Request::new( + let req = build_request( Method::GET, "https://publisher.example/static/tsjs=tsjs-evil.min.js", ); let response = handle_tsjs_dynamic(&req, ®istry).expect("should handle tsjs request"); assert_eq!( - response.get_status(), + response.status(), StatusCode::NOT_FOUND, "should reject unknown module names" ); } + + #[tokio::test] + async fn publisher_request_uses_platform_http_client_with_http_types() { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"origin response".to_vec()); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let req = HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/page") + .header(header::HOST, "publisher.example") + .body(EdgeBody::empty()) + .expect("should build request"); + + let response = handle_publisher_request(&settings, ®istry, &services, req) + .await + .expect("should proxy publisher request"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response_body_string(response), "origin response"); + assert_eq!( + stub.recorded_backend_names(), + vec!["stub-backend".to_string()], + "should proxy through the platform http client" + ); + } } diff --git a/crates/trusted-server-core/src/request_signing/endpoints.rs b/crates/trusted-server-core/src/request_signing/endpoints.rs index 949ba9e5..110fe301 100644 --- a/crates/trusted-server-core/src/request_signing/endpoints.rs +++ b/crates/trusted-server-core/src/request_signing/endpoints.rs @@ -3,8 +3,9 @@ //! This module provides endpoint handlers for JWKS retrieval, signature verification, //! key rotation, and key deactivation operations. +use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; -use fastly::{Request, Response}; +use http::{header, Request, Response, StatusCode}; use serde::{Deserialize, Serialize}; use crate::error::TrustedServerError; @@ -14,6 +15,14 @@ use crate::request_signing::rotation::KeyRotationManager; use crate::request_signing::signing; use crate::settings::Settings; +fn json_response(status: StatusCode, body: String) -> Response { + Response::builder() + .status(status) + .header(header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) + .body(EdgeBody::from(body.into_bytes())) + .expect("should build json response") +} + /// Retrieves and returns the trusted-server discovery document. /// /// This endpoint provides a standardized discovery mechanism following the IAB @@ -26,8 +35,8 @@ use crate::settings::Settings; pub fn handle_trusted_server_discovery( _settings: &Settings, services: &RuntimeServices, - _req: Request, -) -> Result> { + _req: Request, +) -> Result, Report> { let jwks_json = crate::request_signing::jwks::get_active_jwks(services).change_context( TrustedServerError::Configuration { message: "failed to retrieve JWKS".into(), @@ -47,9 +56,7 @@ pub fn handle_trusted_server_discovery( }, )?; - Ok(Response::from_status(200) - .with_content_type(mime::APPLICATION_JSON) - .with_body(json)) + Ok(json_response(StatusCode::OK, json)) } #[derive(Debug, Deserialize, Serialize)] @@ -77,11 +84,11 @@ pub struct VerifySignatureResponse { pub fn handle_verify_signature( _settings: &Settings, services: &RuntimeServices, - mut req: Request, -) -> Result> { - let body = req.take_body_str(); + req: Request, +) -> Result, Report> { + let body = req.into_body().into_bytes(); let verify_req: VerifySignatureRequest = - serde_json::from_str(&body).change_context(TrustedServerError::Configuration { + serde_json::from_slice(&body).change_context(TrustedServerError::Configuration { message: "invalid JSON request body".into(), })?; @@ -119,9 +126,7 @@ pub fn handle_verify_signature( }) })?; - Ok(Response::from_status(200) - .with_content_type(mime::APPLICATION_JSON) - .with_body(response_json)) + Ok(json_response(StatusCode::OK, response_json)) } #[derive(Debug, Deserialize, Serialize)] @@ -150,8 +155,8 @@ pub struct RotateKeyResponse { pub fn handle_rotate_key( settings: &Settings, services: &RuntimeServices, - mut req: Request, -) -> Result> { + req: Request, +) -> Result, Report> { let (config_store_id, secret_store_id) = match &settings.request_signing { Some(setting) => (&setting.config_store_id, &setting.secret_store_id), None => { @@ -162,11 +167,11 @@ pub fn handle_rotate_key( } }; - let body = req.take_body_str(); + let body = req.into_body().into_bytes(); let rotate_req: RotateKeyRequest = if body.is_empty() { RotateKeyRequest { kid: None } } else { - serde_json::from_str(&body).change_context(TrustedServerError::Configuration { + serde_json::from_slice(&body).change_context(TrustedServerError::Configuration { message: "invalid JSON request body".into(), })? }; @@ -197,9 +202,7 @@ pub fn handle_rotate_key( }) })?; - Ok(Response::from_status(200) - .with_content_type(mime::APPLICATION_JSON) - .with_body(response_json)) + Ok(json_response(StatusCode::OK, response_json)) } Err(e) => { let response = RotateKeyResponse { @@ -218,9 +221,10 @@ pub fn handle_rotate_key( }) })?; - Ok(Response::from_status(500) - .with_content_type(mime::APPLICATION_JSON) - .with_body(response_json)) + Ok(json_response( + StatusCode::INTERNAL_SERVER_ERROR, + response_json, + )) } } } @@ -251,8 +255,8 @@ pub struct DeactivateKeyResponse { pub fn handle_deactivate_key( settings: &Settings, services: &RuntimeServices, - mut req: Request, -) -> Result> { + req: Request, +) -> Result, Report> { let (config_store_id, secret_store_id) = match &settings.request_signing { Some(setting) => (&setting.config_store_id, &setting.secret_store_id), None => { @@ -263,9 +267,9 @@ pub fn handle_deactivate_key( } }; - let body = req.take_body_str(); + let body = req.into_body().into_bytes(); let deactivate_req: DeactivateKeyRequest = - serde_json::from_str(&body).change_context(TrustedServerError::Configuration { + serde_json::from_slice(&body).change_context(TrustedServerError::Configuration { message: "invalid JSON request body".into(), })?; @@ -303,9 +307,7 @@ pub fn handle_deactivate_key( }) })?; - Ok(Response::from_status(200) - .with_content_type(mime::APPLICATION_JSON) - .with_body(response_json)) + Ok(json_response(StatusCode::OK, response_json)) } Err(e) => { let response = DeactivateKeyResponse { @@ -327,9 +329,10 @@ pub fn handle_deactivate_key( }) })?; - Ok(Response::from_status(500) - .with_content_type(mime::APPLICATION_JSON) - .with_body(response_json)) + Ok(json_response( + StatusCode::INTERNAL_SERVER_ERROR, + response_json, + )) } } } @@ -338,7 +341,9 @@ pub fn handle_deactivate_key( mod tests { use std::collections::HashMap; + use edgezero_core::body::Body as EdgeBody; use error_stack::Report; + use http::{header, Method, Request as HttpRequest, StatusCode}; use crate::platform::{ test_support::{ @@ -348,7 +353,35 @@ mod tests { }; use super::*; - use fastly::http::{Method, StatusCode}; + + fn build_request(method: Method, uri: &str, body: Option<&str>) -> HttpRequest { + let body = match body { + Some(body) => EdgeBody::from(body.as_bytes().to_vec()), + None => EdgeBody::empty(), + }; + + HttpRequest::builder() + .method(method) + .uri(uri) + .body(body) + .expect("should build request") + } + + fn response_body_string(response: http::Response) -> String { + String::from_utf8(response.into_body().into_bytes().to_vec()) + .expect("should decode response body") + } + + fn assert_json_content_type(response: &http::Response) { + assert_eq!( + response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some(mime::APPLICATION_JSON.as_ref()), + "should return application/json content type" + ); + } /// Build `RuntimeServices` pre-loaded with a real Ed25519 keypair for /// testing signature creation and verification in endpoint handlers. @@ -454,19 +487,18 @@ mod tests { }; let body = serde_json::to_string(&verify_req).expect("should serialize verify request"); - let mut req = Request::new(Method::POST, "https://test.com/verify-signature"); - req.set_body(body); + let req = build_request( + Method::POST, + "https://test.com/verify-signature", + Some(&body), + ); - let mut resp = handle_verify_signature(&settings, &services, req) + let resp = handle_verify_signature(&settings, &services, req) .expect("should handle verification request"); - assert_eq!(resp.get_status(), StatusCode::OK); - assert_eq!( - resp.get_content_type(), - Some(mime::APPLICATION_JSON), - "should return application/json content type" - ); + assert_eq!(resp.status(), StatusCode::OK); + assert_json_content_type(&resp); - let resp_body = resp.take_body_str(); + let resp_body = response_body_string(resp); let verify_resp: VerifySignatureResponse = serde_json::from_str(&resp_body).expect("should deserialize verify response"); @@ -494,19 +526,18 @@ mod tests { }; let body = serde_json::to_string(&verify_req).expect("should serialize verify request"); - let mut req = Request::new(Method::POST, "https://test.com/verify-signature"); - req.set_body(body); + let req = build_request( + Method::POST, + "https://test.com/verify-signature", + Some(&body), + ); - let mut resp = handle_verify_signature(&settings, &services, req) + let resp = handle_verify_signature(&settings, &services, req) .expect("should handle verification request"); - assert_eq!(resp.get_status(), StatusCode::OK); - assert_eq!( - resp.get_content_type(), - Some(mime::APPLICATION_JSON), - "should return application/json content type" - ); + assert_eq!(resp.status(), StatusCode::OK); + assert_json_content_type(&resp); - let resp_body = resp.take_body_str(); + let resp_body = response_body_string(resp); let verify_resp: VerifySignatureResponse = serde_json::from_str(&resp_body).expect("should deserialize verify response"); @@ -522,8 +553,11 @@ mod tests { fn test_handle_verify_signature_malformed_request() { let settings = crate::test_support::tests::create_test_settings(); - let mut req = Request::new(Method::POST, "https://test.com/verify-signature"); - req.set_body("not valid json"); + let req = build_request( + Method::POST, + "https://test.com/verify-signature", + Some("not valid json"), + ); let result = handle_verify_signature(&settings, &noop_services(), req); assert!(result.is_err(), "Malformed JSON should error"); @@ -532,12 +566,12 @@ mod tests { #[test] fn test_handle_rotate_key_with_empty_body() { let settings = crate::test_support::tests::create_test_settings(); - let req = Request::new(Method::POST, "https://test.com/admin/keys/rotate"); + let req = build_request(Method::POST, "https://test.com/admin/keys/rotate", None); let result = handle_rotate_key(&settings, &noop_services(), req); match result { - Ok(mut resp) => { - let body = resp.take_body_str(); + Ok(resp) => { + let body = response_body_string(resp); let response: RotateKeyResponse = serde_json::from_str(&body).expect("should deserialize rotate response"); log::debug!( @@ -559,13 +593,16 @@ mod tests { }; let body_json = serde_json::to_string(&req_body).expect("should serialize rotate request"); - let mut req = Request::new(Method::POST, "https://test.com/admin/keys/rotate"); - req.set_body(body_json); + let req = build_request( + Method::POST, + "https://test.com/admin/keys/rotate", + Some(&body_json), + ); let result = handle_rotate_key(&settings, &noop_services(), req); match result { - Ok(mut resp) => { - let body = resp.take_body_str(); + Ok(resp) => { + let body = response_body_string(resp); let response: RotateKeyResponse = serde_json::from_str(&body).expect("should deserialize rotate response"); log::debug!( @@ -581,8 +618,11 @@ mod tests { #[test] fn test_handle_rotate_key_invalid_json() { let settings = crate::test_support::tests::create_test_settings(); - let mut req = Request::new(Method::POST, "https://test.com/admin/keys/rotate"); - req.set_body("invalid json"); + let req = build_request( + Method::POST, + "https://test.com/admin/keys/rotate", + Some("invalid json"), + ); let result = handle_rotate_key(&settings, &noop_services(), req); assert!(result.is_err(), "Invalid JSON should return error"); @@ -599,13 +639,16 @@ mod tests { let body_json = serde_json::to_string(&req_body).expect("should serialize deactivate request"); - let mut req = Request::new(Method::POST, "https://test.com/admin/keys/deactivate"); - req.set_body(body_json); + let req = build_request( + Method::POST, + "https://test.com/admin/keys/deactivate", + Some(&body_json), + ); let result = handle_deactivate_key(&settings, &noop_services(), req); match result { - Ok(mut resp) => { - let body = resp.take_body_str(); + Ok(resp) => { + let body = response_body_string(resp); let response: DeactivateKeyResponse = serde_json::from_str(&body).expect("should deserialize deactivate response"); log::debug!( @@ -629,13 +672,16 @@ mod tests { let body_json = serde_json::to_string(&req_body).expect("should serialize deactivate request"); - let mut req = Request::new(Method::POST, "https://test.com/admin/keys/deactivate"); - req.set_body(body_json); + let req = build_request( + Method::POST, + "https://test.com/admin/keys/deactivate", + Some(&body_json), + ); let result = handle_deactivate_key(&settings, &noop_services(), req); match result { - Ok(mut resp) => { - let body = resp.take_body_str(); + Ok(resp) => { + let body = response_body_string(resp); let response: DeactivateKeyResponse = serde_json::from_str(&body).expect("should deserialize deactivate response"); log::debug!( @@ -651,8 +697,11 @@ mod tests { #[test] fn test_handle_deactivate_key_invalid_json() { let settings = crate::test_support::tests::create_test_settings(); - let mut req = Request::new(Method::POST, "https://test.com/admin/keys/deactivate"); - req.set_body("invalid json"); + let req = build_request( + Method::POST, + "https://test.com/admin/keys/deactivate", + Some("invalid json"), + ); let result = handle_deactivate_key(&settings, &noop_services(), req); assert!(result.is_err(), "Invalid JSON should return error"); @@ -678,22 +727,19 @@ mod tests { #[test] fn test_handle_trusted_server_discovery() { let settings = crate::test_support::tests::create_test_settings(); - let req = Request::new( + let req = build_request( Method::GET, "https://test.com/.well-known/trusted-server.json", + None, ); let services = noop_services(); let result = handle_trusted_server_discovery(&settings, &services, req); match result { - Ok(mut resp) => { - assert_eq!(resp.get_status(), StatusCode::OK); - assert_eq!( - resp.get_content_type(), - Some(mime::APPLICATION_JSON), - "should return application/json content type" - ); - let body = resp.take_body_str(); + Ok(resp) => { + assert_eq!(resp.status(), StatusCode::OK); + assert_json_content_type(&resp); + let body = response_body_string(resp); // Parse the discovery document let discovery: serde_json::Value = @@ -714,18 +760,19 @@ mod tests { #[test] fn test_handle_trusted_server_discovery_returns_jwks_document() { let settings = crate::test_support::tests::create_test_settings(); - let req = Request::new( + let req = build_request( Method::GET, "https://test.com/.well-known/trusted-server.json", + None, ); let services = build_services_with_config(StubJwksConfigStore); - let mut resp = handle_trusted_server_discovery(&settings, &services, req) + let resp = handle_trusted_server_discovery(&settings, &services, req) .expect("should return discovery document when config store is populated"); - assert_eq!(resp.get_status(), StatusCode::OK, "should return 200 OK"); + assert_eq!(resp.status(), StatusCode::OK, "should return 200 OK"); - let body = resp.take_body_str(); + let body = response_body_string(resp); let discovery: serde_json::Value = serde_json::from_str(&body).expect("should parse discovery document as JSON"); diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 6f14dfac..ec54d72d 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -483,6 +483,19 @@ impl Settings { handler.prepare_runtime()?; } + for (name, value) in &self.response_headers { + http::header::HeaderName::from_bytes(name.as_bytes()).map_err(|_| { + Report::new(TrustedServerError::Configuration { + message: format!("Invalid response header name: {name}"), + }) + })?; + http::header::HeaderValue::from_str(value).map_err(|_| { + Report::new(TrustedServerError::Configuration { + message: format!("Invalid response header value for {name}"), + }) + })?; + } + Ok(()) }