From ff782e0f1e9a2a005fcf7b32128d9ca7795dc45d Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 8 Jul 2026 14:46:47 +0530 Subject: [PATCH 01/44] Quantize auction transport timeouts to stabilize Fastly backend names Fastly dynamic backend names embed the first-byte and between-bytes timeouts so a registration can never be silently reused with a different transport configuration. Deriving those timeouts from the remaining wall-clock auction budget minted a new backend name on nearly every request, defeating cross-request TCP/TLS connection reuse (Fastly pools connections per backend name) and accumulating registrations toward the per-service dynamic backend limit. Compute the effective transport timeout from the configured provider timeout verbatim when the budget allows, floor the budget-bound value to 250ms buckets otherwise, and pass sub-quantum remainders through exactly so publishers with sub-250ms configured budgets keep launching. The quantized value feeds both the backend name and the registered configuration, so they cannot diverge. Rounding down never extends a transport cap past the auction deadline, which the mediator and dispatched-collect paths rely on to bound the hold. Also add a Fastly platform test pinning predict_name == ensure for the same spec, since the orchestrator maps responses back to providers by predicted backend name. Fixes #847 --- .../src/platform.rs | 31 + .../src/auction/orchestrator.rs | 628 ++++++++++++++++-- 2 files changed, 617 insertions(+), 42 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index 106ced78..c5bb60b9 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -736,6 +736,37 @@ mod tests { ); } + #[test] + fn predict_name_matches_ensured_backend_name() { + // The auction orchestrator maps responses back to providers by the + // predicted backend name, so predict_name and ensure must return the + // identical string for the same spec — a divergence would make + // responses land in the "unknown backend" branch and drop bids + // silently. + let backend = FastlyPlatformBackend; + let spec = PlatformBackendSpec { + scheme: "https".to_string(), + host: "consistency.example.com".to_string(), + port: None, + host_header_override: None, + certificate_check: true, + first_byte_timeout: Duration::from_millis(750), + between_bytes_timeout: Duration::from_millis(750), + }; + + let predicted = backend + .predict_name(&spec) + .expect("should predict backend name"); + let ensured = backend + .ensure(&spec) + .expect("should register backend for valid spec"); + + assert_eq!( + predicted, ensured, + "predicted backend name should match the registered backend name" + ); + } + // --- FastlyPlatformHttpClient ------------------------------------------- #[test] diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 145059d9..d884a022 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -157,6 +157,64 @@ fn remaining_budget_ms(start: Instant, timeout_ms: u32) -> u32 { timeout_ms.saturating_sub(elapsed) } +/// Transport-timeout quantum for auction backends. +/// +/// See [`quantize_transport_timeout_ms`] for why provider transport timeouts +/// are rounded to this granularity. +const TRANSPORT_TIMEOUT_QUANTUM_MS: u32 = 250; + +/// Round a transport timeout down to a [`TRANSPORT_TIMEOUT_QUANTUM_MS`] multiple. +/// +/// The Fastly adapter embeds the first-byte and between-bytes timeouts in the +/// dynamic backend name so a registration can never be silently reused with a +/// different transport configuration. Deriving those timeouts from the +/// remaining wall-clock budget minted a new backend name on nearly every +/// request, which defeated cross-request TCP/TLS connection reuse (Fastly +/// pools connections per backend name) and accumulated registrations toward +/// the per-service dynamic backend limit. +/// +/// Quantizing the value — not just the name — keeps the registered backend +/// configuration aligned with its name. Rounding down never extends a +/// transport cap past the auction deadline, which matters on the mediator and +/// dispatched-collect paths where the backend timeouts (not a select-loop +/// deadline check) bound the `` hold. +#[inline] +fn quantize_transport_timeout_ms(timeout_ms: u32) -> u32 { + (timeout_ms / TRANSPORT_TIMEOUT_QUANTUM_MS) * TRANSPORT_TIMEOUT_QUANTUM_MS +} + +/// Compute the transport timeout for a provider launch from the remaining +/// auction budget and the provider's configured timeout. +/// +/// The configured timeout is a per-provider constant, so using it verbatim +/// already yields a stable backend name — including configured values below +/// one quantum, which must not be rounded away or the provider could never +/// launch. Only when the remaining budget is the binding constraint does the +/// wall-clock-derived value enter the name, and that value is quantized via +/// [`quantize_transport_timeout_ms`] so it cannot mint a new backend name on +/// every request. +/// +/// A remaining budget below one quantum is passed through exactly rather +/// than rounded to zero: rounding up would extend the transport cap past the +/// deadline, and rounding down would skip the launch and hard-fail auctions +/// whose configured budget is under one quantum. Name churn in this regime +/// is bounded to sub-quantum values and matches the pre-quantization +/// behavior. The result never exceeds `remaining_ms` and is zero only when +/// `remaining_ms` or `configured_ms` is zero, which callers treat as +/// "budget exhausted — skip the launch". +#[inline] +fn effective_transport_timeout_ms(remaining_ms: u32, configured_ms: u32) -> u32 { + if remaining_ms >= configured_ms { + return configured_ms; + } + let quantized = quantize_transport_timeout_ms(remaining_ms); + if quantized == 0 { + remaining_ms + } else { + quantized + } +} + /// Manages auction execution across multiple providers. pub struct AuctionOrchestrator { config: AuctionConfig, @@ -279,10 +337,14 @@ impl AuctionOrchestrator { // Give the mediator only the remaining time from the auction // deadline, not the full timeout — the bidding phase already - // consumed part of it. + // consumed part of it, and the mediator has no select-loop + // deadline backstop. Quantized for backend-name stability (see + // effective_transport_timeout_ms). let remaining_ms = remaining_budget_ms(mediation_start, context.timeout_ms); + let mediator_timeout = + effective_transport_timeout_ms(remaining_ms, mediator.timeout_ms()); - if remaining_ms == 0 { + if mediator_timeout == 0 { log::warn!("Auction timeout exhausted during bidding phase; skipping mediator"); let winning = self.select_winning_bids(&provider_responses, &floor_prices); return Ok(OrchestrationResult { @@ -297,9 +359,7 @@ impl AuctionOrchestrator { let mediator_context = AuctionContext { settings: context.settings, request: context.request, - // Bound by both the remaining auction budget and the mediator's - // own configured timeout, matching the dispatched collect path. - timeout_ms: remaining_ms.min(mediator.timeout_ms()), + timeout_ms: mediator_timeout, provider_responses: Some(&provider_responses), services: context.services, }; @@ -465,10 +525,11 @@ impl AuctionOrchestrator { // Give each provider only the remaining time from the auction // deadline so that backend transport timeouts do not extend past - // the overall budget. Also respect the provider's own configured - // timeout when it is tighter than the remaining budget. + // the overall budget, quantized for backend-name stability (see + // effective_transport_timeout_ms). let remaining_ms = remaining_budget_ms(auction_start, context.timeout_ms); - let effective_timeout = remaining_ms.min(provider.timeout_ms()); + let effective_timeout = + effective_transport_timeout_ms(remaining_ms, provider.timeout_ms()); if effective_timeout == 0 { log::warn!("Auction timeout exhausted before launching provider request; skipping"); @@ -876,8 +937,11 @@ impl AuctionOrchestrator { continue; } + // Remaining budget quantized for backend-name stability (see + // effective_transport_timeout_ms). let remaining_ms = remaining_budget_ms(auction_start, context.timeout_ms); - let effective_timeout = remaining_ms.min(provider.timeout_ms()); + let effective_timeout = + effective_transport_timeout_ms(remaining_ms, provider.timeout_ms()); if effective_timeout == 0 { log::warn!( @@ -1132,8 +1196,15 @@ impl AuctionOrchestrator { // timeout) at dispatch time, so they cannot run past A_deadline // independently. Giving the mediator an uncapped timeout lets it run // past A_deadline, violating the bounded hold invariant. + // The mediator's only time bound on this path is its + // backend transport timeout, so the effective value must + // never exceed the remaining budget. Quantized for + // backend-name stability (see + // effective_transport_timeout_ms). let remaining = remaining_budget_ms(auction_start, timeout_ms); - if remaining == 0 { + let mediator_timeout = + effective_transport_timeout_ms(remaining, mediator.timeout_ms()); + if mediator_timeout == 0 { log::warn!( "A_deadline exhausted before mediator '{}' — returning {} SSP bids without mediation", mediator.provider_name(), @@ -1148,7 +1219,6 @@ impl AuctionOrchestrator { metadata: HashMap::new(), }; } - let mediator_timeout = remaining.min(mediator.timeout_ms()); let mediator_start = Instant::now(); log::info!( "Running mediator '{}' with {}ms budget (A_deadline remaining: {}ms, configured: {}ms)", @@ -1334,7 +1404,7 @@ mod tests { use crate::test_support::tests::crate_test_settings_str; use error_stack::{Report, ResultExt}; use std::collections::{HashMap, HashSet}; - use std::sync::Arc; + use std::sync::{Arc, Mutex}; use super::AuctionOrchestrator; @@ -1342,9 +1412,49 @@ mod tests { // Minimal test double for AuctionProvider // --------------------------------------------------------------------------- + /// Minimal stub provider. Optionally records every transport timeout it + /// observes — the value passed to `backend_name` and the + /// `context.timeout_ms` handed to `request_bids` — so tests can assert + /// the orchestrator quantizes them. struct StubAuctionProvider { name: &'static str, backend: &'static str, + configured_timeout_ms: u32, + observed_timeouts: Option>>>, + } + + impl StubAuctionProvider { + fn new(name: &'static str, backend: &'static str) -> Self { + Self { + name, + backend, + configured_timeout_ms: 2000, + observed_timeouts: None, + } + } + + fn recording( + name: &'static str, + backend: &'static str, + configured_timeout_ms: u32, + observed_timeouts: Arc>>, + ) -> Self { + Self { + name, + backend, + configured_timeout_ms, + observed_timeouts: Some(observed_timeouts), + } + } + + fn record(&self, timeout_ms: u32) { + if let Some(observed) = &self.observed_timeouts { + observed + .lock() + .expect("should lock observed timeouts") + .push(timeout_ms); + } + } } #[async_trait::async_trait(?Send)] @@ -1358,6 +1468,7 @@ mod tests { _request: &AuctionRequest, context: &AuctionContext<'_>, ) -> Result> { + self.record(context.timeout_ms); let req = PlatformHttpRequest::new( http::Request::builder() .method("POST") @@ -1389,10 +1500,11 @@ mod tests { } fn timeout_ms(&self) -> u32 { - 2000 + self.configured_timeout_ms } - fn backend_name(&self, _services: &RuntimeServices, _timeout_ms: u32) -> Option { + fn backend_name(&self, _services: &RuntimeServices, timeout_ms: u32) -> Option { + self.record(timeout_ms); Some(self.backend.to_string()) } } @@ -1509,10 +1621,10 @@ mod tests { ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "bidder", - backend: "bidder-backend", - })); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "bidder", + "bidder-backend", + ))); orchestrator.register_provider(Arc::new(CacheRestoringMediator)); let request = create_test_auction_request(); @@ -1926,6 +2038,438 @@ mod tests { ); } + #[test] + fn quantize_transport_timeout_floors_to_quantum() { + assert_eq!( + super::quantize_transport_timeout_ms(0), + 0, + "should keep zero at zero" + ); + assert_eq!( + super::quantize_transport_timeout_ms(249), + 0, + "should floor a sub-quantum budget to zero" + ); + assert_eq!( + super::quantize_transport_timeout_ms(250), + 250, + "should keep an exact quantum multiple unchanged" + ); + assert_eq!( + super::quantize_transport_timeout_ms(999), + 750, + "should floor to the next-lower quantum multiple" + ); + assert_eq!( + super::quantize_transport_timeout_ms(2000), + 2000, + "should keep a larger exact quantum multiple unchanged" + ); + } + + #[test] + fn effective_transport_timeout_prefers_configured_constant() { + assert_eq!( + super::effective_transport_timeout_ms(2000, 1000), + 1000, + "should use the configured timeout verbatim when the budget allows" + ); + assert_eq!( + super::effective_transport_timeout_ms(2000, 100), + 100, + "should preserve a sub-quantum configured timeout — quantizing it away would permanently disable the provider" + ); + assert_eq!( + super::effective_transport_timeout_ms(999, 2000), + 750, + "should quantize the budget-bound value down to the 750ms bucket" + ); + assert_eq!( + super::effective_transport_timeout_ms(300, 2000), + 250, + "should quantize a tight budget down to one quantum" + ); + assert_eq!( + super::effective_transport_timeout_ms(200, 2000), + 200, + "should pass a sub-quantum budget through exactly instead of rounding to zero" + ); + assert_eq!( + super::effective_transport_timeout_ms(50, 100), + 50, + "should pass through when the budget is below both the quantum and the configured timeout" + ); + assert_eq!( + super::effective_transport_timeout_ms(0, 1000), + 0, + "should return zero for an exhausted budget so the launch is skipped" + ); + assert_eq!( + super::effective_transport_timeout_ms(100, 0), + 0, + "should return zero for a zero configured timeout so the launch is skipped" + ); + } + + #[test] + fn sub_quantum_configured_timeout_still_launches_provider() { + futures::executor::block_on(async { + // A provider whose configured timeout is below one quantum must + // still launch with its exact configured value: the constant is + // name-stable on its own, so only budget-derived values are + // quantized. + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"{}".to_vec()); + let services = build_services_with_http_client(stub); + // SAFETY: `Box::leak` creates a `'static` reference for test use only. + // The leaked allocation is bounded to the test process lifetime. + let services: &'static RuntimeServices = Box::leak(Box::new(services)); + + let observed = Arc::new(Mutex::new(Vec::new())); + let config = AuctionConfig { + enabled: true, + providers: vec!["bidder".to_string()], + timeout_ms: 2000, + mediator: None, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(StubAuctionProvider::recording( + "bidder", + "bidder-backend", + 100, + Arc::clone(&observed), + ))); + + let request = create_test_auction_request(); + let settings = create_test_settings(); + let req = http::Request::builder() + .method(http::Method::GET) + .uri("https://example.com/test") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let context = AuctionContext { + settings: &settings, + request: &req, + timeout_ms: 2000, + provider_responses: None, + services, + }; + + orchestrator + .run_auction(&request, &context) + .await + .expect("should complete auction"); + + let observed = observed.lock().expect("should lock observed timeouts"); + assert!( + !observed.is_empty(), + "should launch the sub-quantum-configured provider" + ); + for timeout in observed.iter() { + assert_eq!( + *timeout, 100, + "should pass the configured 100ms timeout through unchanged" + ); + } + }); + } + + #[test] + fn parallel_path_quantizes_provider_transport_timeout() { + futures::executor::block_on(async { + // A 999ms budget must reach the provider as the 750ms quantum + // bucket — both in backend_name (which derives the Fastly backend + // name) and in context.timeout_ms (which configures the backend + // and payload deadlines) — so the backend name stays stable + // across requests with slightly different remaining budgets. + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"{}".to_vec()); + let services = build_services_with_http_client(stub); + // SAFETY: `Box::leak` creates a `'static` reference for test use only. + // The leaked allocation is bounded to the test process lifetime. + let services: &'static RuntimeServices = Box::leak(Box::new(services)); + + let observed = Arc::new(Mutex::new(Vec::new())); + let config = AuctionConfig { + enabled: true, + providers: vec!["bidder".to_string()], + timeout_ms: 999, + mediator: None, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(StubAuctionProvider::recording( + "bidder", + "bidder-backend", + 2000, + Arc::clone(&observed), + ))); + + let request = create_test_auction_request(); + let settings = create_test_settings(); + let req = http::Request::builder() + .method(http::Method::GET) + .uri("https://example.com/test") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let context = AuctionContext { + settings: &settings, + request: &req, + timeout_ms: 999, + provider_responses: None, + services, + }; + + orchestrator + .run_auction(&request, &context) + .await + .expect("should complete auction"); + + let observed = observed.lock().expect("should lock observed timeouts"); + assert!( + !observed.is_empty(), + "should record provider transport timeouts" + ); + for timeout in observed.iter() { + assert!( + *timeout % super::TRANSPORT_TIMEOUT_QUANTUM_MS == 0 + && *timeout > 0 + && *timeout <= 750, + "should floor the 999ms budget to a quantum bucket at or below 750ms, got {timeout}ms" + ); + } + }); + } + + #[test] + fn sub_quantum_budget_launches_with_exact_remaining_timeout() { + futures::executor::block_on(async { + // A configured auction budget below one quantum must still launch + // providers with the exact remaining budget — rounding it to zero + // would hard-fail every auction for publishers with sub-250ms + // budgets. + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"{}".to_vec()); + let services = build_services_with_http_client(stub); + // SAFETY: `Box::leak` creates a `'static` reference for test use only. + // The leaked allocation is bounded to the test process lifetime. + let services: &'static RuntimeServices = Box::leak(Box::new(services)); + + let observed = Arc::new(Mutex::new(Vec::new())); + let config = AuctionConfig { + enabled: true, + providers: vec!["bidder".to_string()], + timeout_ms: 200, + mediator: None, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(StubAuctionProvider::recording( + "bidder", + "bidder-backend", + 2000, + Arc::clone(&observed), + ))); + + let request = create_test_auction_request(); + let settings = create_test_settings(); + let req = http::Request::builder() + .method(http::Method::GET) + .uri("https://example.com/test") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let context = AuctionContext { + settings: &settings, + request: &req, + timeout_ms: 200, + provider_responses: None, + services, + }; + + let result = orchestrator + .run_auction(&request, &context) + .await + .expect("should complete auction with a sub-quantum budget"); + + assert_eq!( + result.provider_responses.len(), + 1, + "should launch the provider despite the sub-quantum budget" + ); + let observed = observed.lock().expect("should lock observed timeouts"); + assert!( + !observed.is_empty(), + "should record provider transport timeouts" + ); + for timeout in observed.iter() { + assert!( + *timeout > 0 && *timeout <= 200, + "should pass the exact sub-quantum remaining budget through, got {timeout}ms" + ); + } + }); + } + + #[test] + fn synchronous_mediation_quantizes_mediator_timeout() { + futures::executor::block_on(async { + // The mediator has no select-loop deadline backstop, so its + // transport timeout must be quantized by rounding down: a + // quantum-aligned value no larger than the remaining budget. + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"{}".to_vec()); // bidder send_async + stub.push_response(200, b"{}".to_vec()); // mediator send_async + let services = build_services_with_http_client(stub); + // SAFETY: `Box::leak` creates a `'static` reference for test use only. + // The leaked allocation is bounded to the test process lifetime. + let services: &'static RuntimeServices = Box::leak(Box::new(services)); + + let observed = Arc::new(Mutex::new(Vec::new())); + let config = AuctionConfig { + enabled: true, + providers: vec!["bidder".to_string()], + mediator: Some("mediator".to_string()), + timeout_ms: 999, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "bidder", + "bidder-backend", + ))); + orchestrator.register_provider(Arc::new(StubAuctionProvider::recording( + "mediator", + "mediator-backend", + 2000, + Arc::clone(&observed), + ))); + + let request = create_test_auction_request(); + let settings = create_test_settings(); + let req = http::Request::builder() + .method(http::Method::GET) + .uri("https://example.com/test") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let context = AuctionContext { + settings: &settings, + request: &req, + timeout_ms: 999, + provider_responses: None, + services, + }; + + orchestrator + .run_auction(&request, &context) + .await + .expect("should complete mediated auction"); + + let observed = observed.lock().expect("should lock observed timeouts"); + assert!(!observed.is_empty(), "should run the mediator"); + for timeout in observed.iter() { + assert!( + *timeout % super::TRANSPORT_TIMEOUT_QUANTUM_MS == 0, + "mediator timeout {timeout}ms should be quantum-aligned" + ); + assert!( + *timeout > 0 && *timeout <= 750, + "mediator timeout {timeout}ms should be positive and floored below the 999ms budget" + ); + } + }); + } + + #[test] + fn dispatched_collect_quantizes_mediator_timeout() { + futures::executor::block_on(async { + // Same invariant as the synchronous path, on the split + // dispatch/collect path used by publisher page rendering. + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"{}".to_vec()); // bidder send_async + stub.push_response(200, b"{}".to_vec()); // mediator send_async + let services = build_services_with_http_client(stub); + // SAFETY: `Box::leak` creates a `'static` reference for test use only. + // The leaked allocation is bounded to the test process lifetime. + let services: &'static RuntimeServices = Box::leak(Box::new(services)); + + let observed_bidder = Arc::new(Mutex::new(Vec::new())); + let observed_mediator = Arc::new(Mutex::new(Vec::new())); + let config = AuctionConfig { + enabled: true, + providers: vec!["bidder".to_string()], + mediator: Some("mediator".to_string()), + timeout_ms: 999, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(StubAuctionProvider::recording( + "bidder", + "bidder-backend", + 2000, + Arc::clone(&observed_bidder), + ))); + orchestrator.register_provider(Arc::new(StubAuctionProvider::recording( + "mediator", + "mediator-backend", + 2000, + Arc::clone(&observed_mediator), + ))); + + let request = create_test_auction_request(); + let settings = create_test_settings(); + let req = http::Request::builder() + .method(http::Method::GET) + .uri("https://example.com/test") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let context = AuctionContext { + settings: &settings, + request: &req, + timeout_ms: 999, + provider_responses: None, + services, + }; + + let dispatched = match orchestrator.dispatch_auction(&request, &context).await { + DispatchAuctionOutcome::Dispatched(dispatched) => dispatched, + _ => panic!("should dispatch the bidder request"), + }; + orchestrator + .collect_dispatched_auction(dispatched, services, &context) + .await; + + let observed_bidder = observed_bidder.lock().expect("should lock bidder timeouts"); + assert!( + !observed_bidder.is_empty(), + "should record dispatched bidder timeouts" + ); + for timeout in observed_bidder.iter() { + assert!( + *timeout % super::TRANSPORT_TIMEOUT_QUANTUM_MS == 0 + && *timeout > 0 + && *timeout <= 750, + "dispatched bidder timeout should floor 999ms to a quantum bucket at or below 750ms, got {timeout}ms" + ); + } + + let observed_mediator = observed_mediator + .lock() + .expect("should lock mediator timeouts"); + assert!(!observed_mediator.is_empty(), "should run the mediator"); + for timeout in observed_mediator.iter() { + assert!( + *timeout % super::TRANSPORT_TIMEOUT_QUANTUM_MS == 0, + "mediator timeout {timeout}ms should be quantum-aligned" + ); + assert!( + *timeout > 0 && *timeout <= 750, + "mediator timeout {timeout}ms should be positive and floored below the 999ms budget" + ); + } + }); + } + #[test] fn select_error_is_attributed_to_correct_provider() { futures::executor::block_on(async { @@ -1950,14 +2494,14 @@ mod tests { ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-a", - backend: "backend-a", - })); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-b", - backend: "backend-b", - })); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-a", + "backend-a", + ))); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-b", + "backend-b", + ))); let request = create_test_auction_request(); let settings = create_test_settings(); @@ -2033,14 +2577,14 @@ mod tests { ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-a", - backend: "backend-a", - })); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-b", - backend: "backend-b", - })); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-a", + "backend-a", + ))); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-b", + "backend-b", + ))); let request = create_test_auction_request(); let settings = create_test_settings(); @@ -2098,14 +2642,14 @@ mod tests { ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-a", - backend: "backend-a", - })); - orchestrator.register_provider(Arc::new(StubAuctionProvider { - name: "provider-b", - backend: "backend-b", - })); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-a", + "backend-a", + ))); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-b", + "backend-b", + ))); let request = create_test_auction_request(); let settings = create_test_settings(); From 35e872bf621962c814d13866fac26b5c75f4d44c Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 8 Jul 2026 20:56:59 +0530 Subject: [PATCH 02/44] Stream publisher origin bodies end-to-end on Fastly Publisher pages were fully buffered before the first byte reached the client: the platform client materialized the origin body (10 MiB cap), the rewrite pipeline ran over an in-memory cursor, and the EdgeZero finalize buffered the assembled response while awaiting auction collection. TTFB therefore tracked full origin transfer plus the auction instead of origin first byte. - Add supports_streaming_responses() to PlatformHttpClient (default false, Fastly true) and request with_stream_response() on the publisher origin fetch only where honored - Teach the pipeline to consume Body::Stream asynchronously: BodyChunkSource (cumulative raw-byte cap via publisher.max_buffered_body_bytes), push-style BodyStreamDecoder/BodyStreamEncoder in streaming_processor - Replace the Fastly buffered finalize with publisher_response_into_streaming_response: a lazy Body::Stream that commits headers at origin first byte, streams rewritten chunks, and holds only the tail for auction collection; bids still inject before body close - Share one hold implementation (hold_step_decoded_chunk / hold_finish_segments) between the lazy body and the writer-driven loop so the paths cannot drift; collect_non_html_auction dedupes the collect-before-stream path - Finalize brotli decode with close() so truncated origin streams error instead of silently truncating; decode failures emit stream_decode_error telemetry - Guard bodiless (HEAD/204/304) responses and log wasted auction dispatch, matching the buffered finalizer Local A/B on a 183 KB gzip publisher page with a live 3-slot auction (release builds, 20 interleaved rounds): TTFB median 741 ms buffered vs 161 ms streamed (-78%); guest wall time and wasm heap unchanged. --- Cargo.lock | 1 + Cargo.toml | 1 + .../trusted-server-adapter-fastly/src/app.rs | 43 +- .../trusted-server-adapter-fastly/src/main.rs | 11 +- .../src/platform.rs | 4 + crates/trusted-server-core/Cargo.toml | 1 + .../trusted-server-core/src/platform/http.rs | 11 + .../src/platform/test_support.rs | 15 + crates/trusted-server-core/src/proxy.rs | 9 +- crates/trusted-server-core/src/publisher.rs | 1915 +++++++++++++++-- crates/trusted-server-core/src/settings.rs | 15 +- .../src/streaming_processor.rs | 191 ++ ...2026-07-08-true-origin-streaming-fastly.md | 1039 +++++++++ 13 files changed, 3078 insertions(+), 178 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-08-true-origin-streaming-fastly.md diff --git a/Cargo.lock b/Cargo.lock index a19be7ab..cd222788 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5277,6 +5277,7 @@ dependencies = [ name = "trusted-server-core" version = "0.1.0" dependencies = [ + "async-stream", "async-trait", "base64", "brotli", diff --git a/Cargo.toml b/Cargo.toml index 27411acb..0512371e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ debug = 1 [workspace.dependencies] anyhow = "1" +async-stream = "0.3" async-trait = "0.1" axum = "0.8" base64 = "0.22" diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index e56498b1..d5e37f91 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -65,10 +65,10 @@ //! run on these responses. Legacy ran EC finalization on its own auth //! challenges. Like the 401 geo-skip, this is privacy-conservative: no EC //! cookies are issued to unauthenticated callers. -//! - **Publisher responses** are buffered (bounded by -//! `publisher.max_buffered_body_bytes`) instead of streamed to the client. -//! Asset responses are streamed straight to the client (see -//! [`dispatch_asset_fallback`]), matching legacy. +//! - **Publisher responses** keep Fastly origin bodies streaming through the +//! `EdgeZero` response body when the body is processable or pass-through. +//! Adapters without streaming-body support still use the bounded buffered +//! finalizer. //! - **Router-level 405s** (unregistered verbs) skip EC finalization along //! with the middleware chain; the entry point still adds TS headers. //! @@ -116,8 +116,8 @@ use trusted_server_core::proxy::{ handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, AssetProxyCachePolicy, }; use trusted_server_core::publisher::{ - buffer_publisher_response_async, handle_page_bids, handle_publisher_request, - handle_tsjs_dynamic, page_bids_preflight_denied, AuctionDispatch, + handle_page_bids, handle_publisher_request, handle_tsjs_dynamic, page_bids_preflight_denied, + publisher_response_into_streaming_response, AuctionDispatch, }; use trusted_server_core::request_signing::{ handle_deactivate_key, handle_rotate_key, handle_trusted_server_discovery, @@ -721,10 +721,9 @@ async fn dispatch_fallback( let result = if uses_dynamic_tsjs_fallback(&method, &path) { handle_tsjs_dynamic(&req, &state.registry) } else if state.registry.has_route(&method, &path) { - // Integration-proxy responses are not bounded by publisher.max_buffered_body_bytes. - // Only the handle_publisher_request branch below routes through - // buffer_publisher_response_async. Integration responses are small in practice - // and the EdgeZero flag is off by default; extend the cap here if that changes. + // Integration-proxy responses are not bounded by + // publisher.max_buffered_body_bytes. Publisher fallback below uses the + // publisher-specific streaming finalizer instead. state .registry .handle_proxy(ProxyDispatchInput { @@ -773,9 +772,8 @@ async fn dispatch_fallback( match runtime_services_for_consent_route(&state.settings, services) { Ok(publisher_services) => { // Run the server-side auction with the configured creative- - // opportunity slots and collect the dispatched bids in the - // buffered finalize (`buffer_publisher_response_async`), matching - // the legacy streaming path. `handle_publisher_request` matches the + // opportunity slots and collect dispatched bids from the lazy + // publisher body stream. `handle_publisher_request` matches the // slots against the request path. The partner registry plus the // EC identity-graph KV (`ec.kv_graph`) enrich the bid request with // server-side EIDs, same as the legacy auction. @@ -797,17 +795,14 @@ async fn dispatch_fallback( ) .await { - Ok(pub_response) => { - buffer_publisher_response_async( - pub_response, - &method, - &state.settings, - &state.registry, - &state.orchestrator, - &publisher_services, - ) - .await - } + Ok(pub_response) => publisher_response_into_streaming_response( + pub_response, + &method, + Arc::clone(&state.settings), + state.registry.as_ref(), + Arc::clone(&state.orchestrator), + publisher_services.clone(), + ), Err(e) => Err(e), } } diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index d20de533..963686cb 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -321,10 +321,9 @@ fn run_edgezero_pull_sync_after_send( /// Sends a finalized `EdgeZero` response to the client. /// -/// Asset streams commit headers first, then pipe the origin body chunk by chunk -/// so large responses do not materialize in the Wasm heap. Publisher responses -/// are buffered by the server-side auction path so bids can be injected into the -/// document, and are sent in one shot along with all other responses. +/// Streaming `EdgeZero` bodies commit headers first, then pipe chunks to Fastly's +/// client stream so large asset and publisher-origin responses do not +/// materialize in the Wasm heap. fn send_edgezero_response( mut response: HttpResponse, request_filter_effects: Option<&RequestFilterEffects>, @@ -350,11 +349,11 @@ fn send_edgezero_response( match futures::executor::block_on(stream_asset_body(body, &mut streaming_body)) { Ok(()) => { if let Err(e) = streaming_body.finish() { - log::error!("failed to finish EdgeZero asset streaming body: {e}"); + log::error!("failed to finish EdgeZero streaming body: {e}"); } } Err(e) => { - log::error!("EdgeZero asset streaming failed: {e:?}"); + log::error!("EdgeZero streaming failed: {e:?}"); drop(streaming_body); } } diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index 106ced78..65d0f4b0 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -426,6 +426,10 @@ pub struct FastlyPlatformHttpClient; #[async_trait::async_trait(?Send)] impl PlatformHttpClient for FastlyPlatformHttpClient { + fn supports_streaming_responses(&self) -> bool { + true + } + async fn send( &self, request: PlatformHttpRequest, diff --git a/crates/trusted-server-core/Cargo.toml b/crates/trusted-server-core/Cargo.toml index ba88f361..bedefc32 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -13,6 +13,7 @@ workspace = true [dependencies] async-trait = { workspace = true } +async-stream = { workspace = true } base64 = { workspace = true } brotli = { workspace = true } bytes = { workspace = true } diff --git a/crates/trusted-server-core/src/platform/http.rs b/crates/trusted-server-core/src/platform/http.rs index 80bb2312..9e9337ed 100644 --- a/crates/trusted-server-core/src/platform/http.rs +++ b/crates/trusted-server-core/src/platform/http.rs @@ -276,6 +276,17 @@ pub trait PlatformHttpClient: Send + Sync { true } + /// Whether [`send`](Self::send) can preserve upstream response bodies as + /// [`Body::Stream`](edgezero_core::body::Body::Stream) when requested via + /// [`PlatformHttpRequest::with_stream_response`]. + /// + /// Adapters that cannot preserve streaming response bodies must keep the + /// default `false` so callers do not request a contract the adapter will + /// reject or silently buffer. + fn supports_streaming_responses(&self) -> bool { + false + } + /// Wait for one of the in-flight requests to complete. /// /// # Errors diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index ee7201fb..0c86b959 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -224,6 +224,9 @@ pub(crate) struct StubHttpClient { // Reported by supports_concurrent_fanout(); set false to emulate // platforms whose send_async executes eagerly (e.g. Cloudflare Workers). concurrent_fanout: std::sync::atomic::AtomicBool, + // Reported by supports_streaming_responses(); set true to emulate Fastly's + // streaming response support. + streaming_responses_supported: std::sync::atomic::AtomicBool, image_optimizer_options: Mutex>>, stream_response_flags: Mutex>, request_methods: Mutex>, @@ -246,6 +249,7 @@ impl StubHttpClient { request_headers: Mutex::new(Vec::new()), select_errors: Mutex::new(VecDeque::new()), concurrent_fanout: std::sync::atomic::AtomicBool::new(true), + streaming_responses_supported: std::sync::atomic::AtomicBool::new(false), image_optimizer_options: Mutex::new(Vec::new()), stream_response_flags: Mutex::new(Vec::new()), request_methods: Mutex::new(Vec::new()), @@ -260,6 +264,12 @@ impl StubHttpClient { .store(supported, std::sync::atomic::Ordering::Relaxed); } + /// Make `supports_streaming_responses()` report the given value. + pub fn set_streaming_responses_supported(&self, supported: bool) { + self.streaming_responses_supported + .store(supported, std::sync::atomic::Ordering::Relaxed); + } + /// Queue a canned response by status code and body bytes. pub fn push_response(&self, status: u16, body: Vec) { self.push_response_with_headers(status, body, Vec::<(String, String)>::new()); @@ -363,6 +373,11 @@ impl PlatformHttpClient for StubHttpClient { .load(std::sync::atomic::Ordering::Relaxed) } + fn supports_streaming_responses(&self) -> bool { + self.streaming_responses_supported + .load(std::sync::atomic::Ordering::Relaxed) + } + async fn send( &self, request: PlatformHttpRequest, diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 19c10a80..00444b38 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -246,7 +246,10 @@ fn platform_response_to_fastly_asset(platform_resp: PlatformResponse) -> AssetPr } } -/// Stream an asset response body directly to a writable client stream. +/// Stream a platform response body directly to a writable client stream. +/// +/// Asset routes and Fastly `EdgeZero` publisher fallback both use this bridge +/// after headers have been committed through `stream_to_client()`. /// /// # Errors /// @@ -261,7 +264,7 @@ pub async fn stream_asset_body( output .write_all(bytes.as_ref()) .change_context(TrustedServerError::Proxy { - message: "failed to write buffered asset response body".to_string(), + message: "failed to write buffered platform response body".to_string(), })?; } EdgeBody::Stream(mut stream) => { @@ -274,7 +277,7 @@ pub async fn stream_asset_body( output .write_all(chunk.as_ref()) .change_context(TrustedServerError::Proxy { - message: "failed to write streaming asset response body".to_string(), + message: "failed to write streaming platform response body".to_string(), })?; } } diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 6b0ea3a5..c10ac0b3 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -22,9 +22,15 @@ use std::io::Write; use std::sync::{Arc, Mutex}; use std::time::Duration; +use brotli::enc::writer::CompressorWriter; +use brotli::enc::BrotliEncoderParams; +use brotli::Decompressor; use cookie::CookieJar; use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; +use flate2::read::{GzDecoder, ZlibDecoder}; +use flate2::write::{GzEncoder, ZlibEncoder}; +use futures::StreamExt as _; use http::{header, HeaderValue, Method, Request, Response, StatusCode, Uri}; use crate::auction::endpoints::{ @@ -53,19 +59,134 @@ use crate::platform::{GeoInfo, PlatformBackendSpec, PlatformHttpRequest, Runtime use crate::price_bucket::{price_bucket, PriceGranularity}; use crate::rsc_flight::RscFlightUrlRewriter; use crate::settings::Settings; -use crate::streaming_processor::{Compression, PipelineConfig, StreamProcessor, StreamingPipeline}; +use crate::streaming_processor::{ + BodyStreamDecoder, BodyStreamEncoder, Compression, PipelineConfig, StreamProcessor, + StreamingPipeline, STREAM_CHUNK_SIZE, +}; use crate::streaming_replacer::create_url_replacer; const SUPPORTED_ENCODING_VALUES: [&str; 3] = ["gzip", "deflate", "br"]; const DEFAULT_PUBLISHER_FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(15); -/// Read buffer size for streaming body processing and brotli internal buffers. -/// Both the `Decompressor` and `CompressorWriter` use this value so all -/// brotli I/O layers operate on consistently-sized chunks. -const STREAM_CHUNK_SIZE: usize = 8192; +fn body_as_reader( + body: EdgeBody, +) -> Result, Report> { + let bytes = body.into_bytes().ok_or_else(|| { + Report::new(TrustedServerError::Proxy { + message: "streaming body cannot be processed by sync publisher pipeline".to_string(), + }) + })?; + Ok(std::io::Cursor::new(bytes)) +} + +struct BodyChunkSource { + body: Option, + chunk_size: usize, + max_bytes: usize, + bytes_seen: usize, + once_offset: usize, +} + +impl BodyChunkSource { + fn new(body: EdgeBody, chunk_size: usize) -> Self { + Self { + body: Some(body), + chunk_size, + max_bytes: usize::MAX, + bytes_seen: 0, + once_offset: 0, + } + } + + fn with_max_bytes(mut self, max_bytes: usize) -> Self { + self.max_bytes = max_bytes; + self + } + + async fn next_chunk(&mut self) -> Result, Report> { + let Some(body) = self.body.take() else { + return Ok(None); + }; + + let chunk = match body { + EdgeBody::Once(bytes) => { + if self.once_offset >= bytes.len() { + None + } else { + let end = (self.once_offset + self.chunk_size).min(bytes.len()); + let chunk = bytes.slice(self.once_offset..end); + self.once_offset = end; + if self.once_offset < bytes.len() { + self.body = Some(EdgeBody::Once(bytes)); + } + Some(chunk) + } + } + EdgeBody::Stream(mut stream) => match stream.next().await { + Some(Ok(chunk)) => { + self.body = Some(EdgeBody::Stream(stream)); + Some(chunk) + } + Some(Err(err)) => { + return Err(Report::new(TrustedServerError::Proxy { + message: format!("Failed to read publisher origin body stream: {err}"), + })); + } + None => None, + }, + }; + + let Some(chunk) = chunk else { + return Ok(None); + }; + + self.bytes_seen = self.bytes_seen.checked_add(chunk.len()).ok_or_else(|| { + Report::new(TrustedServerError::Proxy { + message: "publisher origin body byte count overflowed".to_string(), + }) + })?; + if self.bytes_seen > self.max_bytes { + return Err(Report::new(TrustedServerError::Proxy { + message: format!( + "publisher origin body exceeded {}-byte streaming limit", + self.max_bytes + ), + })); + } + + Ok(Some(chunk)) + } +} + +fn process_and_encode_chunk( + processor: &mut P, + encoder: &mut BodyStreamEncoder, + chunk: &[u8], + is_last: bool, + process_error: &str, +) -> Result, Report> { + let processed = + processor + .process_chunk(chunk, is_last) + .change_context(TrustedServerError::Proxy { + message: process_error.to_string(), + })?; + if processed.is_empty() { + return Ok(None); + } + let encoded = encoder.encode_chunk(&processed)?; + if encoded.is_empty() { + return Ok(None); + } + Ok(Some(bytes::Bytes::from(encoded))) +} -fn body_as_reader(body: EdgeBody) -> std::io::Cursor { - std::io::Cursor::new(body.into_bytes().unwrap_or_default()) +fn publisher_stream_error(err: Report) -> std::io::Error { + let message = format!("{err:?}"); + // Consume the report so clippy's needless_pass_by_value accepts the + // by-value signature that `map_err(publisher_stream_error)` requires. + drop(err); + std::io::Error::other(message) } fn not_found_response() -> Response { @@ -233,6 +354,55 @@ struct ProcessResponseParams<'a> { ad_bids_state: &'a Arc>>, } +struct PublisherBodyProcessor { + inner: Box, +} + +impl PublisherBodyProcessor { + fn new( + params: &OwnedProcessResponseParams, + settings: &Settings, + integration_registry: &IntegrationRegistry, + ) -> Result> { + let is_html = is_html_content_type(¶ms.content_type); + let is_rsc_flight = + content_type_contains_ascii_case_insensitive(¶ms.content_type, "text/x-component"); + let inner: Box = if is_html { + Box::new(create_html_stream_processor( + ¶ms.origin_host, + ¶ms.request_host, + ¶ms.request_scheme, + settings, + integration_registry, + params.ad_slots_script.as_deref().map(str::to_string), + Arc::clone(¶ms.ad_bids_state), + )?) + } else if is_rsc_flight { + Box::new(RscFlightUrlRewriter::new( + ¶ms.origin_host, + ¶ms.origin_url, + ¶ms.request_host, + ¶ms.request_scheme, + )) + } else { + Box::new(create_url_replacer( + ¶ms.origin_host, + ¶ms.origin_url, + ¶ms.request_host, + ¶ms.request_scheme, + )) + }; + + Ok(Self { inner }) + } +} + +impl StreamProcessor for PublisherBodyProcessor { + fn process_chunk(&mut self, chunk: &[u8], is_last: bool) -> Result, std::io::Error> { + self.inner.process_chunk(chunk, is_last) + } +} + /// Process response body through the streaming pipeline. /// /// Selects the appropriate processor based on content type (HTML rewriter, @@ -276,7 +446,7 @@ fn process_response_streaming( params.ad_slots_script.map(str::to_string), params.ad_bids_state.clone(), )?; - StreamingPipeline::new(config, processor).process(body_as_reader(body), output)?; + StreamingPipeline::new(config, processor).process(body_as_reader(body)?, 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. @@ -286,7 +456,7 @@ fn process_response_streaming( params.request_host, params.request_scheme, ); - StreamingPipeline::new(config, processor).process(body_as_reader(body), output)?; + StreamingPipeline::new(config, processor).process(body_as_reader(body)?, output)?; } else { let replacer = create_url_replacer( params.origin_host, @@ -294,12 +464,352 @@ fn process_response_streaming( params.request_host, params.request_scheme, ); - StreamingPipeline::new(config, replacer).process(body_as_reader(body), output)?; + StreamingPipeline::new(config, replacer).process(body_as_reader(body)?, output)?; } Ok(()) } +async fn process_response_streaming_async( + body: EdgeBody, + output: &mut W, + params: &ProcessResponseParams<'_>, + max_raw_body_bytes: usize, +) -> Result<(), Report> { + let is_html = is_html_content_type(params.content_type); + let is_rsc_flight = + content_type_contains_ascii_case_insensitive(params.content_type, "text/x-component"); + log::debug!( + "process_response_streaming_async: content_type={}, content_encoding={}, is_html={}, is_rsc_flight={}", + params.content_type, + params.content_encoding, + is_html, + is_rsc_flight + ); + + let compression = Compression::from_content_encoding(params.content_encoding); + + if is_html { + let mut processor = create_html_stream_processor( + params.origin_host, + params.request_host, + params.request_scheme, + params.settings, + params.integration_registry, + params.ad_slots_script.map(str::to_string), + params.ad_bids_state.clone(), + )?; + process_body_chunks_async( + body, + output, + &mut processor, + compression, + max_raw_body_bytes, + ) + .await + } else if is_rsc_flight { + let mut processor = RscFlightUrlRewriter::new( + params.origin_host, + params.origin_url, + params.request_host, + params.request_scheme, + ); + process_body_chunks_async( + body, + output, + &mut processor, + compression, + max_raw_body_bytes, + ) + .await + } else { + let mut replacer = create_url_replacer( + params.origin_host, + params.origin_url, + params.request_host, + params.request_scheme, + ); + process_body_chunks_async(body, output, &mut replacer, compression, max_raw_body_bytes) + .await + } +} + +async fn process_body_chunks_async( + body: EdgeBody, + writer: &mut W, + processor: &mut P, + compression: Compression, + max_raw_body_bytes: usize, +) -> Result<(), Report> { + let mut decoder = BodyStreamDecoder::new(compression); + let mut encoder = BodyStreamEncoder::new(compression); + let mut source = + BodyChunkSource::new(body, STREAM_CHUNK_SIZE).with_max_bytes(max_raw_body_bytes); + + while let Some(chunk) = source.next_chunk().await? { + let decoded = decoder.decode_chunk(&chunk)?; + if decoded.is_empty() { + continue; + } + if let Some(encoded) = process_and_encode_chunk( + processor, + &mut encoder, + &decoded, + false, + "Failed to process chunk", + )? { + write_encoded_segment(writer, &encoded)?; + } + } + + for encoded in passthrough_finish_segments(processor, &mut decoder, &mut encoder)? { + write_encoded_segment(writer, &encoded)?; + } + writer.flush().change_context(TrustedServerError::Proxy { + message: "Failed to flush output".to_string(), + })?; + + Ok(()) +} + +/// Write one encoded output segment produced by the chunk pipeline. +fn write_encoded_segment( + writer: &mut W, + encoded: &[u8], +) -> Result<(), Report> { + writer + .write_all(encoded) + .change_context(TrustedServerError::Proxy { + message: "Failed to write encoded chunk".to_string(), + }) +} + +/// Finalize a no-hold chunk pipeline: drain the decoder tail through the +/// processor, signal end-of-stream to the processor, and emit the encoder +/// trailer. Returns the encoded segments for the caller to emit. +fn passthrough_finish_segments( + processor: &mut P, + decoder: &mut BodyStreamDecoder, + encoder: &mut BodyStreamEncoder, +) -> Result, Report> { + let mut segments = Vec::new(); + let decoded_tail = decoder.finish()?; + if !decoded_tail.is_empty() { + if let Some(encoded) = process_and_encode_chunk( + processor, + encoder, + &decoded_tail, + false, + "Failed to process decoded tail", + )? { + segments.push(encoded); + } + } + if let Some(encoded) = process_and_encode_chunk( + processor, + encoder, + &[], + true, + "Failed to finalize processor", + )? { + segments.push(encoded); + } + let trailer = encoder.finish()?; + if !trailer.is_empty() { + segments.push(bytes::Bytes::from(trailer)); + } + Ok(segments) +} + +/// Mutable auction-hold state threaded through the streaming hold pipeline. +struct AuctionHoldState { + hold: Option, + dispatched: Option, + telemetry: AuctionTelemetryCarry, +} + +impl AuctionHoldState { + fn new(dispatched: DispatchedAuction, telemetry: AuctionTelemetryCarry) -> Self { + Self { + hold: Some(BodyCloseHoldBuffer::new()), + dispatched: Some(dispatched), + telemetry, + } + } +} + +/// Abandon the in-flight auction (if still pending) with the given telemetry +/// reason. No-op once the auction has been collected or already abandoned. +async fn abandon_hold_auction( + state: &mut AuctionHoldState, + services: &RuntimeServices, + reason: &'static str, +) { + if let Some(dispatched) = state.dispatched.take() { + emit_abandoned_auction( + services, + state.telemetry.observation.take(), + dispatched, + reason, + ) + .await; + } +} + +/// Feed one decoded chunk through the close-body hold and processor. +/// +/// Returns the encoded output segments for the caller to emit — written to a +/// client stream by [`body_close_hold_loop_stream`], yielded from the lazy +/// body by [`publisher_response_into_streaming_response`]. Both async hold +/// paths share this function so their behavior cannot drift apart. +/// +/// When the raw `( + processor: &mut P, + encoder: &mut BodyStreamEncoder, + chunk: &[u8], + state: &mut AuctionHoldState, + collect_refs: &AuctionHoldCollectRefs<'_>, +) -> Result, Report> { + let mut segments = Vec::new(); + if let Some(hold_buffer) = state.hold.as_mut() { + let ready = hold_buffer.push(chunk); + match process_and_encode_chunk(processor, encoder, &ready, false, "Failed to process chunk") + { + Ok(Some(encoded)) => segments.push(encoded), + Ok(None) => {} + Err(err) => { + abandon_hold_auction(state, collect_refs.services, "stream_process_error").await; + return Err(err); + } + } + + if state + .hold + .as_ref() + .is_some_and(BodyCloseHoldBuffer::found_close) + { + let dispatched = state + .dispatched + .take() + .expect("should have dispatched auction to collect"); + collect_stream_auction( + dispatched, + state.telemetry.take(), + collect_refs.price_granularity, + collect_refs.ad_bids_state, + collect_refs.orchestrator, + collect_refs.services, + collect_refs.settings, + ) + .await; + + let held = state + .hold + .take() + .expect("should have close-body hold buffer") + .finish(); + if let Some(encoded) = process_and_encode_chunk( + processor, + encoder, + &held, + false, + "Failed to process held body close", + )? { + segments.push(encoded); + } + } + } else { + match process_and_encode_chunk(processor, encoder, chunk, false, "Failed to process chunk") + { + Ok(Some(encoded)) => segments.push(encoded), + Ok(None) => {} + Err(err) => { + abandon_hold_auction(state, collect_refs.services, "stream_process_error").await; + return Err(err); + } + } + } + Ok(segments) +} + +/// Finalize the close-body hold pipeline at end of the origin stream. +/// +/// Drains the decoder tail through the hold (or straight through when the +/// hold was already released mid-stream), collects the auction if the +/// close-body tag never streamed, processes the held tail plus the +/// processor's final chunk, and emits the encoder trailer. Returns the +/// encoded segments for the caller to emit. On decoder failure the pending +/// auction is abandoned before the error is returned. +async fn hold_finish_segments( + processor: &mut P, + decoder: &mut BodyStreamDecoder, + encoder: &mut BodyStreamEncoder, + state: &mut AuctionHoldState, + collect_refs: &AuctionHoldCollectRefs<'_>, +) -> Result, Report> { + let mut segments = Vec::new(); + + let decoded_tail = match decoder.finish() { + Ok(decoded_tail) => decoded_tail, + Err(err) => { + abandon_hold_auction(state, collect_refs.services, "stream_decode_error").await; + return Err(err); + } + }; + if !decoded_tail.is_empty() { + segments.extend( + hold_step_decoded_chunk(processor, encoder, &decoded_tail, state, collect_refs).await?, + ); + } + + if let Some(hold) = state.hold.take() { + let dispatched = state + .dispatched + .take() + .expect("should have dispatched auction to collect"); + collect_stream_auction( + dispatched, + state.telemetry.take(), + collect_refs.price_granularity, + collect_refs.ad_bids_state, + collect_refs.orchestrator, + collect_refs.services, + collect_refs.settings, + ) + .await; + + let held = hold.finish(); + if let Some(encoded) = process_and_encode_chunk( + processor, + encoder, + &held, + false, + "Failed to process held body close", + )? { + segments.push(encoded); + } + } + + if let Some(encoded) = process_and_encode_chunk( + processor, + encoder, + &[], + true, + "Failed to finalize processor", + )? { + segments.push(encoded); + } + let trailer = encoder.finish()?; + if !trailer.is_empty() { + segments.push(bytes::Bytes::from(trailer)); + } + Ok(segments) +} + /// Create a unified HTML stream processor. /// /// Builds the config via [`HtmlProcessorConfig::from_settings`] and then @@ -339,16 +849,13 @@ pub enum PublisherResponse { /// content on any status (2xx or non-2xx — e.g., branded 404/500 HTML and /// error JSON still get URL rewriting) where the encoding is supported. /// Post-processors run inside the streaming processor, so processable HTML - /// is streamed regardless of whether any are registered. The caller must: - /// 1. Call `finalize_response()` on the response - /// 2. Call `response.stream_to_client()` to get a `StreamingBody` - /// 3. Call `stream_publisher_body()` with the body and streaming writer - /// 4. Call `StreamingBody::finish()` + /// is streamed regardless of whether any are registered. /// - /// **Interim (PR 15):** `body` has already been fully materialised into - /// WASM heap by the platform HTTP client. `stream_publisher_body` reads - /// from an in-memory buffer, not a live origin stream. The origin-side - /// peak is bounded by `MAX_PLATFORM_RESPONSE_BODY_BYTES`. + /// Adapters with platform streaming support preserve `body` as + /// [`EdgeBody::Stream`] and attach a lazy processed stream via + /// [`publisher_response_into_streaming_response`]. Buffered adapters use + /// [`buffer_publisher_response_async`] and are bounded by + /// `settings.publisher.max_buffered_body_bytes`. Stream { /// Response with all headers set (EC ID, cookies, etc.) /// but body not yet written. `Content-Length` already removed. @@ -363,12 +870,9 @@ pub enum PublisherResponse { /// `finalize_response()` and `send_to_client()` are applied at the outer /// response-dispatch level, not in this arm. /// - /// `Content-Length` is preserved — the body is unmodified. - /// - /// **Interim (PR 15):** `body` has been fully materialised into WASM heap. - /// Previously, binary assets streamed lazily from origin with no WASM - /// buffering. This path is now bounded by `MAX_PLATFORM_RESPONSE_BODY_BYTES`; - /// assets exceeding that limit return an error instead of exhausting heap. + /// `Content-Length` is preserved — the body is unmodified. Streaming + /// adapters reattach the origin body directly so non-processable 2xx bodies + /// can pass through without materializing in WASM memory. PassThrough { /// Response with all headers set but body not yet written. response: Response, @@ -465,7 +969,7 @@ pub struct OwnedProcessResponseParams { /// statuses (204, 304) carry no body but may advertise the `GET` representation's /// length, so they skip the buffer and length rewrite. /// -/// Every adapter (Axum, Cloudflare, Spin, and the Fastly `EdgeZero` path) calls +/// Buffered adapters (Axum, Cloudflare, Spin, and non-streaming fallbacks) call /// this: it drives /// [`stream_publisher_body_async`], which awaits /// [`AuctionOrchestrator::collect_dispatched_auction`], writes the winning bids @@ -530,48 +1034,225 @@ pub async fn buffer_publisher_response_async( } } -/// Returns `true` when a buffered publisher response should carry a body and a -/// recomputed `Content-Length`. +/// Convert a [`PublisherResponse`] into a response that preserves streaming +/// bodies where possible. /// -/// `HEAD` responses and bodiless statuses (204, 304) carry no body; rewriting -/// their `Content-Length` to the (empty) buffered length would mislead clients -/// and caches, so the origin metadata is preserved instead. -fn response_carries_body(method: &Method, status: StatusCode) -> bool { - *method != Method::HEAD - && status != StatusCode::NO_CONTENT - && status != StatusCode::NOT_MODIFIED -} - -/// A [`Write`] sink that buffers into a `Vec` but fails once the configured -/// byte limit would be exceeded. +/// Buffered adapters should keep using [`buffer_publisher_response_async`]. +/// Fastly uses this helper before the entry point commits headers, allowing the +/// response body to be pulled lazily by `stream_to_client()`. /// -/// Used to bound in-WASM-heap buffering of decoded/re-written publisher bodies. -/// A highly-compressible origin response can sit under the platform raw-body cap -/// yet expand past a safe heap size after decode and post-processing; this writer -/// turns that into a recoverable error instead of an out-of-memory abort. -pub struct BoundedWriter { - inner: Vec, - limit: usize, -} - -impl BoundedWriter { - /// Creates a writer that accepts at most `limit` bytes before erroring. - #[must_use] - pub fn new(limit: usize) -> Self { - Self { - inner: Vec::new(), - limit, +/// # Errors +/// +/// Returns an error if processor construction fails before the streaming body is +/// created. +pub fn publisher_response_into_streaming_response( + publisher_response: PublisherResponse, + method: &Method, + settings: Arc, + integration_registry: &IntegrationRegistry, + orchestrator: Arc, + services: RuntimeServices, +) -> Result, Report> { + match publisher_response { + PublisherResponse::Buffered(response) => Ok(response), + PublisherResponse::PassThrough { mut response, body } => { + if response_carries_body(method, response.status()) { + *response.body_mut() = body; + } + Ok(response) } - } - - /// Consumes the writer and returns the buffered bytes. - #[must_use] - pub fn into_inner(self) -> Vec { - self.inner - } -} - -impl Write for BoundedWriter { + PublisherResponse::Stream { + mut response, + body, + params, + } => { + if !response_carries_body(method, response.status()) { + if params.dispatched_auction.is_some() { + // A bodiless response (HEAD navigation, 204/304) has no + // `` to inject bids into, so the dispatched SSP + // requests are wasted — surface it for quota observability, + // matching the buffered finalizer. + log::warn!( + "Server-side auction dispatched but response is bodiless (method: {}, status: {}); in-flight SSP bid requests will not be collected", + method, + response.status(), + ); + } + return Ok(response); + } + + response.headers_mut().remove(header::CONTENT_LENGTH); + let mut params = *params; + let mut processor = + PublisherBodyProcessor::new(¶ms, &settings, integration_registry)?; + let stream = async_stream::try_stream! { + let compression = Compression::from_content_encoding(¶ms.content_encoding); + let mut decoder = BodyStreamDecoder::new(compression); + let mut encoder = BodyStreamEncoder::new(compression); + let mut source = BodyChunkSource::new(body, STREAM_CHUNK_SIZE) + .with_max_bytes(settings.publisher.max_buffered_body_bytes); + + // HTML rides the close-body hold so bids land before ``; + // non-HTML has no injection point, so its auction is collected + // before any byte streams (matching the buffered finalizer). + let mut hold_auction = None; + if let Some(dispatched) = params.dispatched_auction.take() { + let telemetry = AuctionTelemetryCarry { + observation: params.auction_observation.take(), + auction_request: params.auction_request.take(), + }; + if is_html_content_type(¶ms.content_type) { + hold_auction = Some((dispatched, telemetry)); + } else { + collect_non_html_auction( + dispatched, + telemetry, + ¶ms, + &orchestrator, + &services, + &settings, + ) + .await; + } + } + + if let Some((dispatched, telemetry)) = hold_auction { + let mut state = AuctionHoldState::new(dispatched, telemetry); + let collect_refs = AuctionHoldCollectRefs { + price_granularity: params.price_granularity, + ad_bids_state: ¶ms.ad_bids_state, + orchestrator: &orchestrator, + services: &services, + settings: &settings, + }; + + loop { + let raw_chunk = match source.next_chunk().await { + Ok(Some(chunk)) => chunk, + Ok(None) => break, + Err(err) => { + abandon_hold_auction(&mut state, &services, "stream_read_error") + .await; + Err(publisher_stream_error(err))?; + unreachable!("error should have returned"); + } + }; + let decoded = match decoder.decode_chunk(&raw_chunk) { + Ok(decoded) => decoded, + Err(err) => { + abandon_hold_auction(&mut state, &services, "stream_decode_error") + .await; + Err(publisher_stream_error(err))?; + unreachable!("error should have returned"); + } + }; + if decoded.is_empty() { + continue; + } + for encoded in hold_step_decoded_chunk( + &mut processor, + &mut encoder, + &decoded, + &mut state, + &collect_refs, + ) + .await + .map_err(publisher_stream_error)? + { + yield encoded; + } + } + + for encoded in hold_finish_segments( + &mut processor, + &mut decoder, + &mut encoder, + &mut state, + &collect_refs, + ) + .await + .map_err(publisher_stream_error)? + { + yield encoded; + } + } else { + while let Some(raw_chunk) = + source.next_chunk().await.map_err(publisher_stream_error)? + { + let decoded = decoder + .decode_chunk(&raw_chunk) + .map_err(publisher_stream_error)?; + if decoded.is_empty() { + continue; + } + if let Some(encoded) = process_and_encode_chunk( + &mut processor, + &mut encoder, + &decoded, + false, + "Failed to process chunk", + ) + .map_err(publisher_stream_error)? + { + yield encoded; + } + } + for encoded in + passthrough_finish_segments(&mut processor, &mut decoder, &mut encoder) + .map_err(publisher_stream_error)? + { + yield encoded; + } + } + }; + *response.body_mut() = EdgeBody::from_stream::<_, std::io::Error>(stream); + Ok(response) + } + } +} + +/// Returns `true` when a buffered publisher response should carry a body and a +/// recomputed `Content-Length`. +/// +/// `HEAD` responses and bodiless statuses (204, 304) carry no body; rewriting +/// their `Content-Length` to the (empty) buffered length would mislead clients +/// and caches, so the origin metadata is preserved instead. +fn response_carries_body(method: &Method, status: StatusCode) -> bool { + *method != Method::HEAD + && status != StatusCode::NO_CONTENT + && status != StatusCode::NOT_MODIFIED +} + +/// A [`Write`] sink that buffers into a `Vec` but fails once the configured +/// byte limit would be exceeded. +/// +/// Used to bound in-WASM-heap buffering of decoded/re-written publisher bodies. +/// A highly-compressible origin response can sit under the platform raw-body cap +/// yet expand past a safe heap size after decode and post-processing; this writer +/// turns that into a recoverable error instead of an out-of-memory abort. +pub struct BoundedWriter { + inner: Vec, + limit: usize, +} + +impl BoundedWriter { + /// Creates a writer that accepts at most `limit` bytes before erroring. + #[must_use] + pub fn new(limit: usize) -> Self { + Self { + inner: Vec::new(), + limit, + } + } + + /// Consumes the writer and returns the buffered bytes. + #[must_use] + pub fn into_inner(self) -> Vec { + self.inner + } +} + +impl Write for BoundedWriter { fn write(&mut self, buf: &[u8]) -> std::io::Result { if self.inner.len() + buf.len() > self.limit { return Err(std::io::Error::other( @@ -652,7 +1333,29 @@ pub async fn stream_publisher_body_async( services: &RuntimeServices, ) -> Result<(), Report> { let Some(dispatched) = params.dispatched_auction.take() else { - // No auction — use the existing sync pipeline unchanged. + if body.is_stream() { + let borrowed = ProcessResponseParams { + content_encoding: ¶ms.content_encoding, + origin_host: ¶ms.origin_host, + origin_url: ¶ms.origin_url, + request_host: ¶ms.request_host, + request_scheme: ¶ms.request_scheme, + settings, + content_type: ¶ms.content_type, + integration_registry, + ad_slots_script: params.ad_slots_script.as_deref(), + ad_bids_state: ¶ms.ad_bids_state, + }; + return process_response_streaming_async( + body, + output, + &borrowed, + settings.publisher.max_buffered_body_bytes, + ) + .await; + } + + // No auction and already-buffered body — keep the existing sync pipeline. return stream_publisher_body(body, output, params, settings, integration_registry); }; let telemetry = AuctionTelemetryCarry { @@ -665,35 +1368,36 @@ pub async fn stream_publisher_body_async( if !is_html { // Non-HTML: collect auction first, then stream. There is no // to hold, so delaying the entire body until collection is acceptable. - let placeholder = mediator_placeholder_request(); - let result = orchestrator - .collect_dispatched_auction( - dispatched, - services, - &make_collect_context(settings, services, &placeholder), + collect_non_html_auction( + dispatched, + telemetry, + params, + orchestrator, + services, + settings, + ) + .await; + if body.is_stream() { + let borrowed = ProcessResponseParams { + content_encoding: ¶ms.content_encoding, + origin_host: ¶ms.origin_host, + origin_url: ¶ms.origin_url, + request_host: ¶ms.request_host, + request_scheme: ¶ms.request_scheme, + settings, + content_type: ¶ms.content_type, + integration_registry, + ad_slots_script: params.ad_slots_script.as_deref(), + ad_bids_state: ¶ms.ad_bids_state, + }; + return process_response_streaming_async( + body, + output, + &borrowed, + settings.publisher.max_buffered_body_bytes, ) .await; - if let (Some(observation), Some(auction_request)) = - (telemetry.observation, telemetry.auction_request.as_ref()) - { - emit_auction_events_best_effort_lazy(services, || { - build_auction_events( - observation, - AuctionTerminalOutcome::Completed { - request: auction_request, - result: &result, - }, - ) - }) - .await; } - - write_bids_to_state( - &result.winning_bids, - params.price_granularity, - ¶ms.ad_bids_state, - settings.debug.inject_adm_for_testing, - ); return stream_publisher_body(body, output, params, settings, integration_registry); } @@ -917,6 +1621,14 @@ struct AuctionCollectCtx<'a> { settings: &'a Settings, } +struct AuctionHoldCollectRefs<'a> { + price_granularity: PriceGranularity, + ad_bids_state: &'a Arc>>, + orchestrator: &'a AuctionOrchestrator, + services: &'a RuntimeServices, + settings: &'a Settings, +} + /// Run the close-body hold loop for HTML bodies, collecting the auction before /// the raw `( @@ -926,13 +1638,20 @@ async fn stream_html_with_auction_hold( compression: Compression, ctx: AuctionCollectCtx<'_>, ) -> Result<(), Report> { - use brotli::enc::writer::CompressorWriter; - use brotli::enc::BrotliEncoderParams; - use brotli::Decompressor; - use flate2::read::{GzDecoder, ZlibDecoder}; - use flate2::write::{GzEncoder, ZlibEncoder}; + if body.is_stream() { + let max_raw_body_bytes = ctx.settings.publisher.max_buffered_body_bytes; + return body_close_hold_loop_stream( + body, + output, + processor, + compression, + ctx, + max_raw_body_bytes, + ) + .await; + } - let body = body_as_reader(body); + let body = body_as_reader(body)?; match compression { Compression::None => body_close_hold_loop(body, output, processor, ctx).await, Compression::Gzip => { @@ -969,6 +1688,85 @@ async fn stream_html_with_auction_hold( } } +/// Async-pull variant of [`body_close_hold_loop`] for live origin streams. +/// +/// Shares [`hold_step_decoded_chunk`] and [`hold_finish_segments`] with the +/// lazy streaming body built by [`publisher_response_into_streaming_response`], +/// so the two async hold paths cannot drift apart. +async fn body_close_hold_loop_stream( + body: EdgeBody, + writer: &mut W, + processor: &mut P, + compression: Compression, + ctx: AuctionCollectCtx<'_>, + max_raw_body_bytes: usize, +) -> Result<(), Report> { + let AuctionCollectCtx { + dispatched, + telemetry, + price_granularity, + ad_bids_state, + orchestrator, + services, + settings, + } = ctx; + let mut decoder = BodyStreamDecoder::new(compression); + let mut encoder = BodyStreamEncoder::new(compression); + let mut source = + BodyChunkSource::new(body, STREAM_CHUNK_SIZE).with_max_bytes(max_raw_body_bytes); + let mut state = AuctionHoldState::new(dispatched, telemetry); + let collect_refs = AuctionHoldCollectRefs { + price_granularity, + ad_bids_state, + orchestrator, + services, + settings, + }; + + loop { + let raw_chunk = match source.next_chunk().await { + Ok(Some(chunk)) => chunk, + Ok(None) => break, + Err(err) => { + abandon_hold_auction(&mut state, services, "stream_read_error").await; + return Err(err); + } + }; + let decoded = match decoder.decode_chunk(&raw_chunk) { + Ok(decoded) => decoded, + Err(err) => { + abandon_hold_auction(&mut state, services, "stream_decode_error").await; + return Err(err); + } + }; + if decoded.is_empty() { + continue; + } + for encoded in + hold_step_decoded_chunk(processor, &mut encoder, &decoded, &mut state, &collect_refs) + .await? + { + write_encoded_segment(writer, &encoded)?; + } + } + + for encoded in hold_finish_segments( + processor, + &mut decoder, + &mut encoder, + &mut state, + &collect_refs, + ) + .await? + { + write_encoded_segment(writer, &encoded)?; + } + writer.flush().change_context(TrustedServerError::Proxy { + message: "Failed to flush output".to_string(), + })?; + Ok(()) +} + const BODY_CLOSE_PREFIX: &[u8] = b"` to inject into, so bids are written to state up front and the +/// auction telemetry completes immediately. +async fn collect_non_html_auction( + dispatched: DispatchedAuction, + telemetry: AuctionTelemetryCarry, + params: &OwnedProcessResponseParams, + orchestrator: &AuctionOrchestrator, + services: &RuntimeServices, + settings: &Settings, +) { + let placeholder = mediator_placeholder_request(); + let result = orchestrator + .collect_dispatched_auction( + dispatched, + services, + &make_collect_context(settings, services, &placeholder), + ) + .await; + if let (Some(observation), Some(auction_request)) = + (telemetry.observation, telemetry.auction_request.as_ref()) + { + emit_auction_events_best_effort_lazy(services, || { + build_auction_events( + observation, + AuctionTerminalOutcome::Completed { + request: auction_request, + result: &result, + }, + ) + }) + .await; + } + write_bids_to_state( + &result.winning_bids, + params.price_granularity, + ¶ms.ad_bids_state, + settings.debug.inject_adm_for_testing, + ); +} + async fn collect_stream_auction( dispatched: DispatchedAuction, telemetry: AuctionTelemetryCarry, @@ -1600,11 +2439,12 @@ pub async fn handle_publisher_request( // SSP requests are already racing through the platform HTTP client, so // origin TTFB tracks origin latency rather than the auction timeout. - let mut response = match services - .http_client() - .send(PlatformHttpRequest::new(req, backend_name)) - .await - { + let mut platform_request = PlatformHttpRequest::new(req, backend_name); + if services.http_client().supports_streaming_responses() { + platform_request = platform_request.with_stream_response(); + } + + let mut response = match services.http_client().send(platform_request).await { Ok(platform_response) => platform_response.response, Err(err) => { if let Some(dispatched) = dispatched_auction.take() { @@ -2505,6 +3345,24 @@ mod tests { output } + fn deflate_encode(input: &[u8]) -> Vec { + let mut encoder = + flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + encoder + .write_all(input) + .expect("should write deflate test input"); + encoder.finish().expect("should finish deflate encoding") + } + + fn deflate_decode(input: &[u8]) -> Vec { + let mut decoder = flate2::read::ZlibDecoder::new(input); + let mut output = Vec::new(); + decoder + .read_to_end(&mut output) + .expect("should decode deflate test output"); + output + } + fn brotli_encode(input: &[u8]) -> Vec { let mut encoder = CompressorWriter::new(Vec::new(), 4096, 5, 22); encoder @@ -2734,50 +3592,107 @@ mod tests { } #[tokio::test] - async fn handle_publisher_request_does_not_self_generate_ec() { - // EC generation is the adapter's real-browser-gated responsibility. This - // handler must never mint an EC ID on its own: for a navigation from a - // client the adapter did not pre-generate for (e.g. a non-real browser), - // `ec_value` must stay `None` so no IP-derived identifier reaches the - // auction. Consent allows EC creation and a client IP is present here — - // exactly the conditions under which the old inline call would have - // generated one. + async fn publisher_origin_fetch_leaves_stream_response_disabled_when_unsupported() { let settings = create_test_settings(); let stub = Arc::new(StubHttpClient::new()); - stub.push_response(200, b"ok".to_vec()); + stub.push_response_with_headers( + 200, + b"origin".to_vec(), + vec![("content-type", "text/html; charset=utf-8")], + ); let services = build_services_with_http_client( Arc::clone(&stub) as Arc ); - - let consent = crate::consent::ConsentContext { - jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, - ..Default::default() - }; - let mut ec_context = - EcContext::new_for_test_with_ip(None, consent, Some("203.0.113.7".to_string())); - assert!( - ec_context.ec_allowed(), - "test precondition: consent must allow EC creation" - ); - - let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let req = HttpRequest::builder() .method(Method::GET) - .uri("https://publisher.example/article") + .uri("https://publisher.example/page") .header(header::HOST, "publisher.example") - .header("sec-fetch-dest", "document") .body(EdgeBody::empty()) .expect("should build request"); - let _ = handle_publisher_request( - &settings, - &services, - None, - &mut ec_context, - AuctionDispatch { - orchestrator: &orchestrator, - slots: &[], - registry: None, + let _ = run_publisher_proxy(&settings, &services, req).await; + + assert_eq!( + stub.recorded_stream_response_flags(), + vec![false], + "publisher origin fetch must not request streams when the platform does not support them" + ); + } + + #[tokio::test] + async fn publisher_origin_fetch_sets_stream_response_when_supported() { + let settings = create_test_settings(); + let stub = Arc::new(StubHttpClient::new()); + stub.set_streaming_responses_supported(true); + stub.push_response_with_headers( + 200, + b"origin".to_vec(), + vec![("content-type", "text/html; charset=utf-8")], + ); + 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 _ = run_publisher_proxy(&settings, &services, req).await; + + assert_eq!( + stub.recorded_stream_response_flags(), + vec![true], + "publisher origin fetch should request streams when the platform supports them" + ); + } + + #[tokio::test] + async fn handle_publisher_request_does_not_self_generate_ec() { + // EC generation is the adapter's real-browser-gated responsibility. This + // handler must never mint an EC ID on its own: for a navigation from a + // client the adapter did not pre-generate for (e.g. a non-real browser), + // `ec_value` must stay `None` so no IP-derived identifier reaches the + // auction. Consent allows EC creation and a client IP is present here — + // exactly the conditions under which the old inline call would have + // generated one. + let settings = create_test_settings(); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"ok".to_vec()); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + + let consent = crate::consent::ConsentContext { + jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, + ..Default::default() + }; + let mut ec_context = + EcContext::new_for_test_with_ip(None, consent, Some("203.0.113.7".to_string())); + assert!( + ec_context.ec_allowed(), + "test precondition: consent must allow EC creation" + ); + + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let req = HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/article") + .header(header::HOST, "publisher.example") + .header("sec-fetch-dest", "document") + .body(EdgeBody::empty()) + .expect("should build request"); + + let _ = handle_publisher_request( + &settings, + &services, + None, + &mut ec_context, + AuctionDispatch { + orchestrator: &orchestrator, + slots: &[], + registry: None, }, req, ) @@ -3659,6 +4574,734 @@ mod tests { ); } + #[test] + fn stream_publisher_body_rejects_stream_body_in_sync_path() { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let params = OwnedProcessResponseParams { + content_encoding: String::new(), + origin_host: "origin.example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + request_host: "proxy.example.com".to_string(), + request_scheme: "https".to_string(), + content_type: "text/html; charset=utf-8".to_string(), + ad_slots_script: None, + ad_bids_state: Arc::new(Mutex::new(None)), + auction_observation: None, + auction_request: None, + dispatched_auction: None, + price_granularity: crate::price_bucket::PriceGranularity::default(), + }; + let body = EdgeBody::from_stream(futures::stream::iter(vec![Ok::<_, io::Error>( + bytes::Bytes::from_static(b"live"), + )])); + let mut output = Vec::new(); + + let err = stream_publisher_body(body, &mut output, ¶ms, &settings, ®istry) + .expect_err("should reject stream body in sync path"); + + assert!( + format!("{err:?}").contains("streaming body"), + "should explain that Body::Stream is not supported by the sync path: {err:?}" + ); + } + + #[test] + fn body_chunk_source_yields_once_body_in_chunks() { + futures::executor::block_on(async { + let body = EdgeBody::from_bytes(bytes::Bytes::from_static(b"abcdef")); + let mut source = BodyChunkSource::new(body, 3).with_max_bytes(16); + + assert_eq!( + source.next_chunk().await.expect("should read").as_deref(), + Some(&b"abc"[..]), + "should yield the first chunk" + ); + assert_eq!( + source.next_chunk().await.expect("should read").as_deref(), + Some(&b"def"[..]), + "should yield the second chunk" + ); + assert!( + source.next_chunk().await.expect("should read").is_none(), + "should end after buffered bytes are exhausted" + ); + }); + } + + #[test] + fn body_chunk_source_preserves_stream_chunks() { + futures::executor::block_on(async { + let body = EdgeBody::stream(futures::stream::iter(vec![ + bytes::Bytes::from_static(b"first"), + bytes::Bytes::from_static(b"second"), + ])); + let mut source = BodyChunkSource::new(body, 3).with_max_bytes(16); + + assert_eq!( + source.next_chunk().await.expect("should read").as_deref(), + Some(&b"first"[..]), + "stream chunks should pass through without re-chunking" + ); + assert_eq!( + source.next_chunk().await.expect("should read").as_deref(), + Some(&b"second"[..]), + "stream chunks should preserve upstream boundaries" + ); + assert!( + source.next_chunk().await.expect("should read").is_none(), + "should end after stream is exhausted" + ); + }); + } + + #[test] + fn body_chunk_source_enforces_cumulative_raw_cap() { + futures::executor::block_on(async { + let body = EdgeBody::stream(futures::stream::iter(vec![ + bytes::Bytes::from_static(b"1234"), + bytes::Bytes::from_static(b"5678"), + ])); + let mut source = BodyChunkSource::new(body, STREAM_CHUNK_SIZE).with_max_bytes(6); + + assert!( + source + .next_chunk() + .await + .expect("first chunk should pass") + .is_some(), + "first chunk should stay under cap" + ); + let err = source + .next_chunk() + .await + .expect_err("second chunk should exceed cap"); + + assert!( + format!("{err:?}").contains("publisher origin body exceeded"), + "should report cumulative cap: {err:?}" + ); + }); + } + + #[test] + fn stream_publisher_body_async_processes_stream_without_auction() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let services = noop_services(); + let mut params = OwnedProcessResponseParams { + content_encoding: String::new(), + origin_host: "origin.example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + request_host: "proxy.example.com".to_string(), + request_scheme: "https".to_string(), + content_type: "text/css".to_string(), + ad_slots_script: None, + ad_bids_state: Arc::new(Mutex::new(None)), + auction_observation: None, + auction_request: None, + dispatched_auction: None, + price_granularity: crate::price_bucket::PriceGranularity::default(), + }; + let body = EdgeBody::stream(futures::stream::iter(vec![ + bytes::Bytes::from_static(b"body{background:url('https://origin.example.com/"), + bytes::Bytes::from_static(b"asset.png')}"), + ])); + let mut output = Vec::new(); + + stream_publisher_body_async( + body, + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("stream body should process on async path"); + + let css = String::from_utf8(output).expect("should be valid UTF-8"); + assert!( + css.contains("proxy.example.com"), + "should rewrite origin host while streaming. Got: {css}" + ); + assert!( + !css.contains("origin.example.com"), + "should not leave origin host after rewrite. Got: {css}" + ); + }); + } + + #[test] + fn stream_publisher_body_async_processes_gzip_stream_without_auction() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let services = noop_services(); + let mut params = OwnedProcessResponseParams { + content_encoding: "gzip".to_string(), + origin_host: "origin.example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + request_host: "proxy.example.com".to_string(), + request_scheme: "https".to_string(), + content_type: "text/css".to_string(), + ad_slots_script: None, + ad_bids_state: Arc::new(Mutex::new(None)), + auction_observation: None, + auction_request: None, + dispatched_auction: None, + price_granularity: crate::price_bucket::PriceGranularity::default(), + }; + let compressed = + gzip_encode(b"body{background:url('https://origin.example.com/asset.png')}"); + let split_at = compressed.len() / 2; + let body = EdgeBody::stream(futures::stream::iter(vec![ + bytes::Bytes::copy_from_slice(&compressed[..split_at]), + bytes::Bytes::copy_from_slice(&compressed[split_at..]), + ])); + let mut output = Vec::new(); + + stream_publisher_body_async( + body, + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("gzip stream body should process on async path"); + + let css = String::from_utf8(gzip_decode(&output)).expect("should be valid UTF-8"); + assert!( + css.contains("proxy.example.com"), + "should rewrite origin host while streaming gzip. Got: {css}" + ); + assert!( + !css.contains("origin.example.com"), + "should not leave origin host after gzip rewrite. Got: {css}" + ); + }); + } + + #[test] + fn stream_publisher_body_async_processes_deflate_stream_without_auction() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let services = noop_services(); + let mut params = OwnedProcessResponseParams { + content_encoding: "deflate".to_string(), + origin_host: "origin.example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + request_host: "proxy.example.com".to_string(), + request_scheme: "https".to_string(), + content_type: "text/css".to_string(), + ad_slots_script: None, + ad_bids_state: Arc::new(Mutex::new(None)), + auction_observation: None, + auction_request: None, + dispatched_auction: None, + price_granularity: crate::price_bucket::PriceGranularity::default(), + }; + let compressed = + deflate_encode(b"body{background:url('https://origin.example.com/asset.png')}"); + let split_at = compressed.len() / 2; + let body = EdgeBody::stream(futures::stream::iter(vec![ + bytes::Bytes::copy_from_slice(&compressed[..split_at]), + bytes::Bytes::copy_from_slice(&compressed[split_at..]), + ])); + let mut output = Vec::new(); + + stream_publisher_body_async( + body, + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("deflate stream body should process on async path"); + + let css = String::from_utf8(deflate_decode(&output)).expect("should be valid UTF-8"); + assert!( + css.contains("proxy.example.com"), + "should rewrite origin host while streaming deflate. Got: {css}" + ); + assert!( + !css.contains("origin.example.com"), + "should not leave origin host after deflate rewrite. Got: {css}" + ); + }); + } + + #[test] + fn stream_publisher_body_async_processes_brotli_stream_without_auction() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let services = noop_services(); + let mut params = OwnedProcessResponseParams { + content_encoding: "br".to_string(), + origin_host: "origin.example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + request_host: "proxy.example.com".to_string(), + request_scheme: "https".to_string(), + content_type: "text/css".to_string(), + ad_slots_script: None, + ad_bids_state: Arc::new(Mutex::new(None)), + auction_observation: None, + auction_request: None, + dispatched_auction: None, + price_granularity: crate::price_bucket::PriceGranularity::default(), + }; + let compressed = + brotli_encode(b"body{background:url('https://origin.example.com/asset.png')}"); + let split_at = compressed.len() / 2; + let body = EdgeBody::stream(futures::stream::iter(vec![ + bytes::Bytes::copy_from_slice(&compressed[..split_at]), + bytes::Bytes::copy_from_slice(&compressed[split_at..]), + ])); + let mut output = Vec::new(); + + stream_publisher_body_async( + body, + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("brotli stream body should process on async path"); + + let css = String::from_utf8(brotli_decode(&output)).expect("should be valid UTF-8"); + assert!( + css.contains("proxy.example.com"), + "should rewrite origin host while streaming brotli. Got: {css}" + ); + assert!( + !css.contains("origin.example.com"), + "should not leave origin host after brotli rewrite. Got: {css}" + ); + }); + } + + #[test] + fn stream_publisher_body_async_rejects_truncated_brotli_stream() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let services = noop_services(); + let mut params = OwnedProcessResponseParams { + content_encoding: "br".to_string(), + origin_host: "origin.example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + request_host: "proxy.example.com".to_string(), + request_scheme: "https".to_string(), + content_type: "text/css".to_string(), + ad_slots_script: None, + ad_bids_state: Arc::new(Mutex::new(None)), + auction_observation: None, + auction_request: None, + dispatched_auction: None, + price_granularity: crate::price_bucket::PriceGranularity::default(), + }; + let compressed = + brotli_encode(b"body{background:url('https://origin.example.com/asset.png')}"); + let truncated = &compressed[..compressed.len() - 3]; + let body = + EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::copy_from_slice( + truncated, + )])); + let mut output = Vec::new(); + + let err = stream_publisher_body_async( + body, + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect_err("truncated brotli stream must fail instead of truncating silently"); + + assert!( + format!("{err:?}").contains("brotli"), + "should surface the brotli finalization failure: {err:?}" + ); + }); + } + + #[test] + fn stream_publisher_body_async_processes_stream_with_auction_hold() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let services = noop_services(); + let state = Arc::new(Mutex::new(None)); + let mut params = OwnedProcessResponseParams { + content_encoding: String::new(), + origin_host: "origin.example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + request_host: "proxy.example.com".to_string(), + request_scheme: "https".to_string(), + content_type: "text/html; charset=utf-8".to_string(), + ad_slots_script: Some( + r#""# + .to_string(), + ), + ad_bids_state: state, + auction_observation: None, + auction_request: Some(test_auction_request()), + dispatched_auction: Some(DispatchedAuction::empty_for_test( + test_auction_request(), + 10, + )), + price_granularity: crate::price_bucket::PriceGranularity::default(), + }; + let body = EdgeBody::stream(futures::stream::iter(vec![ + bytes::Bytes::from_static(b"hello"), + bytes::Bytes::from_static(b""), + ])); + let mut output = Vec::new(); + + stream_publisher_body_async( + body, + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("stream body with auction should process on async path"); + + let html = String::from_utf8(output).expect("should be valid UTF-8"); + assert!( + html.contains("hello"), + "should preserve streamed HTML content. Got: {html}" + ); + assert!( + html.contains(".adSlots=JSON.parse"), + "should still inject ad slots. Got: {html}" + ); + assert!( + html.contains(".bids=JSON.parse"), + "should collect auction and inject bids before body close. Got: {html}" + ); + }); + } + + #[test] + fn stream_publisher_body_async_processes_non_html_stream_after_auction_collect() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let services = noop_services(); + let mut params = OwnedProcessResponseParams { + content_encoding: String::new(), + origin_host: "origin.example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + request_host: "proxy.example.com".to_string(), + request_scheme: "https".to_string(), + content_type: "text/css".to_string(), + ad_slots_script: None, + ad_bids_state: Arc::new(Mutex::new(None)), + auction_observation: None, + auction_request: Some(test_auction_request()), + dispatched_auction: Some(DispatchedAuction::empty_for_test( + test_auction_request(), + 10, + )), + price_granularity: crate::price_bucket::PriceGranularity::default(), + }; + let body = EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::from_static( + b"body{background:url('https://origin.example.com/asset.png')}", + )])); + let mut output = Vec::new(); + + stream_publisher_body_async( + body, + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("non-html stream body should process after auction collection"); + + let css = String::from_utf8(output).expect("should be valid UTF-8"); + assert!( + css.contains("proxy.example.com"), + "should rewrite non-html stream after auction collection. Got: {css}" + ); + assert!( + !css.contains("origin.example.com"), + "should not leave origin host after rewrite. Got: {css}" + ); + }); + } + + fn drain_streaming_finalize_body(content_encoding: &str, body: EdgeBody) -> Vec { + let settings = Arc::new(create_test_settings()); + let registry = Arc::new( + IntegrationRegistry::new(&settings).expect("should create integration registry"), + ); + let orchestrator = Arc::new(AuctionOrchestrator::new(settings.auction.clone())); + let services = noop_services(); + let response = Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/css") + .body(EdgeBody::empty()) + .expect("should build response"); + let params = OwnedProcessResponseParams { + content_encoding: content_encoding.to_string(), + origin_host: "origin.example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + request_host: "proxy.example.com".to_string(), + request_scheme: "https".to_string(), + content_type: "text/css".to_string(), + ad_slots_script: None, + ad_bids_state: Arc::new(Mutex::new(None)), + auction_observation: None, + auction_request: None, + dispatched_auction: None, + price_granularity: crate::price_bucket::PriceGranularity::default(), + }; + let publisher_response = PublisherResponse::Stream { + response, + body, + params: Box::new(params), + }; + + let response = publisher_response_into_streaming_response( + publisher_response, + &Method::GET, + Arc::clone(&settings), + registry.as_ref(), + orchestrator, + services, + ) + .expect("should build streaming response"); + + assert!( + matches!(response.body(), EdgeBody::Stream(_)), + "streaming finalize should keep a lazy Body::Stream" + ); + + futures::executor::block_on( + response + .into_body() + .into_bytes_bounded(settings.publisher.max_buffered_body_bytes), + ) + .expect("streaming body should drain") + .to_vec() + } + + #[test] + fn publisher_response_streaming_finalize_keeps_stream_body_lazy() { + let body_bytes = drain_streaming_finalize_body( + "", + EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::from_static( + b"body{background:url('https://origin.example.com/asset.png')}", + )])), + ); + let css = String::from_utf8(body_bytes).expect("should be valid UTF-8"); + assert!( + css.contains("proxy.example.com"), + "streaming response body should still run publisher rewriting. Got: {css}" + ); + assert!( + !css.contains("origin.example.com"), + "streaming response body should not leave origin URLs unrewritten. Got: {css}" + ); + } + + #[test] + fn publisher_response_streaming_finalize_processes_gzip_stream() { + let compressed = + gzip_encode(b"body{background:url('https://origin.example.com/asset.png')}"); + let split_at = compressed.len() / 2; + let output = drain_streaming_finalize_body( + "gzip", + EdgeBody::stream(futures::stream::iter(vec![ + bytes::Bytes::copy_from_slice(&compressed[..split_at]), + bytes::Bytes::copy_from_slice(&compressed[split_at..]), + ])), + ); + + let css = String::from_utf8(gzip_decode(&output)).expect("should be valid UTF-8"); + assert!( + css.contains("proxy.example.com"), + "streaming response finalize should rewrite gzip body. Got: {css}" + ); + assert!( + !css.contains("origin.example.com"), + "streaming response finalize should not leave gzip origin URLs. Got: {css}" + ); + } + + #[test] + fn publisher_response_streaming_finalize_processes_deflate_stream() { + let compressed = + deflate_encode(b"body{background:url('https://origin.example.com/asset.png')}"); + let split_at = compressed.len() / 2; + let output = drain_streaming_finalize_body( + "deflate", + EdgeBody::stream(futures::stream::iter(vec![ + bytes::Bytes::copy_from_slice(&compressed[..split_at]), + bytes::Bytes::copy_from_slice(&compressed[split_at..]), + ])), + ); + + let css = String::from_utf8(deflate_decode(&output)).expect("should be valid UTF-8"); + assert!( + css.contains("proxy.example.com"), + "streaming response finalize should rewrite deflate body. Got: {css}" + ); + assert!( + !css.contains("origin.example.com"), + "streaming response finalize should not leave deflate origin URLs. Got: {css}" + ); + } + + #[test] + fn publisher_response_streaming_finalize_processes_brotli_stream() { + let compressed = + brotli_encode(b"body{background:url('https://origin.example.com/asset.png')}"); + let split_at = compressed.len() / 2; + let output = drain_streaming_finalize_body( + "br", + EdgeBody::stream(futures::stream::iter(vec![ + bytes::Bytes::copy_from_slice(&compressed[..split_at]), + bytes::Bytes::copy_from_slice(&compressed[split_at..]), + ])), + ); + + let css = String::from_utf8(brotli_decode(&output)).expect("should be valid UTF-8"); + assert!( + css.contains("proxy.example.com"), + "streaming response finalize should rewrite brotli body. Got: {css}" + ); + assert!( + !css.contains("origin.example.com"), + "streaming response finalize should not leave brotli origin URLs. Got: {css}" + ); + } + + #[test] + fn publisher_response_streaming_finalize_holds_auction_and_keeps_gzip_tail() { + let settings = Arc::new(create_test_settings()); + let registry = Arc::new( + IntegrationRegistry::new(&settings).expect("should create integration registry"), + ); + let orchestrator = Arc::new(AuctionOrchestrator::new(settings.auction.clone())); + let services = noop_services(); + let response = Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") + .body(EdgeBody::empty()) + .expect("should build response"); + // The trailing content after `` must exceed the flate2 write + // decoder's 32 KiB internal output buffer: the close-body tag then + // surfaces (and releases the auction hold) mid-stream, while the + // trailing markup only surfaces at decoder finalization. This guards + // against the EOF decoded tail being dropped once the hold is gone. + let trailing_comment = format!("", "trailing-content ".repeat(3 * 1024)); + let page = format!("hello{trailing_comment}"); + let compressed = gzip_encode(page.as_bytes()); + let chunks: Vec = compressed + .chunks(STREAM_CHUNK_SIZE) + .map(bytes::Bytes::copy_from_slice) + .collect(); + let params = OwnedProcessResponseParams { + content_encoding: "gzip".to_string(), + origin_host: "origin.example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + request_host: "proxy.example.com".to_string(), + request_scheme: "https".to_string(), + content_type: "text/html; charset=utf-8".to_string(), + ad_slots_script: Some( + r#""# + .to_string(), + ), + ad_bids_state: Arc::new(Mutex::new(None)), + auction_observation: None, + auction_request: Some(test_auction_request()), + dispatched_auction: Some(DispatchedAuction::empty_for_test( + test_auction_request(), + 10, + )), + price_granularity: crate::price_bucket::PriceGranularity::default(), + }; + let publisher_response = PublisherResponse::Stream { + response, + body: EdgeBody::stream(futures::stream::iter(chunks)), + params: Box::new(params), + }; + + let response = publisher_response_into_streaming_response( + publisher_response, + &Method::GET, + Arc::clone(&settings), + registry.as_ref(), + orchestrator, + services, + ) + .expect("should build streaming response"); + + let output = futures::executor::block_on( + response + .into_body() + .into_bytes_bounded(settings.publisher.max_buffered_body_bytes), + ) + .expect("streaming body should drain") + .to_vec(); + + let html = String::from_utf8(gzip_decode(&output)).expect("should be valid UTF-8"); + assert!( + html.contains(".bids=JSON.parse"), + "should collect the held auction and inject bids. Got tail: {}", + &html[html.len().saturating_sub(200)..] + ); + assert!( + html.contains("trailing-content"), + "should preserve content after the close-body tag" + ); + assert!( + html.trim_end().ends_with(""), + "should not drop the decoded tail once the auction hold is released. Got tail: {}", + &html[html.len().saturating_sub(200)..] + ); + } + #[test] fn stream_publisher_body_treats_mixed_case_html_as_html() { let settings = create_test_settings(); diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 9cbb2a54..008b4a2d 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -50,15 +50,12 @@ pub struct Publisher { /// exceeding it fails the response rather than allocating past the cap. /// Defaults to 16 MiB — a conservative cap that prevents Wasm-heap OOM. /// - /// On Fastly the *effective* ceiling for a publisher page is lower: the - /// platform HTTP client rejects any origin response whose raw (still - /// compressed) body exceeds 10 MiB before this buffer is ever filled, so - /// raising this value only helps highly compressible pages whose decoded - /// size exceeds the 16 MiB default while their compressed origin body stays - /// under 10 MiB. Raising it above ~10 MiB does not lift the platform cap for - /// uncompressed pages. That platform limit is removed once true streaming - /// lands (tracked for PR 15, issue #495), after which this setting becomes - /// the sole ceiling. + /// Fastly origin bodies are preserved as streams on the publisher path, so + /// this setting is also the cumulative raw-byte cap while the streaming + /// processor decodes and rewrites chunks. Buffered adapters keep using it + /// as the post-rewrite output buffer cap. On the streaming path headers + /// are already committed when the cap trips, so the response is truncated + /// mid-body (with the error logged) rather than replaced with a 5xx. /// /// Must be at least 1: a zero-byte cap fails every non-empty buffered /// publisher response at request time, so it is rejected at config diff --git a/crates/trusted-server-core/src/streaming_processor.rs b/crates/trusted-server-core/src/streaming_processor.rs index 5692118a..ef1a0bc5 100644 --- a/crates/trusted-server-core/src/streaming_processor.rs +++ b/crates/trusted-server-core/src/streaming_processor.rs @@ -349,6 +349,197 @@ impl StreamProcessor for StreamingReplacer { } } +/// Read buffer size for streaming body processing and brotli internal buffers. +/// Both the `Decompressor` and `CompressorWriter` use this value so all +/// brotli I/O layers operate on consistently-sized chunks. +pub(crate) const STREAM_CHUNK_SIZE: usize = 8192; + +/// Incremental push-style decompressor for the async chunk pipeline. +/// +/// Compressed bytes go in via [`Self::decode_chunk`]; decoded bytes drain +/// out of the internal buffer after every push. Write-based decoders are +/// used because the async publisher path cannot wrap a blocking `Read`. +pub(crate) enum BodyStreamDecoder { + None, + Gzip(flate2::write::GzDecoder>), + Deflate(flate2::write::ZlibDecoder>), + Brotli(Box>>), +} + +impl BodyStreamDecoder { + pub(crate) fn new(compression: Compression) -> Self { + match compression { + Compression::None => Self::None, + Compression::Gzip => Self::Gzip(flate2::write::GzDecoder::new(Vec::new())), + Compression::Deflate => Self::Deflate(flate2::write::ZlibDecoder::new(Vec::new())), + Compression::Brotli => Self::Brotli(Box::new(brotli::DecompressorWriter::new( + Vec::new(), + STREAM_CHUNK_SIZE, + ))), + } + } + + pub(crate) fn decode_chunk( + &mut self, + chunk: &[u8], + ) -> Result, Report> { + match self { + Self::None => Ok(chunk.to_vec()), + Self::Gzip(decoder) => { + decoder + .write_all(chunk) + .change_context(TrustedServerError::Proxy { + message: "Failed to decode gzip publisher body chunk".to_string(), + })?; + Ok(std::mem::take(decoder.get_mut())) + } + Self::Deflate(decoder) => { + decoder + .write_all(chunk) + .change_context(TrustedServerError::Proxy { + message: "Failed to decode deflate publisher body chunk".to_string(), + })?; + Ok(std::mem::take(decoder.get_mut())) + } + Self::Brotli(decoder) => { + decoder + .write_all(chunk) + .change_context(TrustedServerError::Proxy { + message: "Failed to decode brotli publisher body chunk".to_string(), + })?; + Ok(std::mem::take(decoder.get_mut())) + } + } + } + + pub(crate) fn finish(&mut self) -> Result, Report> { + match self { + Self::None => Ok(Vec::new()), + Self::Gzip(decoder) => { + decoder + .try_finish() + .change_context(TrustedServerError::Proxy { + message: "Failed to finalize gzip publisher body decoder".to_string(), + })?; + Ok(std::mem::take(decoder.get_mut())) + } + Self::Deflate(decoder) => { + decoder + .try_finish() + .change_context(TrustedServerError::Proxy { + message: "Failed to finalize deflate publisher body decoder".to_string(), + })?; + Ok(std::mem::take(decoder.get_mut())) + } + Self::Brotli(decoder) => { + // `close()` (not `flush()`): flush accepts a truncated brotli + // stream silently, while close validates end-of-stream and + // errors on incomplete input, matching the gzip/deflate arms. + decoder.close().change_context(TrustedServerError::Proxy { + message: "Failed to finalize brotli publisher body decoder".to_string(), + })?; + Ok(std::mem::take(decoder.get_mut())) + } + } + } +} + +/// Incremental push-style compressor mirroring [`BodyStreamDecoder`]. +/// +/// Processed bytes go in via [`Self::encode_chunk`]; encoded bytes drain out +/// after every push, and [`Self::finish`] emits the stream trailer. +pub(crate) enum BodyStreamEncoder { + None, + Gzip(flate2::write::GzEncoder>), + Deflate(flate2::write::ZlibEncoder>), + Brotli(Box>>), +} + +fn new_brotli_vec_encoder() -> brotli::enc::writer::CompressorWriter> { + let params = brotli::enc::BrotliEncoderParams { + quality: 4, + lgwin: 22, + ..Default::default() + }; + brotli::enc::writer::CompressorWriter::with_params(Vec::new(), STREAM_CHUNK_SIZE, ¶ms) +} + +impl BodyStreamEncoder { + pub(crate) fn new(compression: Compression) -> Self { + match compression { + Compression::None => Self::None, + Compression::Gzip => Self::Gzip(flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::default(), + )), + Compression::Deflate => Self::Deflate(flate2::write::ZlibEncoder::new( + Vec::new(), + flate2::Compression::default(), + )), + Compression::Brotli => Self::Brotli(Box::new(new_brotli_vec_encoder())), + } + } + + pub(crate) fn encode_chunk( + &mut self, + chunk: &[u8], + ) -> Result, Report> { + match self { + Self::None => Ok(chunk.to_vec()), + Self::Gzip(encoder) => { + encoder + .write_all(chunk) + .change_context(TrustedServerError::Proxy { + message: "Failed to encode gzip publisher body chunk".to_string(), + })?; + Ok(std::mem::take(encoder.get_mut())) + } + Self::Deflate(encoder) => { + encoder + .write_all(chunk) + .change_context(TrustedServerError::Proxy { + message: "Failed to encode deflate publisher body chunk".to_string(), + })?; + Ok(std::mem::take(encoder.get_mut())) + } + Self::Brotli(encoder) => { + encoder + .write_all(chunk) + .change_context(TrustedServerError::Proxy { + message: "Failed to encode brotli publisher body chunk".to_string(), + })?; + Ok(std::mem::take(encoder.get_mut())) + } + } + } + + pub(crate) fn finish(&mut self) -> Result, Report> { + match self { + Self::None => Ok(Vec::new()), + Self::Gzip(encoder) => { + encoder + .try_finish() + .change_context(TrustedServerError::Proxy { + message: "Failed to finalize gzip publisher body encoder".to_string(), + })?; + Ok(std::mem::take(encoder.get_mut())) + } + Self::Deflate(encoder) => { + encoder + .try_finish() + .change_context(TrustedServerError::Proxy { + message: "Failed to finalize deflate publisher body encoder".to_string(), + })?; + Ok(std::mem::take(encoder.get_mut())) + } + Self::Brotli(encoder) => { + let encoder = std::mem::replace(encoder, Box::new(new_brotli_vec_encoder())); + Ok((*encoder).into_inner()) + } + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/docs/superpowers/plans/2026-07-08-true-origin-streaming-fastly.md b/docs/superpowers/plans/2026-07-08-true-origin-streaming-fastly.md new file mode 100644 index 00000000..bc1983a9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-true-origin-streaming-fastly.md @@ -0,0 +1,1039 @@ +# True Origin Streaming Fastly Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix issue #849 for the production Fastly path so publisher HTML origin bodies stream through the rewrite pipeline to the client, with auction collection outside TTFB except for the held `` tail. + +**Architecture:** Keep one cohesive Fastly PR because the core pipeline, Fastly origin fetch, and Fastly finalize path are incomplete in isolation. Convert publisher body processing from sync `Read` over buffered bodies to async chunk-pull over `edgezero_core::body::Body`, then enable Fastly `with_stream_response()` and return a lazy streaming body for publisher responses. Leave Cloudflare, Spin, and Axum streaming as follow-up work. + +**Tech Stack:** Rust 2024, `edgezero_core::body::Body`, `futures::StreamExt`, `error-stack`, `flate2`, `brotli`, Fastly Compute, Viceroy tests. + +--- + +## Scope + +In scope: + +- Publisher HTML and processable publisher responses on the Fastly adapter. +- Core publisher pipeline support for `Body::Stream`. +- Fastly platform capability signaling for streaming origin responses. +- Fastly EdgeZero response delivery that streams publisher bodies to clients. +- Tests proving stream-vs-buffer parity, bodiless handling, stream caps, and Fastly routing behavior. + +Out of scope: + +- Cloudflare origin streaming. Current adapter rejects `PlatformHttpRequest::stream_response`. +- Spin streaming. Current adapter and upstream EdgeZero Spin conversion are buffered/blocking issues. +- Axum client streaming. Axum is dev-only and has `LocalBoxStream`/`Send` constraints. +- Parser-context `` scan fix from issue #850. +- Origin template caching and transformed HTML caching from issue #852. + +## Current Failure Points + +- `crates/trusted-server-adapter-fastly/src/platform.rs`: `fastly_response_to_platform(..., stream_response: false)` uses `take_body_bytes()` and the 10 MiB platform cap for publisher origin responses. +- `crates/trusted-server-core/src/publisher.rs`: `body_as_reader()` calls `body.into_bytes().unwrap_or_default()`, so `Body::Stream` becomes an empty body. +- `crates/trusted-server-core/src/publisher.rs`: `stream_html_with_auction_hold()` and `body_close_hold_loop()` are sync-`Read` based. +- `crates/trusted-server-adapter-fastly/src/app.rs`: publisher route calls `buffer_publisher_response_async()`, buffering all processed output and awaiting auction before any client bytes are sent. +- `crates/trusted-server-adapter-fastly/src/main.rs`: `send_edgezero_response()` already streams `EdgeBody::Stream`, but currently only asset responses reach that arm. + +## File Structure + +- Modify `crates/trusted-server-core/src/platform/http.rs` + - Add `PlatformHttpClient::supports_streaming_responses()` with default `false`. +- Modify adapter platform implementations: + - `crates/trusted-server-adapter-fastly/src/platform.rs`: return `true` for `supports_streaming_responses()`. + - `crates/trusted-server-adapter-cloudflare/src/platform.rs`: inherit default `false`. + - `crates/trusted-server-adapter-spin/src/platform.rs`: inherit default `false`. + - `crates/trusted-server-adapter-axum/src/platform.rs`: inherit default `false`. + - `crates/trusted-server-core/src/platform/test_support.rs`: configurable test support if needed. +- Modify `crates/trusted-server-core/src/streaming_processor.rs` + - Add small push decoder/encoder helpers only if keeping them here reduces duplication. + - Keep the existing `StreamingPipeline::process(Read, Write)` API for existing call sites. +- Modify `crates/trusted-server-core/src/publisher.rs` + - Replace publisher async processing internals with async chunk-pull. + - Keep public `buffer_publisher_response_async()` for buffered adapters. + - Add a streaming response constructor/helper for Fastly to use. + - Make `body_as_reader()` reject `Body::Stream` loudly or remove its use from any stream-capable path. +- Modify `crates/trusted-server-adapter-fastly/src/app.rs` + - Replace publisher `buffer_publisher_response_async()` call with streaming finalize for streamable publisher responses. + - Preserve buffered behavior for `PublisherResponse::Buffered`, pass-through/bodiless responses, and error paths. +- Modify `crates/trusted-server-adapter-fastly/src/main.rs` + - Reuse existing `EdgeBody::Stream` delivery. + - If publisher streaming needs a different log message from asset streaming, split the helper name/log text without changing behavior. +- Tests: + - `crates/trusted-server-core/src/publisher.rs` unit tests. + - `crates/trusted-server-core/src/streaming_processor.rs` unit tests if push codec helpers are introduced there. + - `crates/trusted-server-core/src/platform/test_support.rs` tests for capability behavior. + - `crates/trusted-server-adapter-fastly/src/app.rs` route tests for publisher streaming response shape. + +## Design Decisions + +- Use one PR for the full Fastly production fix. Intermediate merged PRs would create incomplete behavior and review confusion. +- Use existing `publisher.max_buffered_body_bytes` as the publisher body ceiling after streaming. `settings.rs` already documents that this becomes the sole ceiling after true streaming removes the 10 MiB Fastly materialization cap. +- Keep `Content-Length` removed for rewritten stream responses. Streaming output can change size due to URL rewriting and bid injection. +- Preserve bodiless behavior for `HEAD`, `204`, and `304`: do not attach or drive a body stream, and log abandoned/wasted auctions as current code does. +- Do not build a sync `Read` bridge over `Body::Stream`; nested `block_on` can panic on Fastly because the router already runs under `futures::executor::block_on`. +- Avoid adding `async-stream` initially. Use `futures::stream::unfold` or a custom stream type so the PR does not add a dependency unless the implementation becomes materially clearer. + +## Task 1: Baseline Tests for Stream Input Safety + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs` + +- [ ] **Step 1: Add a failing test for `Body::Stream` not becoming empty** + +Add a test near the existing `stream_publisher_body` tests: + +```rust +#[test] +fn stream_publisher_body_rejects_stream_body_in_sync_path() { + let settings = create_test_settings(); + let registry = IntegrationRegistry::new(&settings).expect("should build registry"); + let body = EdgeBody::from_stream(futures::stream::iter(vec![Ok(Bytes::from_static( + b"live", + ))])); + let params = test_process_params("text/html", ""); + let mut output = Vec::new(); + + let err = stream_publisher_body(body, &mut output, ¶ms, &settings, ®istry) + .expect_err("should reject stream body in sync path"); + + assert!( + format!("{err:?}").contains("streaming body"), + "should explain that Body::Stream is not supported by the sync path: {err:?}" + ); +} +``` + +- [ ] **Step 2: Run the targeted test and verify it fails** + +Run: + +```bash +cargo test-axum stream_publisher_body_rejects_stream_body_in_sync_path +``` + +Expected: FAIL because current `body_as_reader()` silently returns empty bytes. + +- [ ] **Step 3: Replace `body_as_reader()` with a fallible helper** + +Change `body_as_reader(body: EdgeBody) -> Cursor` to return `Result, Report>` and return a proxy error for `Body::Stream`. + +Minimal shape: + +```rust +fn body_as_reader(body: EdgeBody) -> Result, Report> { + let bytes = body.into_bytes().ok_or_else(|| { + Report::new(TrustedServerError::Proxy { + message: "streaming body cannot be processed by sync publisher pipeline".to_owned(), + }) + })?; + Ok(std::io::Cursor::new(bytes)) +} +``` + +Update existing sync call sites to use `body_as_reader(body)?`. + +- [ ] **Step 4: Run the targeted test and existing publisher sync tests** + +Run: + +```bash +cargo test-axum stream_publisher_body +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/trusted-server-core/src/publisher.rs +git commit -m "Reject publisher stream bodies on sync path" +``` + +## Task 2: Async Chunk Source and Cumulative Cap + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs` + +- [ ] **Step 1: Add tests for async chunk pulling** + +Add focused tests for a private helper that will pull chunks from both body variants: + +```rust +#[test] +fn body_chunk_source_yields_once_body_in_chunks() { + futures::executor::block_on(async { + let body = EdgeBody::from(Bytes::from_static(b"abcdef")); + let mut source = BodyChunkSource::new(body, 3); + + assert_eq!(source.next_chunk().await.expect("should read").as_deref(), Some(&b"abc"[..])); + assert_eq!(source.next_chunk().await.expect("should read").as_deref(), Some(&b"def"[..])); + assert!(source.next_chunk().await.expect("should read").is_none()); + }); +} +``` + +Add a separate test for `Body::Stream` preserving chunk boundaries and surfacing stream errors. + +- [ ] **Step 2: Add a failing test for the cumulative cap** + +Use a stream with two chunks whose total exceeds a small cap: + +```rust +#[test] +fn body_chunk_source_enforces_cumulative_raw_cap() { + futures::executor::block_on(async { + let body = EdgeBody::from_stream(futures::stream::iter(vec![ + Ok(Bytes::from_static(b"1234")), + Ok(Bytes::from_static(b"5678")), + ])); + let mut source = BodyChunkSource::new(body, STREAM_CHUNK_SIZE).with_max_bytes(6); + + assert!(source.next_chunk().await.expect("first chunk should pass").is_some()); + let err = source.next_chunk().await.expect_err("second chunk should exceed cap"); + assert!( + format!("{err:?}").contains("publisher origin body exceeded"), + "should report cumulative cap: {err:?}" + ); + }); +} +``` + +- [ ] **Step 3: Run the new tests and verify they fail** + +Run: + +```bash +cargo test-axum body_chunk_source +``` + +Expected: FAIL because helper does not exist. + +- [ ] **Step 4: Implement `BodyChunkSource`** + +Implement a private helper near `STREAM_CHUNK_SIZE`: + +- Owns `EdgeBody`. +- For `Body::Once`, yields `Bytes` slices up to `chunk_size` without copying more than necessary. +- For `Body::Stream`, awaits `stream.next()`. +- Tracks cumulative raw bytes and errors when total exceeds `max_bytes`. +- Maps stream errors to `TrustedServerError::Proxy`. + +Do not use `block_on` inside the helper. + +- [ ] **Step 5: Run helper tests** + +Run: + +```bash +cargo test-axum body_chunk_source +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add crates/trusted-server-core/src/publisher.rs +git commit -m "Add async publisher body chunk source" +``` + +## Task 3: Push Compression Helpers + +**Files:** + +- Modify: `crates/trusted-server-core/src/streaming_processor.rs` +- Or modify: `crates/trusted-server-core/src/publisher.rs` if helpers are publisher-only + +- [ ] **Step 1: Add parity tests for compressed chunk processing** + +For each compression mode used by publisher HTML (`gzip`, `deflate`, `br`), add a test that feeds compressed HTML in multiple raw chunks through the future async path and verifies the decompressed/processed/recompressed output decodes to expected HTML. + +Start with gzip: + +```rust +#[test] +fn async_publisher_pipeline_preserves_gzip_html_across_stream_chunks() { + futures::executor::block_on(async { + let compressed = gzip_bytes(b"Hello"); + let body = EdgeBody::from_stream(bytes_to_two_chunk_stream(compressed)); + let output = process_test_body_async(body, "text/html", "gzip") + .await + .expect("should process gzip stream"); + + assert_eq!( + gunzip_bytes(&output), + b"Hello" + ); + }); +} +``` + +Use existing HTML processor expectations rather than inventing new behavior. + +- [ ] **Step 2: Run the gzip test and verify it fails** + +Run: + +```bash +cargo test-axum async_publisher_pipeline_preserves_gzip_html_across_stream_chunks +``` + +Expected: FAIL because async compressed processing does not exist. + +- [ ] **Step 3: Implement write-based push decoders** + +Use write-based APIs: + +- `flate2::write::GzDecoder` +- `flate2::write::ZlibDecoder` +- `brotli::DecompressorWriter` + +The helper should: + +- Accept raw compressed chunks. +- Write decoded bytes into an internal `Vec` sink. +- Return newly decoded bytes after each input chunk. +- Finalize at EOF and return any decoder tail bytes. +- Surface decoder errors as `TrustedServerError::Proxy`. + +Keep this helper private unless tests or other modules need it. + +- [ ] **Step 4: Implement output encoding wrapper** + +Continue to use existing write-based encoders: + +- `flate2::write::GzEncoder` +- `flate2::write::ZlibEncoder` +- `brotli::enc::writer::CompressorWriter` + +The async loop should write processed decoded chunks into the encoder and finalize once. + +- [ ] **Step 5: Add deflate and brotli tests** + +Run: + +```bash +cargo test-axum async_publisher_pipeline_preserves_ +``` + +Expected: gzip, deflate, and brotli async parity tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add crates/trusted-server-core/src/streaming_processor.rs crates/trusted-server-core/src/publisher.rs +git commit -m "Add push compression support for publisher streams" +``` + +## Task 4: Async Publisher Pipeline Without Auction + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs` + +- [ ] **Step 1: Add stream-vs-once parity tests for no-auction paths** + +Cover: + +- HTML rewrite. +- RSC flight rewrite. +- Generic URL replacement. +- Unsupported stream body cannot reach sync path. + +Example: + +```rust +#[test] +fn stream_publisher_body_async_matches_buffered_html_without_auction() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let registry = IntegrationRegistry::new(&settings).expect("should build registry"); + let html = Bytes::from_static(b"x"); + + let mut once_params = test_process_params("text/html", ""); + let mut once_output = Vec::new(); + stream_publisher_body_async( + EdgeBody::from(html.clone()), + &mut once_output, + &mut once_params, + &settings, + ®istry, + &AuctionOrchestrator::new(settings.auction.clone()), + &noop_services(), + ) + .await + .expect("once body should process"); + + let mut stream_params = test_process_params("text/html", ""); + let mut stream_output = Vec::new(); + stream_publisher_body_async( + EdgeBody::from_stream(futures::stream::iter(vec![Ok(html)])), + &mut stream_output, + &mut stream_params, + &settings, + ®istry, + &AuctionOrchestrator::new(settings.auction.clone()), + &noop_services(), + ) + .await + .expect("stream body should process"); + + assert_eq!(stream_output, once_output); + }); +} +``` + +- [ ] **Step 2: Run parity tests and verify they fail** + +Run: + +```bash +cargo test-axum stream_publisher_body_async_matches_buffered +``` + +Expected: FAIL because no-auction async path still delegates to sync processing. + +- [ ] **Step 3: Refactor `process_response_streaming` into reusable processor construction** + +Extract the shared routing logic into a helper such as: + +```rust +enum PublisherProcessor { + Html(HtmlRewriterAdapter), + Rsc(RscFlightUrlRewriter), + Url(StreamingReplacer), +} +``` + +Or use a generic closure/helper if that fits existing patterns better. The goal is to avoid duplicating content-type routing between sync and async paths. + +- [ ] **Step 4: Drive all `stream_publisher_body_async()` calls through async chunk-pull** + +Even when `params.dispatched_auction` is `None`, build the same processor and use `BodyChunkSource`. This prevents stream bodies from falling into the sync path. + +- [ ] **Step 5: Keep `stream_publisher_body()` for compatibility** + +The sync function should remain for old tests and any current non-stream callers, but it must not be used by the async path once this task is complete. + +- [ ] **Step 6: Run targeted tests** + +Run: + +```bash +cargo test-axum stream_publisher_body_async_matches_buffered +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add crates/trusted-server-core/src/publisher.rs +git commit -m "Drive publisher async processing from body chunks" +``` + +## Task 5: Async Auction Hold Loop + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs` + +- [ ] **Step 1: Replace reader-based hold-loop test with stream-based test** + +Update or add a test based on `body_close_hold_loop_processes_close_tail_before_reading_post_body_chunks()`: + +- Feed pre-`` chunk. +- Feed held `` chunk. +- Feed post-body chunk. +- Assert the loop collects auction immediately when `` test** + +Verify that auction collection happens at EOF and finalization still calls `processor.process_chunk(&[], true)`. + +- [ ] **Step 3: Add stream error abandonment test** + +Feed a stream error after dispatch and assert telemetry abandonment uses `stream_read_error` or the current expected reason. + +- [ ] **Step 4: Run tests and verify failures** + +Run: + +```bash +cargo test-axum body_close_hold_loop +``` + +Expected: FAIL until the loop consumes `BodyChunkSource`. + +- [ ] **Step 5: Change `body_close_hold_loop` to async chunk-pull** + +Replace: + +```rust +async fn body_close_hold_loop(...) +``` + +with a shape that accepts decoded chunks from an async driver, or accepts `BodyChunkSource` plus codec state. Keep the control flow: + +- Push decoded chunks into `BodyCloseHoldBuffer`. +- Write ready bytes immediately. +- On first ` bool { + false +} +``` + +In `FastlyPlatformHttpClient`: + +```rust +fn supports_streaming_responses(&self) -> bool { + true +} +``` + +In `StubHttpClient`, add a configurable flag if tests need both states. + +- [ ] **Step 4: Enable publisher origin streaming behind the gate** + +At the publisher origin fetch: + +```rust +let mut platform_request = PlatformHttpRequest::new(req, backend_name); +if services.http_client().supports_streaming_responses() { + platform_request = platform_request.with_stream_response(); +} +let mut response = services.http_client().send(platform_request).await?; +``` + +- [ ] **Step 5: Run capability tests** + +Run: + +```bash +cargo test-axum publisher_origin_fetch_sets_stream_response_when_supported +cargo test-axum publisher_origin_fetch_leaves_stream_response_disabled_when_unsupported +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add crates/trusted-server-core/src/platform/http.rs crates/trusted-server-adapter-fastly/src/platform.rs crates/trusted-server-core/src/platform/test_support.rs crates/trusted-server-core/src/publisher.rs +git commit -m "Gate publisher origin streaming by platform capability" +``` + +## Task 8: Fastly Publisher Streaming Finalize + +**Files:** + +- Modify: `crates/trusted-server-adapter-fastly/src/app.rs` +- Modify: `crates/trusted-server-core/src/publisher.rs` if a helper is needed +- Possibly modify: `crates/trusted-server-adapter-fastly/src/main.rs` for logging/helper naming + +- [ ] **Step 1: Add Fastly route test for publisher response body shape** + +In Fastly app tests, configure a publisher HTML origin response and assert the router returns `Body::Stream` for processable publisher responses on `GET`. + +Expected assertion: + +```rust +assert!( + matches!(response.body(), Body::Stream(_)), + "processable publisher response should remain streaming on Fastly" +); +``` + +- [ ] **Step 2: Add bodiless route tests** + +Assert `HEAD`, `204`, and `304` publisher responses do not carry a stream body, preserving existing metadata. + +- [ ] **Step 3: Run tests and verify failure** + +Run: + +```bash +cargo test-fastly publisher_response_streams +``` + +Expected: FAIL because app still calls `buffer_publisher_response_async()`. + +- [ ] **Step 4: Add a core helper to convert `PublisherResponse` to streaming response** + +Preferred shape in `publisher.rs`: + +```rust +pub fn publisher_response_into_streaming_body( + publisher_response: PublisherResponse, + method: &Method, + settings: Arc, + integration_registry: Arc, + orchestrator: Arc, + services: RuntimeServices, +) -> Result, Report> +``` + +The helper should: + +- Return `PublisherResponse::Buffered` unchanged. +- Return `PublisherResponse::PassThrough` with body attached, except bodiless responses. +- For `PublisherResponse::Stream`, build `EdgeBody::from_stream(futures::stream::unfold(...))` or equivalent. +- Move `OwnedProcessResponseParams`, origin body, settings, registry, orchestrator, and services into the stream state. +- Yield processed chunks as they become available. +- On mid-stream processing error, log and end the stream. The client sees a truncated body, matching existing mid-stream error behavior. + +If borrowing/lifetime pressure is high, keep the helper in Fastly `app.rs` and call core `stream_publisher_body_async()` from inside the stream. Prefer core if it avoids Fastly-specific body processing logic. + +- [ ] **Step 5: Replace Fastly buffered finalize for publisher route** + +In `handle_publisher_route`, replace the `buffer_publisher_response_async()` call for Fastly with the streaming helper. Keep non-Fastly adapters on `buffer_publisher_response_async()`. + +- [ ] **Step 6: Preserve entry-point finalization** + +Verify the returned `Response` still carries extensions needed by `main.rs`: + +- `EcFinalizeState` +- `RequestFilterEffects` +- Final cache privacy guard + +Headers must be finalized before `send_edgezero_response()` splits the response and commits headers. + +- [ ] **Step 7: Run Fastly route tests** + +Run: + +```bash +cargo test-fastly publisher_response +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add crates/trusted-server-adapter-fastly/src/app.rs crates/trusted-server-core/src/publisher.rs crates/trusted-server-adapter-fastly/src/main.rs +git commit -m "Stream Fastly publisher responses to clients" +``` + +## Task 9: Pass-Through Publisher Bodies + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/app.rs` + +- [ ] **Step 1: Add pass-through large body test** + +Verify a non-processable successful publisher response (`image/png`, font, video) is returned as `Body::Stream` when origin streaming is supported and body is not bodiless. + +- [ ] **Step 2: Add pass-through bodiless test** + +Verify `HEAD`, `204`, and `304` pass-through arms preserve headers but do not attach/drain the stream body. + +- [ ] **Step 3: Run targeted tests** + +Run: + +```bash +cargo test-fastly publisher_pass_through +``` + +Expected: FAIL if pass-through still buffers or attaches a body for bodiless responses. + +- [ ] **Step 4: Make pass-through use the same body-carrying guard** + +Mirror `asset_response_carries_body()` semantics in publisher finalize. If `response_carries_body(method, status)` is false, drop the body and return headers only. + +- [ ] **Step 5: Run targeted tests** + +Run: + +```bash +cargo test-fastly publisher_pass_through +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add crates/trusted-server-core/src/publisher.rs crates/trusted-server-adapter-fastly/src/app.rs +git commit -m "Preserve publisher pass-through streaming semantics" +``` + +## Task 10: Headers, Length, and Error Semantics + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/app.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/main.rs` if logs are misleading + +- [ ] **Step 1: Add header tests** + +Assert for streamed processed publisher responses: + +- `Content-Length` is absent. +- `Transfer-Encoding` is not manually set. +- `Content-Encoding` is preserved when recompression is used. +- Cache/privacy headers still downgrade when `Set-Cookie` is present. + +- [ ] **Step 2: Add mid-stream cap/error test** + +Use a body stream that exceeds `publisher.max_buffered_body_bytes` after headers would be committed. Assert the stream returns an error/truncates consistently with existing mid-stream asset behavior and logs enough context. + +- [ ] **Step 3: Run tests and verify failures** + +Run: + +```bash +cargo test-fastly publisher_stream +``` + +Expected: FAIL until header/error cleanup is complete. + +- [ ] **Step 4: Clean header handling** + +Ensure the existing `response.headers_mut().remove(header::CONTENT_LENGTH)` remains on `PublisherResponse::Stream`. Do not re-add content length for streaming finalize. + +- [ ] **Step 5: Improve log wording** + +If `main.rs` still logs "asset streaming" for all `EdgeBody::Stream` responses, rename log messages to "EdgeZero streaming body" or split publisher/asset helpers. + +- [ ] **Step 6: Run tests** + +Run: + +```bash +cargo test-fastly publisher_stream +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add crates/trusted-server-core/src/publisher.rs crates/trusted-server-adapter-fastly/src/app.rs crates/trusted-server-adapter-fastly/src/main.rs +git commit -m "Tighten publisher streaming headers and errors" +``` + +## Task 11: End-to-End Regression Coverage + +**Files:** + +- Modify: `crates/trusted-server-adapter-fastly/src/app.rs` +- Modify: existing integration/parity tests if appropriate + +- [ ] **Step 1: Add a slow-origin behavior test if feasible in existing harness** + +Preferred test shape: + +- Origin response body is a stream with first chunk available immediately and second chunk delayed or instrumented. +- Router returns `Body::Stream` without collecting the whole body. +- Pulling the first output chunk does not require pulling the entire origin stream. + +If the harness cannot model time cleanly, use an instrumented stream that panics if polled past the first chunk before the returned response body is consumed. + +- [ ] **Step 2: Add auction timing test** + +Verify response construction does not await `collect_dispatched_auction`; collection happens when the body stream is pulled and reaches `` or EOF. + +- [ ] **Step 3: Run targeted tests** + +Run: + +```bash +cargo test-fastly publisher_streaming_does_not_buffer_origin_before_response +``` + +Expected: PASS after Fastly finalize is lazy. + +- [ ] **Step 4: Commit** + +```bash +git add crates/trusted-server-adapter-fastly/src/app.rs crates/trusted-server-core/src/publisher.rs +git commit -m "Cover lazy publisher streaming behavior" +``` + +## Task 12: Documentation Cleanup + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs` +- Modify: `crates/trusted-server-core/src/settings.rs` +- Modify: `crates/trusted-server-adapter-fastly/src/app.rs` +- Optionally modify: issue/PR description only, not repo docs + +- [ ] **Step 1: Update stale interim comments** + +Remove or rewrite comments saying publisher stream bodies are already materialized into WASM heap. + +Targets: + +- `PublisherResponse::Stream` doc. +- `PublisherResponse::PassThrough` doc if Fastly now preserves stream bodies. +- `settings.rs` comments that reference future true streaming. +- Fastly app module comment that says publisher responses are buffered by `publisher.max_buffered_body_bytes`. + +- [ ] **Step 2: Run doc-related checks locally** + +Run: + +```bash +cargo fmt --all -- --check +``` + +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add crates/trusted-server-core/src/publisher.rs crates/trusted-server-core/src/settings.rs crates/trusted-server-adapter-fastly/src/app.rs +git commit -m "Update publisher streaming documentation" +``` + +## Task 13: Full Verification + +**Files:** + +- No source edits unless failures reveal issues. + +- [ ] **Step 1: Run formatting** + +Run: + +```bash +cargo fmt --all -- --check +``` + +Expected: PASS. + +- [ ] **Step 2: Run target checks** + +Run: + +```bash +cargo check-fastly +cargo check-axum +cargo check-cloudflare +``` + +Expected: PASS. + +- [ ] **Step 3: Run target tests** + +Run: + +```bash +cargo test-fastly +cargo test-axum +cargo test-cloudflare +``` + +Expected: PASS. + +- [ ] **Step 4: Run Spin if touched by shared trait changes** + +Run: + +```bash +cargo test-spin +``` + +Expected: PASS. + +- [ ] **Step 5: Run clippy gates** + +Run: + +```bash +cargo clippy-fastly +cargo clippy-axum +cargo clippy-cloudflare +cargo clippy-cloudflare-wasm +cargo clippy-spin-native +cargo clippy-spin-wasm +``` + +Expected: PASS. + +- [ ] **Step 6: Run parity suite if available locally** + +Run: + +```bash +cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity +``` + +Expected: PASS. + +- [ ] **Step 7: Optional local TTFB smoke** + +Run Fastly local serve against an artificially slow publisher origin: + +- Origin sends headers and first HTML chunk immediately. +- Origin delays later body chunks. +- Verify browser/curl receives response headers and first chunk before full origin drain. +- Verify bids still inject before `` when auction completes. + +Expected: TTFB tracks origin first byte, not full origin transfer or auction collection. + +## Review Checklist + +- [ ] No `block_on` inside stream body processing or `Read::read` equivalents. +- [ ] `Body::Stream` never falls through `into_bytes().unwrap_or_default()`. +- [ ] Fastly publisher origin fetch sets `with_stream_response()` only through capability gate. +- [ ] Cloudflare, Spin, and Axum do not start receiving stream-response requests. +- [ ] `HEAD`, `204`, and `304` do not drive or attach response bodies. +- [ ] `Content-Length` is absent on processed streaming responses. +- [ ] Existing buffered adapters still work through `buffer_publisher_response_async()`. +- [ ] Auction telemetry handles completed and abandoned stream cases. +- [ ] Mid-stream errors do not panic; they log and truncate consistently with current streaming behavior. +- [ ] Comments no longer describe publisher streaming as interim/in-memory cursor based. + +## PR Description Skeleton + +```markdown +## Summary + +- convert publisher response processing to async chunk-pull over `Body::Stream` +- enable Fastly publisher origin streaming behind a platform capability gate +- stream Fastly publisher responses to clients instead of buffering and awaiting auction before send + +## Scope + +Fastly production path for issue #849. Cloudflare, Spin, and Axum streaming remain follow-up work. + +## Tests + +- [ ] cargo fmt --all -- --check +- [ ] cargo check-fastly +- [ ] cargo check-axum +- [ ] cargo check-cloudflare +- [ ] cargo test-fastly +- [ ] cargo test-axum +- [ ] cargo test-cloudflare +- [ ] cargo test-spin +- [ ] cargo clippy-fastly +- [ ] cargo clippy-axum +- [ ] cargo clippy-cloudflare +- [ ] cargo clippy-cloudflare-wasm +- [ ] cargo clippy-spin-native +- [ ] cargo clippy-spin-wasm +``` + +## Known Follow-Ups + +- Cloudflare origin streaming once Worker `ReadableStream` is wrapped into `Body::Stream` and response header/set-cookie behavior is verified. +- Spin streaming after upstream EdgeZero Spin response conversion supports incremental body writes. +- Axum streaming only if the dev server needs it enough to justify a `Send` bridge. +- Issue #850 parser-context `` detection. From affb46c9781239ae78695ad5157826ea232e143e Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 8 Jul 2026 22:39:26 +0530 Subject: [PATCH 03/44] Harden the streaming publisher pipeline after review Address the deep-review findings on the streaming cutover: - Cap cumulative decoded bytes in BodyStreamDecoder against publisher.max_buffered_body_bytes: the chunk source only bounds raw compressed bytes, so a decompression bomb could expand ~1000x past it and push unbounded decoded volume through the rewrite pipeline - Detect truncated deflate streams: write::ZlibDecoder::try_finish accepts truncated input silently, so the deflate arm now drives flate2::Decompress directly and requires Status::StreamEnd at finalization; trailing bytes after the end marker stay ignored. Add truncated-gzip and truncated-deflate regression tests - Make BodyChunkSource::next_chunk cancellation-safe by polling the body in place instead of moving it out across an await; a cancelled pull no longer turns into a silent EOF - Log dispatched auctions dropped uncollected (client disconnect mid-stream or never-polled body) via DispatchedAuctionGuard; the guard is created before the lazy stream so unpolled drops log too - Share the pull+decode step between the lazy publisher body and the write-sink drivers (hold_step_next_chunk / passthrough_step), removing the unreachable!() error plumbing and the triplicated processor selection; document body_close_hold_loop_stream as groundwork for the buffered adapters' streaming cutover - Pass identity-encoded chunks through zero-copy and finish encoders by consuming them instead of allocating a throwaway replacement - Add a Fastly dispatch test asserting the publisher fallback returns Body::Stream without a stale Content-Length, plus a comment on why the publisher fetch gates streaming on capability while the asset path does not Behavior note: gzip bodies with trailing garbage after the trailer now error mid-stream; the old read-path decoder ignored them. --- .../trusted-server-adapter-fastly/src/app.rs | 34 + crates/trusted-server-core/src/publisher.rs | 639 ++++++++++++------ crates/trusted-server-core/src/settings.rs | 13 +- .../src/streaming_processor.rs | 297 ++++++-- 4 files changed, 685 insertions(+), 298 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index d5e37f91..ae9a2749 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -2150,6 +2150,10 @@ mod tests { #[async_trait::async_trait(?Send)] impl PlatformHttpClient for StreamingHttpClient { + fn supports_streaming_responses(&self) -> bool { + true + } + async fn send( &self, request: PlatformHttpRequest, @@ -2260,6 +2264,36 @@ mod tests { ); } + #[test] + fn dispatch_fallback_streams_publisher_body_without_buffering() { + // Regression guard for the publisher streaming cutover (#849): a + // successful publisher origin fetch must hand `edgezero_main` a lazy + // streaming body (`Body::Stream`) so headers commit at origin first + // byte, rather than draining the processed page into a buffered + // `Body::Once`. Core tests cover the rewrite pipeline itself; this + // guards the adapter wiring that could silently re-buffer. + let settings = test_settings(); + let state = build_state_from_settings(settings).expect("should build state"); + let services = streaming_runtime_services(); + let req = empty_request(Method::GET, "/article"); + + let response = block_on(super::dispatch_fallback(&state, &services, req)); + + assert_eq!( + response.status(), + StatusCode::OK, + "publisher proxy should succeed against the streaming origin stub" + ); + assert!( + matches!(response.body(), Body::Stream(_)), + "EdgeZero publisher dispatch must attach the lazy streaming body, not buffer it" + ); + assert!( + !response.headers().contains_key(header::CONTENT_LENGTH), + "processed streaming publisher responses must not carry a stale Content-Length" + ); + } + #[test] fn dispatch_runs_request_filter_and_threads_response_effects() { // Regression guard for the EdgeZero request-filter bypass: the publisher diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index c10ac0b3..fe6467e1 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -104,40 +104,40 @@ impl BodyChunkSource { } async fn next_chunk(&mut self) -> Result, Report> { - let Some(body) = self.body.take() else { - return Ok(None); - }; - - let chunk = match body { - EdgeBody::Once(bytes) => { - if self.once_offset >= bytes.len() { - None + // The body is polled in place (never moved out across an await) so a + // cancelled `next_chunk` future leaves the source resumable instead of + // silently reporting end-of-stream on the next call. + let pulled = match &mut self.body { + None => Ok(None), + Some(EdgeBody::Once(bytes)) => { + let end = (self.once_offset + self.chunk_size).min(bytes.len()); + if self.once_offset >= end { + Ok(None) } else { - let end = (self.once_offset + self.chunk_size).min(bytes.len()); let chunk = bytes.slice(self.once_offset..end); self.once_offset = end; - if self.once_offset < bytes.len() { - self.body = Some(EdgeBody::Once(bytes)); - } - Some(chunk) + Ok(Some(chunk)) } } - EdgeBody::Stream(mut stream) => match stream.next().await { - Some(Ok(chunk)) => { - self.body = Some(EdgeBody::Stream(stream)); - Some(chunk) - } - Some(Err(err)) => { - return Err(Report::new(TrustedServerError::Proxy { - message: format!("Failed to read publisher origin body stream: {err}"), - })); - } - None => None, + Some(EdgeBody::Stream(stream)) => match stream.next().await { + Some(Ok(chunk)) => Ok(Some(chunk)), + Some(Err(err)) => Err(Report::new(TrustedServerError::Proxy { + message: format!("Failed to read publisher origin body stream: {err}"), + })), + None => Ok(None), }, }; - let Some(chunk) = chunk else { - return Ok(None); + let chunk = match pulled { + Ok(Some(chunk)) => chunk, + Ok(None) => { + self.body = None; + return Ok(None); + } + Err(err) => { + self.body = None; + return Err(err); + } }; self.bytes_seen = self.bytes_seen.checked_add(chunk.len()).ok_or_else(|| { @@ -174,19 +174,17 @@ fn process_and_encode_chunk( if processed.is_empty() { return Ok(None); } - let encoded = encoder.encode_chunk(&processed)?; + let encoded = encoder.encode_chunk(processed)?; if encoded.is_empty() { return Ok(None); } Ok(Some(bytes::Bytes::from(encoded))) } +// By-value signature so `map_err(publisher_stream_error)` works directly. +#[allow(clippy::needless_pass_by_value)] fn publisher_stream_error(err: Report) -> std::io::Error { - let message = format!("{err:?}"); - // Consume the report so clippy's needless_pass_by_value accepts the - // by-value signature that `map_err(publisher_stream_error)` requires. - drop(err); - std::io::Error::other(message) + std::io::Error::other(format!("{err:?}")) } fn not_found_response() -> Response { @@ -473,65 +471,58 @@ fn process_response_streaming( async fn process_response_streaming_async( body: EdgeBody, output: &mut W, - params: &ProcessResponseParams<'_>, - max_raw_body_bytes: usize, + params: &OwnedProcessResponseParams, + settings: &Settings, + integration_registry: &IntegrationRegistry, ) -> Result<(), Report> { - let is_html = is_html_content_type(params.content_type); - let is_rsc_flight = - content_type_contains_ascii_case_insensitive(params.content_type, "text/x-component"); log::debug!( - "process_response_streaming_async: content_type={}, content_encoding={}, is_html={}, is_rsc_flight={}", + "process_response_streaming_async: content_type={}, content_encoding={}", params.content_type, - params.content_encoding, - is_html, - is_rsc_flight + params.content_encoding ); - let compression = Compression::from_content_encoding(params.content_encoding); + let compression = Compression::from_content_encoding(¶ms.content_encoding); + let mut processor = PublisherBodyProcessor::new(params, settings, integration_registry)?; + process_body_chunks_async( + body, + output, + &mut processor, + compression, + settings.publisher.max_buffered_body_bytes, + ) + .await +} - if is_html { - let mut processor = create_html_stream_processor( - params.origin_host, - params.request_host, - params.request_scheme, - params.settings, - params.integration_registry, - params.ad_slots_script.map(str::to_string), - params.ad_bids_state.clone(), - )?; - process_body_chunks_async( - body, - output, - &mut processor, - compression, - max_raw_body_bytes, - ) - .await - } else if is_rsc_flight { - let mut processor = RscFlightUrlRewriter::new( - params.origin_host, - params.origin_url, - params.request_host, - params.request_scheme, - ); - process_body_chunks_async( - body, - output, - &mut processor, - compression, - max_raw_body_bytes, - ) - .await - } else { - let mut replacer = create_url_replacer( - params.origin_host, - params.origin_url, - params.request_host, - params.request_scheme, - ); - process_body_chunks_async(body, output, &mut replacer, compression, max_raw_body_bytes) - .await +/// Pull, decode, process, and encode the next chunk of a no-hold pipeline. +/// +/// Returns `Ok(None)` when the source is exhausted; the caller must then emit +/// [`passthrough_finish_segments`]. Shared by the write-sink driver +/// ([`process_body_chunks_async`]) and the lazy publisher body stream so the +/// two no-hold paths cannot drift apart. +async fn passthrough_step( + source: &mut BodyChunkSource, + decoder: &mut BodyStreamDecoder, + encoder: &mut BodyStreamEncoder, + processor: &mut P, +) -> Result>, Report> { + let Some(raw_chunk) = source.next_chunk().await? else { + return Ok(None); + }; + let decoded = decoder.decode_chunk(raw_chunk)?; + if decoded.is_empty() { + return Ok(Some(Vec::new())); } + let mut segments = Vec::new(); + if let Some(encoded) = process_and_encode_chunk( + processor, + encoder, + &decoded, + false, + "Failed to process chunk", + )? { + segments.push(encoded); + } + Ok(Some(segments)) } async fn process_body_chunks_async( @@ -539,25 +530,16 @@ async fn process_body_chunks_async( writer: &mut W, processor: &mut P, compression: Compression, - max_raw_body_bytes: usize, + max_body_bytes: usize, ) -> Result<(), Report> { - let mut decoder = BodyStreamDecoder::new(compression); + let mut decoder = BodyStreamDecoder::new(compression, max_body_bytes); let mut encoder = BodyStreamEncoder::new(compression); - let mut source = - BodyChunkSource::new(body, STREAM_CHUNK_SIZE).with_max_bytes(max_raw_body_bytes); + let mut source = BodyChunkSource::new(body, STREAM_CHUNK_SIZE).with_max_bytes(max_body_bytes); - while let Some(chunk) = source.next_chunk().await? { - let decoded = decoder.decode_chunk(&chunk)?; - if decoded.is_empty() { - continue; - } - if let Some(encoded) = process_and_encode_chunk( - processor, - &mut encoder, - &decoded, - false, - "Failed to process chunk", - )? { + while let Some(segments) = + passthrough_step(&mut source, &mut decoder, &mut encoder, processor).await? + { + for encoded in segments { write_encoded_segment(writer, &encoded)?; } } @@ -621,18 +603,51 @@ fn passthrough_finish_segments( Ok(segments) } +/// Owns a [`DispatchedAuction`] and logs if it is dropped uncollected. +/// +/// The lazy publisher body stream can be dropped at any await point — a +/// client disconnect aborts the transfer mid-body, or the response may never +/// be polled at all. Async telemetry cannot run in `Drop`, so the loss is +/// surfaced in logs; the abandoned-auction telemetry event is only emitted on +/// error paths that can still await (see [`abandon_hold_auction`]). +struct DispatchedAuctionGuard { + dispatched: Option, +} + +impl DispatchedAuctionGuard { + fn new(dispatched: DispatchedAuction) -> Self { + Self { + dispatched: Some(dispatched), + } + } + + fn take(&mut self) -> Option { + self.dispatched.take() + } +} + +impl Drop for DispatchedAuctionGuard { + fn drop(&mut self) { + if self.dispatched.is_some() { + log::warn!( + "Dispatched server-side auction dropped without collection; SSP bid responses discarded (publisher body stream aborted or never polled)" + ); + } + } +} + /// Mutable auction-hold state threaded through the streaming hold pipeline. struct AuctionHoldState { hold: Option, - dispatched: Option, + dispatched: DispatchedAuctionGuard, telemetry: AuctionTelemetryCarry, } impl AuctionHoldState { - fn new(dispatched: DispatchedAuction, telemetry: AuctionTelemetryCarry) -> Self { + fn new(dispatched: DispatchedAuctionGuard, telemetry: AuctionTelemetryCarry) -> Self { Self { hold: Some(BodyCloseHoldBuffer::new()), - dispatched: Some(dispatched), + dispatched, telemetry, } } @@ -736,6 +751,45 @@ async fn hold_step_decoded_chunk( Ok(segments) } +/// Pull and decode the next chunk of the close-body hold pipeline, feeding it +/// through [`hold_step_decoded_chunk`]. +/// +/// Returns `Ok(None)` when the source is exhausted; the caller must then emit +/// [`hold_finish_segments`]. On read or decode failure the pending auction is +/// abandoned before the error is returned. Shared by the write-sink driver +/// ([`body_close_hold_loop_stream`]) and the lazy publisher body stream so +/// the two hold paths cannot drift apart. +async fn hold_step_next_chunk( + source: &mut BodyChunkSource, + decoder: &mut BodyStreamDecoder, + encoder: &mut BodyStreamEncoder, + processor: &mut P, + state: &mut AuctionHoldState, + collect_refs: &AuctionHoldCollectRefs<'_>, +) -> Result>, Report> { + let raw_chunk = match source.next_chunk().await { + Ok(Some(chunk)) => chunk, + Ok(None) => return Ok(None), + Err(err) => { + abandon_hold_auction(state, collect_refs.services, "stream_read_error").await; + return Err(err); + } + }; + let decoded = match decoder.decode_chunk(raw_chunk) { + Ok(decoded) => decoded, + Err(err) => { + abandon_hold_auction(state, collect_refs.services, "stream_decode_error").await; + return Err(err); + } + }; + if decoded.is_empty() { + return Ok(Some(Vec::new())); + } + hold_step_decoded_chunk(processor, encoder, &decoded, state, collect_refs) + .await + .map(Some) +} + /// Finalize the close-body hold pipeline at end of the origin stream. /// /// Drains the decoder tail through the hold (or straight through when the @@ -843,7 +897,11 @@ fn create_html_stream_processor( /// Result of publisher request handling, indicating whether the response body /// should be streamed or has already been buffered. pub enum PublisherResponse { - /// Response is fully buffered and ready to send via `send_to_client()`. + /// Response returned unmodified, ready to send via `send_to_client()`. + /// + /// On streaming adapters the unmodified body may still be a live + /// [`EdgeBody::Stream`] (the origin fetch requested streaming before the + /// response was classified); it passes through to the client untouched. Buffered(Response), /// Response headers are ready for a streaming response. Covers processable /// content on any status (2xx or non-2xx — e.g., branded 404/500 HTML and @@ -1085,25 +1143,31 @@ pub fn publisher_response_into_streaming_response( let mut params = *params; let mut processor = PublisherBodyProcessor::new(¶ms, &settings, integration_registry)?; + // The guard is created before the lazy stream so an auction whose + // response body is dropped unpolled still logs the loss. + let dispatched_auction = params.dispatched_auction.take().map(|dispatched| { + let telemetry = AuctionTelemetryCarry { + observation: params.auction_observation.take(), + auction_request: params.auction_request.take(), + }; + (DispatchedAuctionGuard::new(dispatched), telemetry) + }); let stream = async_stream::try_stream! { let compression = Compression::from_content_encoding(¶ms.content_encoding); - let mut decoder = BodyStreamDecoder::new(compression); + let max_body_bytes = settings.publisher.max_buffered_body_bytes; + let mut decoder = BodyStreamDecoder::new(compression, max_body_bytes); let mut encoder = BodyStreamEncoder::new(compression); let mut source = BodyChunkSource::new(body, STREAM_CHUNK_SIZE) - .with_max_bytes(settings.publisher.max_buffered_body_bytes); + .with_max_bytes(max_body_bytes); // HTML rides the close-body hold so bids land before ``; // non-HTML has no injection point, so its auction is collected // before any byte streams (matching the buffered finalizer). let mut hold_auction = None; - if let Some(dispatched) = params.dispatched_auction.take() { - let telemetry = AuctionTelemetryCarry { - observation: params.auction_observation.take(), - auction_request: params.auction_request.take(), - }; + if let Some((mut guard, telemetry)) = dispatched_auction { if is_html_content_type(¶ms.content_type) { - hold_auction = Some((dispatched, telemetry)); - } else { + hold_auction = Some((guard, telemetry)); + } else if let Some(dispatched) = guard.take() { collect_non_html_auction( dispatched, telemetry, @@ -1116,8 +1180,8 @@ pub fn publisher_response_into_streaming_response( } } - if let Some((dispatched, telemetry)) = hold_auction { - let mut state = AuctionHoldState::new(dispatched, telemetry); + if let Some((guard, telemetry)) = hold_auction { + let mut state = AuctionHoldState::new(guard, telemetry); let collect_refs = AuctionHoldCollectRefs { price_granularity: params.price_granularity, ad_bids_state: ¶ms.ad_bids_state, @@ -1126,39 +1190,18 @@ pub fn publisher_response_into_streaming_response( settings: &settings, }; - loop { - let raw_chunk = match source.next_chunk().await { - Ok(Some(chunk)) => chunk, - Ok(None) => break, - Err(err) => { - abandon_hold_auction(&mut state, &services, "stream_read_error") - .await; - Err(publisher_stream_error(err))?; - unreachable!("error should have returned"); - } - }; - let decoded = match decoder.decode_chunk(&raw_chunk) { - Ok(decoded) => decoded, - Err(err) => { - abandon_hold_auction(&mut state, &services, "stream_decode_error") - .await; - Err(publisher_stream_error(err))?; - unreachable!("error should have returned"); - } - }; - if decoded.is_empty() { - continue; - } - for encoded in hold_step_decoded_chunk( - &mut processor, - &mut encoder, - &decoded, - &mut state, - &collect_refs, - ) - .await - .map_err(publisher_stream_error)? - { + while let Some(segments) = hold_step_next_chunk( + &mut source, + &mut decoder, + &mut encoder, + &mut processor, + &mut state, + &collect_refs, + ) + .await + .map_err(publisher_stream_error)? + { + for encoded in segments { yield encoded; } } @@ -1176,24 +1219,16 @@ pub fn publisher_response_into_streaming_response( yield encoded; } } else { - while let Some(raw_chunk) = - source.next_chunk().await.map_err(publisher_stream_error)? + while let Some(segments) = passthrough_step( + &mut source, + &mut decoder, + &mut encoder, + &mut processor, + ) + .await + .map_err(publisher_stream_error)? { - let decoded = decoder - .decode_chunk(&raw_chunk) - .map_err(publisher_stream_error)?; - if decoded.is_empty() { - continue; - } - if let Some(encoded) = process_and_encode_chunk( - &mut processor, - &mut encoder, - &decoded, - false, - "Failed to process chunk", - ) - .map_err(publisher_stream_error)? - { + for encoded in segments { yield encoded; } } @@ -1334,23 +1369,12 @@ pub async fn stream_publisher_body_async( ) -> Result<(), Report> { let Some(dispatched) = params.dispatched_auction.take() else { if body.is_stream() { - let borrowed = ProcessResponseParams { - content_encoding: ¶ms.content_encoding, - origin_host: ¶ms.origin_host, - origin_url: ¶ms.origin_url, - request_host: ¶ms.request_host, - request_scheme: ¶ms.request_scheme, - settings, - content_type: ¶ms.content_type, - integration_registry, - ad_slots_script: params.ad_slots_script.as_deref(), - ad_bids_state: ¶ms.ad_bids_state, - }; return process_response_streaming_async( body, output, - &borrowed, - settings.publisher.max_buffered_body_bytes, + params, + settings, + integration_registry, ) .await; } @@ -1378,23 +1402,12 @@ pub async fn stream_publisher_body_async( ) .await; if body.is_stream() { - let borrowed = ProcessResponseParams { - content_encoding: ¶ms.content_encoding, - origin_host: ¶ms.origin_host, - origin_url: ¶ms.origin_url, - request_host: ¶ms.request_host, - request_scheme: ¶ms.request_scheme, - settings, - content_type: ¶ms.content_type, - integration_registry, - ad_slots_script: params.ad_slots_script.as_deref(), - ad_bids_state: ¶ms.ad_bids_state, - }; return process_response_streaming_async( body, output, - &borrowed, - settings.publisher.max_buffered_body_bytes, + params, + settings, + integration_registry, ) .await; } @@ -1639,14 +1652,14 @@ async fn stream_html_with_auction_hold( ctx: AuctionCollectCtx<'_>, ) -> Result<(), Report> { if body.is_stream() { - let max_raw_body_bytes = ctx.settings.publisher.max_buffered_body_bytes; + let max_body_bytes = ctx.settings.publisher.max_buffered_body_bytes; return body_close_hold_loop_stream( body, output, processor, compression, ctx, - max_raw_body_bytes, + max_body_bytes, ) .await; } @@ -1690,16 +1703,22 @@ async fn stream_html_with_auction_hold( /// Async-pull variant of [`body_close_hold_loop`] for live origin streams. /// -/// Shares [`hold_step_decoded_chunk`] and [`hold_finish_segments`] with the +/// Shares [`hold_step_next_chunk`] and [`hold_finish_segments`] with the /// lazy streaming body built by [`publisher_response_into_streaming_response`], /// so the two async hold paths cannot drift apart. +/// +/// No production caller reaches this today: it is only entered through +/// [`buffer_publisher_response_async`], and the buffered adapters (Axum, +/// Cloudflare, Spin) never produce `Body::Stream` because the publisher fetch +/// is gated on `supports_streaming_responses()`. It is groundwork for those +/// adapters' streaming cutover; Fastly uses the lazy stream instead. async fn body_close_hold_loop_stream( body: EdgeBody, writer: &mut W, processor: &mut P, compression: Compression, ctx: AuctionCollectCtx<'_>, - max_raw_body_bytes: usize, + max_body_bytes: usize, ) -> Result<(), Report> { let AuctionCollectCtx { dispatched, @@ -1710,11 +1729,10 @@ async fn body_close_hold_loop_stream( services, settings, } = ctx; - let mut decoder = BodyStreamDecoder::new(compression); + let mut decoder = BodyStreamDecoder::new(compression, max_body_bytes); let mut encoder = BodyStreamEncoder::new(compression); - let mut source = - BodyChunkSource::new(body, STREAM_CHUNK_SIZE).with_max_bytes(max_raw_body_bytes); - let mut state = AuctionHoldState::new(dispatched, telemetry); + let mut source = BodyChunkSource::new(body, STREAM_CHUNK_SIZE).with_max_bytes(max_body_bytes); + let mut state = AuctionHoldState::new(DispatchedAuctionGuard::new(dispatched), telemetry); let collect_refs = AuctionHoldCollectRefs { price_granularity, ad_bids_state, @@ -1723,29 +1741,17 @@ async fn body_close_hold_loop_stream( settings, }; - loop { - let raw_chunk = match source.next_chunk().await { - Ok(Some(chunk)) => chunk, - Ok(None) => break, - Err(err) => { - abandon_hold_auction(&mut state, services, "stream_read_error").await; - return Err(err); - } - }; - let decoded = match decoder.decode_chunk(&raw_chunk) { - Ok(decoded) => decoded, - Err(err) => { - abandon_hold_auction(&mut state, services, "stream_decode_error").await; - return Err(err); - } - }; - if decoded.is_empty() { - continue; - } - for encoded in - hold_step_decoded_chunk(processor, &mut encoder, &decoded, &mut state, &collect_refs) - .await? - { + while let Some(segments) = hold_step_next_chunk( + &mut source, + &mut decoder, + &mut encoder, + processor, + &mut state, + &collect_refs, + ) + .await? + { + for encoded in segments { write_encoded_segment(writer, &encoded)?; } } @@ -2439,6 +2445,11 @@ pub async fn handle_publisher_request( // SSP requests are already racing through the platform HTTP client, so // origin TTFB tracks origin latency rather than the auction timeout. + // + // Streaming is gated on the capability (unlike the asset-proxy path, which + // sets the flag unconditionally and tolerates buffered fallback): adapters + // without streaming support may reject the flag outright rather than + // silently buffering, which would fail every publisher fetch. let mut platform_request = PlatformHttpRequest::new(req, backend_name); if services.http_client().supports_streaming_responses() { platform_request = platform_request.with_stream_response(); @@ -3268,6 +3279,7 @@ pub async fn handle_page_bids( #[cfg(test)] mod tests { + use std::future::Future as _; use std::io::{self, Read as _, Write as _}; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -4952,6 +4964,185 @@ mod tests { }); } + fn non_html_stream_params(content_encoding: &str) -> OwnedProcessResponseParams { + OwnedProcessResponseParams { + content_encoding: content_encoding.to_string(), + origin_host: "origin.example.com".to_string(), + origin_url: "https://origin.example.com".to_string(), + request_host: "proxy.example.com".to_string(), + request_scheme: "https".to_string(), + content_type: "text/css".to_string(), + ad_slots_script: None, + ad_bids_state: Arc::new(Mutex::new(None)), + auction_observation: None, + auction_request: None, + dispatched_auction: None, + price_granularity: crate::price_bucket::PriceGranularity::default(), + } + } + + #[test] + fn stream_publisher_body_async_rejects_truncated_gzip_stream() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let services = noop_services(); + let mut params = non_html_stream_params("gzip"); + let compressed = + gzip_encode(b"body{background:url('https://origin.example.com/asset.png')}"); + let truncated = &compressed[..compressed.len() - 3]; + let body = + EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::copy_from_slice( + truncated, + )])); + let mut output = Vec::new(); + + let err = stream_publisher_body_async( + body, + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect_err("truncated gzip stream must fail instead of truncating silently"); + + assert!( + format!("{err:?}").contains("gzip"), + "should surface the gzip finalization failure: {err:?}" + ); + }); + } + + #[test] + fn stream_publisher_body_async_rejects_truncated_deflate_stream() { + futures::executor::block_on(async { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let services = noop_services(); + let mut params = non_html_stream_params("deflate"); + let compressed = + deflate_encode(b"body{background:url('https://origin.example.com/asset.png')}"); + // Cut into the deflate data itself, not just the adler32 trailer. + let truncated = &compressed[..compressed.len() / 2]; + let body = + EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::copy_from_slice( + truncated, + )])); + let mut output = Vec::new(); + + let err = stream_publisher_body_async( + body, + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect_err("truncated deflate stream must fail instead of truncating silently"); + + assert!( + format!("{err:?}").contains("deflate"), + "should surface the deflate finalization failure: {err:?}" + ); + }); + } + + #[test] + fn stream_publisher_body_async_enforces_decoded_byte_cap() { + futures::executor::block_on(async { + let mut settings = create_test_settings(); + // Raw compressed input stays tiny (well under the cap); only the + // decoded expansion exceeds it — the decompression-bomb case the + // raw-byte cap alone cannot catch. + settings.publisher.max_buffered_body_bytes = 1024; + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let services = noop_services(); + let mut params = non_html_stream_params("gzip"); + let compressed = gzip_encode(&vec![b'a'; 64 * 1024]); + assert!( + compressed.len() < 1024, + "test precondition: compressed input must stay under the raw cap" + ); + let body = + EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::from(compressed)])); + let mut output = Vec::new(); + + let err = stream_publisher_body_async( + body, + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect_err("decoded expansion past the cap must fail"); + + assert!( + format!("{err:?}").contains("decoded size exceeded"), + "should report the cumulative decoded cap: {err:?}" + ); + }); + } + + #[test] + fn body_chunk_source_resumes_after_cancelled_poll() { + futures::executor::block_on(async { + let mut pending_once = true; + let mut yielded = false; + let stream = futures::stream::poll_fn(move |cx| { + if pending_once { + pending_once = false; + cx.waker().wake_by_ref(); + return std::task::Poll::Pending; + } + if yielded { + return std::task::Poll::Ready(None); + } + yielded = true; + std::task::Poll::Ready(Some(Ok::<_, io::Error>(bytes::Bytes::from_static( + b"chunk", + )))) + }); + let body = EdgeBody::from_stream(stream); + let mut source = BodyChunkSource::new(body, STREAM_CHUNK_SIZE); + + { + // Poll the pull future once (Pending), then drop it — + // simulating a cancelled await (select/timeout wrapper). + let mut pull = Box::pin(source.next_chunk()); + let waker = futures::task::noop_waker(); + let mut context = std::task::Context::from_waker(&waker); + assert!( + pull.as_mut().poll(&mut context).is_pending(), + "first poll should be pending" + ); + } + + let chunk = source + .next_chunk() + .await + .expect("should read after cancelled poll"); + assert_eq!( + chunk.as_deref(), + Some(&b"chunk"[..]), + "cancelled pull must not lose the origin stream" + ); + }); + } + #[test] fn stream_publisher_body_async_processes_stream_with_auction_hold() { futures::executor::block_on(async { diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 008b4a2d..0a6178a0 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -51,11 +51,14 @@ pub struct Publisher { /// Defaults to 16 MiB — a conservative cap that prevents Wasm-heap OOM. /// /// Fastly origin bodies are preserved as streams on the publisher path, so - /// this setting is also the cumulative raw-byte cap while the streaming - /// processor decodes and rewrites chunks. Buffered adapters keep using it - /// as the post-rewrite output buffer cap. On the streaming path headers - /// are already committed when the cap trips, so the response is truncated - /// mid-body (with the error logged) rather than replaced with a 5xx. + /// this setting also caps the streaming pipeline twice over: cumulative + /// raw (still compressed) bytes pulled from origin, and cumulative decoded + /// bytes emitted by the decompressor — the latter so a decompression bomb + /// cannot push an unbounded decoded volume through the rewrite pipeline. + /// Buffered adapters keep using it as the post-rewrite output buffer cap. + /// On the streaming path headers are already committed when either cap + /// trips, so the response is truncated mid-body (with the error logged) + /// rather than replaced with a 5xx. /// /// Must be at least 1: a zero-byte cap fails every non-empty buffered /// publisher response at request time, so it is rejected at config diff --git a/crates/trusted-server-core/src/streaming_processor.rs b/crates/trusted-server-core/src/streaming_processor.rs index ef1a0bc5..963c69da 100644 --- a/crates/trusted-server-core/src/streaming_processor.rs +++ b/crates/trusted-server-core/src/streaming_processor.rs @@ -359,88 +359,177 @@ pub(crate) const STREAM_CHUNK_SIZE: usize = 8192; /// Compressed bytes go in via [`Self::decode_chunk`]; decoded bytes drain /// out of the internal buffer after every push. Write-based decoders are /// used because the async publisher path cannot wrap a blocking `Read`. -pub(crate) enum BodyStreamDecoder { +/// +/// Decoded output is capped cumulatively: the chunk source only bounds raw +/// (still compressed) bytes, and a decompression bomb can expand ~1000x past +/// that, so the decoder enforces its own ceiling on the total bytes it emits. +/// +/// Every codec validates end-of-stream at [`Self::finish`] so a truncated +/// origin body errors instead of silently truncating the page: gzip via its +/// trailer checksum, brotli via `close()`, and deflate via an explicit +/// [`flate2::Status::StreamEnd`] check (`write::ZlibDecoder` accepts +/// truncated input silently, so the deflate arm drives [`flate2::Decompress`] +/// directly). +pub(crate) struct BodyStreamDecoder { + codec: BodyStreamDecoderCodec, + decoded_bytes: usize, + max_decoded_bytes: usize, +} + +enum BodyStreamDecoderCodec { None, Gzip(flate2::write::GzDecoder>), - Deflate(flate2::write::ZlibDecoder>), + Deflate(DeflateStreamDecoder), Brotli(Box>>), } +/// Streaming zlib decoder that tracks whether the stream reached its end +/// marker, so truncated deflate bodies fail at finalization. +struct DeflateStreamDecoder { + decompress: flate2::Decompress, + stream_ended: bool, +} + +impl DeflateStreamDecoder { + fn new() -> Self { + Self { + decompress: flate2::Decompress::new(true), + stream_ended: false, + } + } + + fn decode(&mut self, chunk: &[u8]) -> Result, Report> { + let mut output = Vec::with_capacity(STREAM_CHUNK_SIZE); + let mut offset = 0usize; + // Trailing bytes after the zlib end marker are ignored, matching the + // read-based decoder used by the buffered pipeline. + while offset < chunk.len() && !self.stream_ended { + if output.len() == output.capacity() { + output.reserve(STREAM_CHUNK_SIZE); + } + let before_in = self.decompress.total_in(); + let before_out = self.decompress.total_out(); + let status = self + .decompress + .decompress_vec(&chunk[offset..], &mut output, flate2::FlushDecompress::None) + .change_context(TrustedServerError::Proxy { + message: "Failed to decode deflate publisher body chunk".to_string(), + })?; + let consumed = (self.decompress.total_in() - before_in) as usize; + let produced = (self.decompress.total_out() - before_out) as usize; + offset += consumed; + match status { + flate2::Status::StreamEnd => self.stream_ended = true, + flate2::Status::Ok | flate2::Status::BufError => { + if consumed == 0 && produced == 0 && output.len() < output.capacity() { + return Err(Report::new(TrustedServerError::Proxy { + message: "deflate publisher body decoder made no progress".to_string(), + })); + } + } + } + } + Ok(output) + } +} + impl BodyStreamDecoder { - pub(crate) fn new(compression: Compression) -> Self { - match compression { - Compression::None => Self::None, - Compression::Gzip => Self::Gzip(flate2::write::GzDecoder::new(Vec::new())), - Compression::Deflate => Self::Deflate(flate2::write::ZlibDecoder::new(Vec::new())), - Compression::Brotli => Self::Brotli(Box::new(brotli::DecompressorWriter::new( - Vec::new(), - STREAM_CHUNK_SIZE, - ))), + pub(crate) fn new(compression: Compression, max_decoded_bytes: usize) -> Self { + let codec = match compression { + Compression::None => BodyStreamDecoderCodec::None, + Compression::Gzip => { + BodyStreamDecoderCodec::Gzip(flate2::write::GzDecoder::new(Vec::new())) + } + Compression::Deflate => BodyStreamDecoderCodec::Deflate(DeflateStreamDecoder::new()), + Compression::Brotli => BodyStreamDecoderCodec::Brotli(Box::new( + brotli::DecompressorWriter::new(Vec::new(), STREAM_CHUNK_SIZE), + )), + }; + Self { + codec, + decoded_bytes: 0, + max_decoded_bytes, } } pub(crate) fn decode_chunk( &mut self, - chunk: &[u8], - ) -> Result, Report> { - match self { - Self::None => Ok(chunk.to_vec()), - Self::Gzip(decoder) => { + chunk: bytes::Bytes, + ) -> Result> { + let decoded = match &mut self.codec { + BodyStreamDecoderCodec::None => chunk, + BodyStreamDecoderCodec::Gzip(decoder) => { decoder - .write_all(chunk) + .write_all(&chunk) .change_context(TrustedServerError::Proxy { message: "Failed to decode gzip publisher body chunk".to_string(), })?; - Ok(std::mem::take(decoder.get_mut())) + bytes::Bytes::from(std::mem::take(decoder.get_mut())) } - Self::Deflate(decoder) => { + BodyStreamDecoderCodec::Deflate(decoder) => bytes::Bytes::from(decoder.decode(&chunk)?), + BodyStreamDecoderCodec::Brotli(decoder) => { decoder - .write_all(chunk) - .change_context(TrustedServerError::Proxy { - message: "Failed to decode deflate publisher body chunk".to_string(), - })?; - Ok(std::mem::take(decoder.get_mut())) - } - Self::Brotli(decoder) => { - decoder - .write_all(chunk) + .write_all(&chunk) .change_context(TrustedServerError::Proxy { message: "Failed to decode brotli publisher body chunk".to_string(), })?; - Ok(std::mem::take(decoder.get_mut())) + bytes::Bytes::from(std::mem::take(decoder.get_mut())) } - } + }; + self.track_decoded(decoded.len())?; + Ok(decoded) } pub(crate) fn finish(&mut self) -> Result, Report> { - match self { - Self::None => Ok(Vec::new()), - Self::Gzip(decoder) => { + let tail = match &mut self.codec { + BodyStreamDecoderCodec::None => Vec::new(), + BodyStreamDecoderCodec::Gzip(decoder) => { decoder .try_finish() .change_context(TrustedServerError::Proxy { message: "Failed to finalize gzip publisher body decoder".to_string(), })?; - Ok(std::mem::take(decoder.get_mut())) + std::mem::take(decoder.get_mut()) } - Self::Deflate(decoder) => { - decoder - .try_finish() - .change_context(TrustedServerError::Proxy { - message: "Failed to finalize deflate publisher body decoder".to_string(), - })?; - Ok(std::mem::take(decoder.get_mut())) + BodyStreamDecoderCodec::Deflate(decoder) => { + if !decoder.stream_ended { + return Err(Report::new(TrustedServerError::Proxy { + message: + "Failed to finalize deflate publisher body decoder: truncated stream" + .to_string(), + })); + } + Vec::new() } - Self::Brotli(decoder) => { + BodyStreamDecoderCodec::Brotli(decoder) => { // `close()` (not `flush()`): flush accepts a truncated brotli // stream silently, while close validates end-of-stream and // errors on incomplete input, matching the gzip/deflate arms. decoder.close().change_context(TrustedServerError::Proxy { message: "Failed to finalize brotli publisher body decoder".to_string(), })?; - Ok(std::mem::take(decoder.get_mut())) + std::mem::take(decoder.get_mut()) } + }; + self.track_decoded(tail.len())?; + Ok(tail) + } + + fn track_decoded(&mut self, len: usize) -> Result<(), Report> { + self.decoded_bytes = self.decoded_bytes.checked_add(len).ok_or_else(|| { + Report::new(TrustedServerError::Proxy { + message: "publisher origin body decoded byte count overflowed".to_string(), + }) + })?; + if self.decoded_bytes > self.max_decoded_bytes { + return Err(Report::new(TrustedServerError::Proxy { + message: format!( + "publisher origin body decoded size exceeded {}-byte streaming limit", + self.max_decoded_bytes + ), + })); } + Ok(()) } } @@ -482,13 +571,14 @@ impl BodyStreamEncoder { pub(crate) fn encode_chunk( &mut self, - chunk: &[u8], + chunk: Vec, ) -> Result, Report> { match self { - Self::None => Ok(chunk.to_vec()), + // Identity encoding passes the processed chunk through untouched. + Self::None => Ok(chunk), Self::Gzip(encoder) => { encoder - .write_all(chunk) + .write_all(&chunk) .change_context(TrustedServerError::Proxy { message: "Failed to encode gzip publisher body chunk".to_string(), })?; @@ -496,7 +586,7 @@ impl BodyStreamEncoder { } Self::Deflate(encoder) => { encoder - .write_all(chunk) + .write_all(&chunk) .change_context(TrustedServerError::Proxy { message: "Failed to encode deflate publisher body chunk".to_string(), })?; @@ -504,7 +594,7 @@ impl BodyStreamEncoder { } Self::Brotli(encoder) => { encoder - .write_all(chunk) + .write_all(&chunk) .change_context(TrustedServerError::Proxy { message: "Failed to encode brotli publisher body chunk".to_string(), })?; @@ -513,29 +603,18 @@ impl BodyStreamEncoder { } } + /// Emits the encoder trailer. Consumes the codec state (the encoder + /// becomes identity afterwards); terminal — call once at end of stream. pub(crate) fn finish(&mut self) -> Result, Report> { - match self { + match std::mem::replace(self, Self::None) { Self::None => Ok(Vec::new()), - Self::Gzip(encoder) => { - encoder - .try_finish() - .change_context(TrustedServerError::Proxy { - message: "Failed to finalize gzip publisher body encoder".to_string(), - })?; - Ok(std::mem::take(encoder.get_mut())) - } - Self::Deflate(encoder) => { - encoder - .try_finish() - .change_context(TrustedServerError::Proxy { - message: "Failed to finalize deflate publisher body encoder".to_string(), - })?; - Ok(std::mem::take(encoder.get_mut())) - } - Self::Brotli(encoder) => { - let encoder = std::mem::replace(encoder, Box::new(new_brotli_vec_encoder())); - Ok((*encoder).into_inner()) - } + Self::Gzip(encoder) => encoder.finish().change_context(TrustedServerError::Proxy { + message: "Failed to finalize gzip publisher body encoder".to_string(), + }), + Self::Deflate(encoder) => encoder.finish().change_context(TrustedServerError::Proxy { + message: "Failed to finalize deflate publisher body encoder".to_string(), + }), + Self::Brotli(encoder) => Ok((*encoder).into_inner()), } } } @@ -545,6 +624,86 @@ mod tests { use super::*; use crate::streaming_replacer::{Replacement, StreamingReplacer}; + #[test] + fn body_stream_decoder_enforces_cumulative_decoded_cap() { + let compressed = { + let mut encoder = + flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder + .write_all(&vec![b'a'; 64 * 1024]) + .expect("should write gzip test input"); + encoder.finish().expect("should finish gzip encoding") + }; + assert!( + compressed.len() < 1024, + "test precondition: compressed input must stay small" + ); + let mut decoder = BodyStreamDecoder::new(Compression::Gzip, 1024); + + let err = decoder + .decode_chunk(bytes::Bytes::from(compressed)) + .expect_err("decoded expansion past the cap must fail"); + + assert!( + format!("{err:?}").contains("decoded size exceeded"), + "should report the cumulative decoded cap: {err:?}" + ); + } + + #[test] + fn body_stream_decoder_rejects_truncated_deflate_stream() { + let compressed = { + let mut encoder = + flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + encoder + .write_all(b"deflate payload that spans more than one deflate block boundary") + .expect("should write deflate test input"); + encoder.finish().expect("should finish deflate encoding") + }; + let truncated = &compressed[..compressed.len() / 2]; + let mut decoder = BodyStreamDecoder::new(Compression::Deflate, usize::MAX); + decoder + .decode_chunk(bytes::Bytes::copy_from_slice(truncated)) + .expect("partial deflate input should decode incrementally"); + + let err = decoder + .finish() + .expect_err("truncated deflate stream must fail at finalization"); + + assert!( + format!("{err:?}").contains("truncated stream"), + "should report the missing deflate end marker: {err:?}" + ); + } + + #[test] + fn body_stream_decoder_ignores_deflate_trailing_bytes() { + let compressed = { + let mut encoder = + flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + encoder + .write_all(b"deflate payload") + .expect("should write deflate test input"); + encoder.finish().expect("should finish deflate encoding") + }; + let mut with_trailing = compressed; + with_trailing.extend_from_slice(b"junk"); + let mut decoder = BodyStreamDecoder::new(Compression::Deflate, usize::MAX); + + let decoded = decoder + .decode_chunk(bytes::Bytes::from(with_trailing)) + .expect("complete deflate stream should decode"); + decoder + .finish() + .expect("trailing bytes after the end marker should be ignored"); + + assert_eq!( + decoded.as_ref(), + b"deflate payload", + "should decode the payload and drop trailing junk" + ); + } + /// Verify that `lol_html` fragments text nodes when input chunks split /// mid-text-node. Script rewriters must be fragment-safe — they accumulate /// text fragments internally until `is_last_in_text_node` is true. From 2c64f3c613e4079ac741fab75762376c97573ca1 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 8 Jul 2026 23:02:08 +0530 Subject: [PATCH 04/44] Emit processor_init_error telemetry on streaming finalize failure The buffered finalizer abandons a dispatched auction with processor_init_error telemetry when HTML processor construction fails; the streaming finalizer dropped the in-flight SSP responses silently. Make publisher_response_into_streaming_response async and emit the same abandonment before returning the construction error. --- .../trusted-server-adapter-fastly/src/app.rs | 19 +++++----- crates/trusted-server-core/src/publisher.rs | 35 ++++++++++++++----- 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index ae9a2749..b6889707 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -795,14 +795,17 @@ async fn dispatch_fallback( ) .await { - Ok(pub_response) => publisher_response_into_streaming_response( - pub_response, - &method, - Arc::clone(&state.settings), - state.registry.as_ref(), - Arc::clone(&state.orchestrator), - publisher_services.clone(), - ), + Ok(pub_response) => { + publisher_response_into_streaming_response( + pub_response, + &method, + Arc::clone(&state.settings), + state.registry.as_ref(), + Arc::clone(&state.orchestrator), + publisher_services.clone(), + ) + .await + } Err(e) => Err(e), } } diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index fe6467e1..4d7e38cf 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -1101,9 +1101,10 @@ pub async fn buffer_publisher_response_async( /// /// # Errors /// -/// Returns an error if processor construction fails before the streaming body is -/// created. -pub fn publisher_response_into_streaming_response( +/// Returns an error if processor construction fails before the streaming body +/// is created; a dispatched auction is abandoned with `processor_init_error` +/// telemetry first, matching the buffered finalizer. +pub async fn publisher_response_into_streaming_response( publisher_response: PublisherResponse, method: &Method, settings: Arc, @@ -1142,7 +1143,25 @@ pub fn publisher_response_into_streaming_response( response.headers_mut().remove(header::CONTENT_LENGTH); let mut params = *params; let mut processor = - PublisherBodyProcessor::new(¶ms, &settings, integration_registry)?; + match PublisherBodyProcessor::new(¶ms, &settings, integration_registry) { + Ok(processor) => processor, + Err(err) => { + // Parity with the buffered finalizer: a processor + // construction failure abandons the dispatched auction + // with telemetry instead of dropping the in-flight SSP + // responses silently. + if let Some(dispatched) = params.dispatched_auction.take() { + emit_abandoned_auction( + &services, + params.auction_observation.take(), + dispatched, + "processor_init_error", + ) + .await; + } + return Err(err); + } + }; // The guard is created before the lazy stream so an auction whose // response body is dropped unpolled still logs the loss. let dispatched_auction = params.dispatched_auction.take().map(|dispatched| { @@ -5292,14 +5311,14 @@ mod tests { params: Box::new(params), }; - let response = publisher_response_into_streaming_response( + let response = futures::executor::block_on(publisher_response_into_streaming_response( publisher_response, &Method::GET, Arc::clone(&settings), registry.as_ref(), orchestrator, services, - ) + )) .expect("should build streaming response"); assert!( @@ -5458,14 +5477,14 @@ mod tests { params: Box::new(params), }; - let response = publisher_response_into_streaming_response( + let response = futures::executor::block_on(publisher_response_into_streaming_response( publisher_response, &Method::GET, Arc::clone(&settings), registry.as_ref(), orchestrator, services, - ) + )) .expect("should build streaming response"); let output = futures::executor::block_on( From b95813ccc9a5c6542eebd38dd9b63a2b3ba6200f Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 10 Jul 2026 09:29:00 -0500 Subject: [PATCH 05/44] Fix Prebid User ID diagnostics Publisher-specific bundles need diagnostics based on the modules they actually contain. Otherwise, missing identity integrations can be hidden by the default preset. Refresh the comparison when auctions begin so late publisher configuration is visible without repeating warnings. Resolves: #886 --- .../lib/build-prebid-external.mjs | 10 +++- .../prebid/_user_ids.generated.ts | 5 +- .../lib/src/integrations/prebid/index.ts | 42 ++++++++--------- .../integrations/prebid/user_id_modules.json | 12 +++++ .../lib/test/build-prebid-external.test.mjs | 12 ++++- .../test/integrations/prebid/index.test.ts | 47 ++++++++++++++++++- .../prebid/user_id_modules.test.ts | 22 ++++++++- 7 files changed, 119 insertions(+), 31 deletions(-) diff --git a/crates/trusted-server-js/lib/build-prebid-external.mjs b/crates/trusted-server-js/lib/build-prebid-external.mjs index e3bb5ab3..4e89723e 100644 --- a/crates/trusted-server-js/lib/build-prebid-external.mjs +++ b/crates/trusted-server-js/lib/build-prebid-external.mjs @@ -107,7 +107,7 @@ function validateUserIdImport(entry) { } } -function writeGeneratedModule(filePath, title, moduleNames, imports) { +function writeGeneratedModule(filePath, title, moduleNames, imports, exports = []) { const content = [ '// Auto-generated by build-prebid-external.mjs.', '//', @@ -115,12 +115,17 @@ function writeGeneratedModule(filePath, title, moduleNames, imports) { `// Modules: ${moduleNames.join(', ')}`, '', ...imports, + ...(exports.length > 0 ? ['', ...exports] : []), '', ].join('\n'); fs.writeFileSync(filePath, content); } +export function renderIncludedUserIdModulesExport(moduleNames) { + return `export const INCLUDED_PREBID_USER_ID_MODULES = ${JSON.stringify(moduleNames)};`; +} + function generateAdapterImports(adapterNames, adaptersFile) { const modulesDir = path.join(PREBID_PACKAGE_DIR, 'modules'); const imports = []; @@ -165,7 +170,8 @@ function generateUserIdImports(requestedModules, userIdsFile) { userIdsFile, '// External Prebid bundle User ID module imports.', moduleNames, - imports + imports, + [renderIncludedUserIdModulesExport(moduleNames)] ); return moduleNames; } diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/_user_ids.generated.ts b/crates/trusted-server-js/lib/src/integrations/prebid/_user_ids.generated.ts index e9fa55f7..e7c0112a 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/_user_ids.generated.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/_user_ids.generated.ts @@ -1,6 +1,7 @@ // Placeholder for generated Prebid User ID module imports. // // build-prebid-external.mjs aliases this module to a temporary file containing -// publisher-specific imports during external bundle generation. +// publisher-specific imports and the corresponding module-name list during +// external bundle generation. -export {}; +export const INCLUDED_PREBID_USER_ID_MODULES: string[] = []; diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index be839d8f..c6cf97cf 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -25,14 +25,14 @@ import 'prebid.js/modules/userId.js'; // shim leaves its bids untouched and the corresponding adapter handles them // natively in the browser. import './_adapters.generated'; -import './_user_ids.generated'; +import { INCLUDED_PREBID_USER_ID_MODULES } from './_user_ids.generated'; import { log } from '../../core/log'; import { buildAdRequest, parseAuctionResponse } from '../../core/auction'; import type { AuctionBid, AuctionEid } from '../../core/auction'; import type { AuctionSlot } from '../../core/types'; -import { DEFAULT_PREBID_USER_ID_MODULES, PREBID_USER_ID_MODULE_REGISTRY } from './user_id_modules'; +import { PREBID_USER_ID_MODULE_REGISTRY } from './user_id_modules'; const ADAPTER_CODE = 'trustedServer'; const BIDDER_PARAMS_KEY = 'bidderParams'; @@ -139,7 +139,7 @@ function recordUserIdModuleDiagnostics(): PrebidUserIdDiagnostics { const configuredUserIdNames = [...new Set(readConfiguredUserIdNames())].sort(); const coveredConfigNames = new Set( PREBID_USER_ID_MODULE_REGISTRY.filter((entry) => - DEFAULT_PREBID_USER_ID_MODULES.includes(entry.moduleName) + INCLUDED_PREBID_USER_ID_MODULES.includes(entry.moduleName) ).flatMap((entry) => entry.configNames) ); const missingConfiguredUserIdNames = configuredUserIdNames.filter( @@ -147,15 +147,20 @@ function recordUserIdModuleDiagnostics(): PrebidUserIdDiagnostics { ); const diagnostics: PrebidUserIdDiagnostics = { - includedModules: [...DEFAULT_PREBID_USER_ID_MODULES], + includedModules: [...INCLUDED_PREBID_USER_ID_MODULES], configuredUserIdNames, missingConfiguredUserIdNames, }; + const previouslyMissingConfiguredUserIdNames = new Set(); if (typeof window !== 'undefined') { const tsjsWindow = window as typeof window & { __tsjs_prebid_diagnostics?: { userIdModules?: PrebidUserIdDiagnostics }; }; + for (const name of tsjsWindow.__tsjs_prebid_diagnostics?.userIdModules + ?.missingConfiguredUserIdNames ?? []) { + previouslyMissingConfiguredUserIdNames.add(name); + } tsjsWindow.__tsjs_prebid_diagnostics = { ...(tsjsWindow.__tsjs_prebid_diagnostics ?? {}), userIdModules: diagnostics, @@ -163,9 +168,11 @@ function recordUserIdModuleDiagnostics(): PrebidUserIdDiagnostics { } for (const name of missingConfiguredUserIdNames) { - log.warn( - `[tsjs-prebid] configured User ID module "${name}" is not included in the external bundle` - ); + if (!previouslyMissingConfiguredUserIdNames.has(name)) { + log.warn( + `[tsjs-prebid] configured User ID module "${name}" is not included in the external bundle` + ); + } } return diagnostics; @@ -559,6 +566,7 @@ export function installPrebidNpm(config?: Partial): typeof pbjs // client-side bidders are left untouched. pbjs.requestBids = function (requestObj?: Parameters[0]) { log.debug('[tsjs-prebid] requestBids called'); + recordUserIdModuleDiagnostics(); const opts = requestObj || {}; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -811,25 +819,13 @@ export function installRefreshHandler(timeoutMs = 1500): void { } /** - * Configure Prebid.js userID modules for identity warm-up. - * - * Runs post-window.load (called from installPrebidNpm after setup). - * Writes identity tokens to 1P cookies so the next server-side request - * can harvest them for EC graph enrichment. + * Configure identity sync behavior for the generated Prebid User ID modules. * - * **Current state:** This function only configures `pbjs.userSync` settings. - * It does NOT import or register any userID modules. Actual module imports - * (ID5, sharedID, LiveRamp ATS, Lockr) must be added to this bundle explicitly - * — there is currently no `_userIdModules.generated.ts` build step. - * Track as Phase B follow-up: add `TSJS_PREBID_USER_ID_MODULES` handling to - * `build-all.mjs` (similar to `TSJS_PREBID_ADAPTERS`) and import generated file. + * The external bundle generator statically imports the selected modules through + * `_user_ids.generated.ts`. This post-window-load configuration controls when + * those modules synchronize identities; it does not select or register modules. */ export function installUserIdModules(): void { - // NOTE: No userID module imports exist yet. `_userIdModules.generated.ts` and - // `TSJS_PREBID_USER_ID_MODULES` handling in `build-all.mjs` are not implemented. - // This function only configures pbjs.userSync settings; actual module registration - // requires the Phase B follow-up described in the docblock above. - // Configure sync behavior so modules will run post-window.load when added. try { pbjs.setConfig({ userSync: { diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.json b/crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.json index 10f244f3..a4fd58db 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.json +++ b/crates/trusted-server-js/lib/src/integrations/prebid/user_id_modules.json @@ -58,6 +58,18 @@ "importPath": "prebid.js/modules/liveIntentIdSystem.js", "notes": "Imported through a local ESM shim because the public Prebid wrapper contains CommonJS require()." }, + { + "moduleName": "lockrAIMIdSystem", + "configNames": ["lockrAIMId"], + "eidSources": [], + "importPath": "prebid.js/modules/lockrAIMIdSystem.js" + }, + { + "moduleName": "pairIdSystem", + "configNames": ["pairId"], + "eidSources": ["google.com"], + "importPath": "prebid.js/modules/pairIdSystem.js" + }, { "moduleName": "pubProvidedIdSystem", "configNames": ["pubProvidedId"], diff --git a/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs b/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs index 609bdba9..10702f91 100644 --- a/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs +++ b/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs @@ -3,7 +3,11 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { deriveBundleMetadata, parseArgs } from '../build-prebid-external.mjs'; +import { + deriveBundleMetadata, + parseArgs, + renderIncludedUserIdModulesExport, +} from '../build-prebid-external.mjs'; describe('build-prebid-external metadata', () => { it('derives filename, sha256, and SRI from exact bundle bytes', () => { @@ -18,6 +22,12 @@ describe('build-prebid-external metadata', () => { }); }); + it('renders the exact selected User ID modules for runtime diagnostics', () => { + expect(renderIncludedUserIdModulesExport(['liveIntentIdSystem', 'pairIdSystem'])).toBe( + 'export const INCLUDED_PREBID_USER_ID_MODULES = ["liveIntentIdSystem","pairIdSystem"];' + ); + }); + it('resolves relative output paths against the current working directory', () => { const parsed = parseArgs(['--adapters', 'rubicon', '--out', 'dist/prebid']); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 9ad7945c..e6b5fd35 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -7,6 +7,7 @@ const { mockRequestBids, mockRegisterBidAdapter, mockGetUserIdsAsEids, + mockGetConfig, mockPbjs, mockGetBidAdapter, mockAdapterManager, @@ -19,12 +20,14 @@ const { const mockGetUserIdsAsEids = vi.fn( () => [] as Array<{ source: string; uids?: Array<{ id: string; atype?: number }> }> ); + const mockGetConfig = vi.fn(); const mockPbjs = { setConfig: mockSetConfig, processQueue: mockProcessQueue, requestBids: mockRequestBids, registerBidAdapter: mockRegisterBidAdapter, getUserIdsAsEids: mockGetUserIdsAsEids, + getConfig: mockGetConfig, adUnits: [] as any[], }; const mockAdapterManager = { @@ -36,6 +39,7 @@ const { mockRequestBids, mockRegisterBidAdapter, mockGetUserIdsAsEids, + mockGetConfig, mockPbjs, mockGetBidAdapter, mockAdapterManager, @@ -53,9 +57,11 @@ vi.mock('prebid.js/modules/consentManagementGpp.js', () => ({})); vi.mock('prebid.js/modules/consentManagementUsp.js', () => ({})); vi.mock('prebid.js/modules/userId.js', () => ({})); -// Mock the build-generated side-effect imports (no-op in tests) +// Mock the build-generated imports in tests. vi.mock('../../../src/integrations/prebid/_adapters.generated', () => ({})); -vi.mock('../../../src/integrations/prebid/_user_ids.generated', () => ({})); +vi.mock('../../../src/integrations/prebid/_user_ids.generated', () => ({ + INCLUDED_PREBID_USER_ID_MODULES: ['sharedIdSystem'], +})); import { collectBidders, @@ -65,6 +71,7 @@ import { installRefreshHandler, } from '../../../src/integrations/prebid/index'; import type { AuctionBid } from '../../../src/core/auction'; +import { log } from '../../../src/core/log'; describe('prebid/collectBidders', () => { it('returns empty array for empty ad units', () => { @@ -207,8 +214,14 @@ describe('prebid/installPrebidNpm', () => { mockPbjs.adUnits = []; mockGetUserIdsAsEids.mockReset(); mockGetUserIdsAsEids.mockReturnValue([]); + mockGetConfig.mockReset(); document.cookie = 'ts-eids=; Path=/; Max-Age=0'; delete (window as any).__tsjs_prebid; + delete (window as any).__tsjs_prebid_diagnostics; + }); + + afterEach(() => { + vi.restoreAllMocks(); }); it('registers the trustedServer bid adapter', () => { @@ -251,6 +264,36 @@ describe('prebid/installPrebidNpm', () => { expect(mockProcessQueue).toHaveBeenCalledTimes(1); }); + it('reports the User ID modules selected by the generated bundle', () => { + installPrebidNpm(); + + expect((window as any).__tsjs_prebid_diagnostics.userIdModules).toEqual({ + includedModules: ['sharedIdSystem'], + configuredUserIdNames: [], + missingConfiguredUserIdNames: [], + }); + }); + + it('refreshes late User ID config without repeating missing-module warnings', () => { + installPrebidNpm(); + mockGetConfig.mockImplementation((key?: string) => + key === 'userSync.userIds' ? [{ name: 'sharedId' }, { name: 'pairId' }] : {} + ); + const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => {}); + + mockPbjs.requestBids({ adUnits: [] }); + mockPbjs.requestBids({ adUnits: [] }); + + expect((window as any).__tsjs_prebid_diagnostics.userIdModules).toEqual({ + includedModules: ['sharedIdSystem'], + configuredUserIdNames: ['pairId', 'sharedId'], + missingConfiguredUserIdNames: ['pairId'], + }); + expect( + warnSpy.mock.calls.filter(([message]) => String(message).includes('"pairId"')) + ).toHaveLength(1); + }); + it('returns the pbjs instance', () => { const result = installPrebidNpm(); expect(result).toBe(mockPbjs); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts index f011f294..2832a408 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/user_id_modules.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { resolvePrebidUserIdModulesFromEids } from '../../../src/integrations/prebid/user_id_modules'; +import { + knownUserIdConfigNames, + resolvePrebidUserIdModulesFromEids, +} from '../../../src/integrations/prebid/user_id_modules'; const sampleEids = [ { source: 'yahoo.com', uids: [{ id: 'connect-id', atype: 3 }] }, @@ -65,6 +68,23 @@ describe('prebid user ID module registry', () => { }); }); + it('exposes config names for modules that do not map EID sources', () => { + expect(knownUserIdConfigNames()).toEqual( + expect.arrayContaining(['lockrAIMId', 'pubProvidedId']) + ); + }); + + it('maps the Google PAIR EID source to pairIdSystem', () => { + const result = resolvePrebidUserIdModulesFromEids([ + { source: 'google.com', uids: [{ id: 'pair-id' }] }, + ]); + + expect(result).toEqual({ + modules: ['userId', 'pairIdSystem'], + missingSources: [], + }); + }); + it('maps unknown LiveIntent provider-backed sources to liveIntentIdSystem', () => { const result = resolvePrebidUserIdModulesFromEids([ { From e94430eb72afe9feffdba612e01ec63de5ed8934 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 10 Jul 2026 09:50:08 -0500 Subject: [PATCH 06/44] Order Prebid imports for lint --- crates/trusted-server-js/lib/src/integrations/prebid/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index c6cf97cf..a4b1ccff 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -25,13 +25,13 @@ import 'prebid.js/modules/userId.js'; // shim leaves its bids untouched and the corresponding adapter handles them // natively in the browser. import './_adapters.generated'; -import { INCLUDED_PREBID_USER_ID_MODULES } from './_user_ids.generated'; import { log } from '../../core/log'; import { buildAdRequest, parseAuctionResponse } from '../../core/auction'; import type { AuctionBid, AuctionEid } from '../../core/auction'; import type { AuctionSlot } from '../../core/types'; +import { INCLUDED_PREBID_USER_ID_MODULES } from './_user_ids.generated'; import { PREBID_USER_ID_MODULE_REGISTRY } from './user_id_modules'; const ADAPTER_CODE = 'trustedServer'; From b56adcb39ade3902ff8c131bb8ac7397fbfff74c Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 10 Jul 2026 10:28:03 -0500 Subject: [PATCH 07/44] Preserve vendor-specific OpenRTB atype values --- .../src/auction/endpoints.rs | 22 +++++++- crates/trusted-server-core/src/ec/eids.rs | 16 +++++- .../trusted-server-core/src/ec/prebid_eids.rs | 48 ++++++++++++++--- crates/trusted-server-core/src/ec/registry.rs | 2 +- .../src/integrations/prebid.rs | 18 ++++++- crates/trusted-server-core/src/openrtb.rs | 23 ++++++++- crates/trusted-server-core/src/settings.rs | 51 +++++++++++++++++-- .../lib/src/integrations/prebid/index.ts | 5 +- .../lib/test/build-prebid-external.test.mjs | 32 ++++++++++++ .../test/integrations/prebid/index.test.ts | 10 +++- trusted-server.example.toml | 1 + 11 files changed, 206 insertions(+), 22 deletions(-) diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 83c7df34..2833e63e 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -476,7 +476,7 @@ fn parse_client_auction_uid(raw: &JsonValue) -> Option { let atype = uid .get("atype") .and_then(JsonValue::as_u64) - .and_then(|atype| u8::try_from(atype).ok()); + .and_then(|atype| i32::try_from(atype).ok()); let ext = match uid.get("ext") { Some(JsonValue::Object(_)) => uid.get("ext").cloned(), @@ -1057,6 +1057,24 @@ mod tests { assert_eq!(parsed[0].uids[0].id, "valid", "should keep valid UID"); } + #[test] + fn parse_client_auction_eids_preserves_pair_atype() { + let raw = json!([ + { + "source": "google.com", + "uids": [{ "id": "pair-id", "atype": 571187 }] + } + ]); + + let parsed = parse_client_auction_eids(Some(&raw)).expect("should parse PAIR EID"); + + assert_eq!( + parsed[0].uids[0].atype, + Some(571187), + "should preserve PAIR's vendor-specific atype" + ); + } + #[test] fn parse_client_auction_eids_preserves_uid_ext_and_sanitizes_invalid_atype() { let raw = json!([ @@ -1070,7 +1088,7 @@ mod tests { }, { "id": "uid-bad-atype", - "atype": 999, + "atype": 2_147_483_648_u64, "ext": { "keep": true } }, { diff --git a/crates/trusted-server-core/src/ec/eids.rs b/crates/trusted-server-core/src/ec/eids.rs index 1dd8b179..2c01bf85 100644 --- a/crates/trusted-server-core/src/ec/eids.rs +++ b/crates/trusted-server-core/src/ec/eids.rs @@ -25,7 +25,7 @@ pub struct ResolvedPartnerId { /// The synced user ID value. pub uid: String, /// `OpenRTB` agent type for this partner's identifiers. - pub openrtb_atype: u8, + pub openrtb_atype: i32, } /// Resolves source-domain keyed IDs from a KV entry against the partner registry. @@ -214,17 +214,29 @@ mod tests { source_domain: "id5-sync.com".to_owned(), openrtb_atype: 1, }, + ResolvedPartnerId { + uid: "pair-id".to_owned(), + source_domain: "google.com".to_owned(), + openrtb_atype: 571187, + }, ]; let eids = to_eids(&resolved); - assert_eq!(eids.len(), 2, "should produce one EID per resolved partner"); + assert_eq!(eids.len(), 3, "should produce one EID per resolved partner"); assert_eq!(eids[0].source, "liveramp.com"); assert_eq!(eids[0].uids[0].id, "LR_xyz"); assert_eq!(eids[0].uids[0].atype, Some(3)); assert_eq!(eids[1].source, "id5-sync.com"); assert_eq!(eids[1].uids[0].id, "ID5_abc"); assert_eq!(eids[1].uids[0].atype, Some(1)); + assert_eq!(eids[2].source, "google.com", "should preserve PAIR source"); + assert_eq!(eids[2].uids[0].id, "pair-id", "should preserve PAIR ID"); + assert_eq!( + eids[2].uids[0].atype, + Some(571187), + "should preserve PAIR vendor-specific atype" + ); } #[test] diff --git a/crates/trusted-server-core/src/ec/prebid_eids.rs b/crates/trusted-server-core/src/ec/prebid_eids.rs index 22620e59..dc95be1a 100644 --- a/crates/trusted-server-core/src/ec/prebid_eids.rs +++ b/crates/trusted-server-core/src/ec/prebid_eids.rs @@ -33,7 +33,7 @@ struct LegacyCookieEid { dead_code, reason = "legacy cookie field is deserialized for compatibility but not emitted" )] - atype: u8, + atype: i32, } /// OpenRTB-style `ts-eids` cookie entry. @@ -48,7 +48,7 @@ struct StructuredCookieEid { struct StructuredCookieUid { id: String, #[serde(default)] - atype: Option, + atype: Option, #[serde(default)] ext: Option, } @@ -318,6 +318,7 @@ fn structured_cookie_uid_to_openrtb(uid: StructuredCookieUid) -> Option { return None; } + let atype = uid.atype.filter(|atype| *atype >= 0); let ext = match uid.ext { Some(JsonValue::Object(_)) => uid.ext, _ => None, @@ -325,7 +326,7 @@ fn structured_cookie_uid_to_openrtb(uid: StructuredCookieUid) -> Option { Some(Uid { id: uid.id, - atype: uid.atype, + atype, ext, }) } @@ -338,7 +339,7 @@ fn legacy_cookie_eids_to_openrtb(entries: Vec) -> Vec { source: entry.source, uids: vec![Uid { id: entry.id, - atype: Some(entry.atype), + atype: (entry.atype >= 0).then_some(entry.atype), ext: None, }], }) @@ -407,15 +408,25 @@ mod tests { let eids = vec![ json!({"source": "id5-sync.com", "id": "ID5_abc", "atype": 1}), json!({"source": "liveramp.com", "id": "LR_xyz", "atype": 3}), + json!({"source": "google.com", "id": "pair-id", "atype": 571187}), ]; let encoded = BASE64.encode(serde_json::to_vec(&eids).expect("should serialize")); let decoded = parse_prebid_eids_cookie(&encoded).expect("should decode valid payload"); - assert_eq!(decoded.len(), 2, "should parse both EIDs"); + assert_eq!(decoded.len(), 3, "should parse all EIDs"); assert_eq!(decoded[0].source, "id5-sync.com"); assert_eq!(decoded[0].uids[0].id, "ID5_abc"); assert_eq!(decoded[1].source, "liveramp.com"); assert_eq!(decoded[1].uids[0].id, "LR_xyz"); + assert_eq!( + decoded[2].source, "google.com", + "should preserve PAIR source" + ); + assert_eq!( + decoded[2].uids[0].atype, + Some(571187), + "should preserve PAIR vendor-specific atype" + ); } #[test] @@ -424,7 +435,8 @@ mod tests { "source": "sharedid.org", "uids": [ {"id": "shared_123", "atype": 3}, - {"id": "shared_456", "ext": {"provider": "example"}} + {"id": "shared_456", "ext": {"provider": "example"}}, + {"id": "shared_invalid", "atype": -1} ] })]; let encoded = BASE64.encode(serde_json::to_vec(&eids).expect("should serialize")); @@ -432,7 +444,7 @@ mod tests { let decoded = parse_prebid_eids_cookie(&encoded).expect("should decode valid payload"); assert_eq!(decoded.len(), 1, "should parse one structured EID entry"); assert_eq!(decoded[0].source, "sharedid.org"); - assert_eq!(decoded[0].uids.len(), 2, "should preserve multiple UIDs"); + assert_eq!(decoded[0].uids.len(), 3, "should preserve multiple UIDs"); assert_eq!(decoded[0].uids[0].id, "shared_123"); assert_eq!(decoded[0].uids[0].atype, Some(3)); assert_eq!( @@ -440,6 +452,28 @@ mod tests { Some(json!({"provider": "example"})), "should preserve UID ext objects" ); + assert_eq!( + decoded[0].uids[2].atype, None, + "should drop negative atype values" + ); + } + + #[test] + fn parse_prebid_eids_cookie_preserves_pair_atype() { + let encoded = encode_json(&json!([ + { + "source": "google.com", + "uids": [{ "id": "pair-id", "atype": 571187 }] + } + ])); + + let decoded = parse_prebid_eids_cookie(&encoded).expect("should decode PAIR EID"); + + assert_eq!( + decoded[0].uids[0].atype, + Some(571187), + "should preserve PAIR's vendor-specific atype" + ); } #[test] diff --git a/crates/trusted-server-core/src/ec/registry.rs b/crates/trusted-server-core/src/ec/registry.rs index 6b688d30..8532de03 100644 --- a/crates/trusted-server-core/src/ec/registry.rs +++ b/crates/trusted-server-core/src/ec/registry.rs @@ -25,7 +25,7 @@ pub struct PartnerConfig { /// Canonical `OpenRTB` EID source domain and EC KV `ids` key. pub source_domain: String, /// `OpenRTB` `atype` value. - pub openrtb_atype: u8, + pub openrtb_atype: i32, /// Whether this partner's UIDs appear in auction `user.eids`. pub bidstream_enabled: bool, /// SHA-256 hex of the partner's API token (precomputed at startup). diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 32a1c2a8..acfef572 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -4654,6 +4654,14 @@ external_bundle_sri = "sha384-AAAA" ext: None, }], }, + crate::openrtb::Eid { + source: "google.com".to_owned(), + uids: vec![crate::openrtb::Uid { + id: "pair-id".to_owned(), + atype: Some(571187), + ext: None, + }], + }, ]); let settings = make_settings(); @@ -4670,7 +4678,7 @@ external_bundle_sri = "sha384-AAAA" let serialized = serde_json::to_value(&openrtb).expect("should serialize OpenRTB request"); let ext_eids = &serialized["user"]["ext"]["eids"]; assert!(ext_eids.is_array(), "should populate user.ext.eids"); - assert_eq!(ext_eids.as_array().unwrap().len(), 2, "should have 2 EIDs"); + assert_eq!(ext_eids.as_array().unwrap().len(), 3, "should have 3 EIDs"); assert_eq!( ext_eids[0]["source"], "liveramp.com", "should include liveramp EID" @@ -4679,6 +4687,14 @@ external_bundle_sri = "sha384-AAAA" ext_eids[1]["source"], "id5-sync.com", "should include id5 EID" ); + assert_eq!( + ext_eids[2]["source"], "google.com", + "should include PAIR EID" + ); + assert_eq!( + ext_eids[2]["uids"][0]["atype"], 571187, + "should preserve PAIR's vendor-specific atype" + ); } #[test] diff --git a/crates/trusted-server-core/src/openrtb.rs b/crates/trusted-server-core/src/openrtb.rs index df99f565..65237ce0 100644 --- a/crates/trusted-server-core/src/openrtb.rs +++ b/crates/trusted-server-core/src/openrtb.rs @@ -80,9 +80,9 @@ pub struct Eid { pub struct Uid { /// The identifier value. pub id: String, - /// Agent type: 1 = cookie/device, 2 = person, 3 = user-provided. + /// `OpenRTB` agent type, including vendor-specific values such as PAIR's `571187`. #[serde(skip_serializing_if = "Option::is_none")] - pub atype: Option, + pub atype: Option, /// Provider-specific extension data. #[serde(skip_serializing_if = "Option::is_none")] pub ext: Option, @@ -400,4 +400,23 @@ mod tests { "ext should be omitted when None" ); } + + #[test] + fn eid_serializes_vendor_specific_atype() { + let eid = Eid { + source: "google.com".to_owned(), + uids: vec![Uid { + id: "pair-id".to_owned(), + atype: Some(571187), + ext: None, + }], + }; + + let serialized = serde_json::to_value(&eid).expect("should serialize"); + + assert_eq!( + serialized["uids"][0]["atype"], 571187, + "should preserve PAIR's vendor-specific atype" + ); + } } diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 9cbb2a54..aaacb29d 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -299,12 +299,13 @@ pub struct EcPartner { /// This normalized domain is also the canonical EC KV `ids` map key. #[validate(custom(function = EcPartner::validate_source_domain))] pub source_domain: String, - /// `OpenRTB` `atype` value (typically 3). + /// `OpenRTB` `atype` value, including vendor-specific values such as PAIR's `571187`. #[serde( default = "EcPartner::default_openrtb_atype", deserialize_with = "from_value_or_str" )] - pub openrtb_atype: u8, + #[validate(range(min = 0, message = "must be a non-negative OpenRTB agent type"))] + pub openrtb_atype: i32, /// Whether this partner's UIDs appear in auction `user.eids`. #[serde(default, deserialize_with = "from_value_or_str")] pub bidstream_enabled: bool, @@ -417,7 +418,7 @@ impl EcPartner { } #[must_use] - pub const fn default_openrtb_atype() -> u8 { + pub const fn default_openrtb_atype() -> i32 { 3 } @@ -2808,6 +2809,46 @@ mod tests { } } + #[test] + fn validate_accepts_vendor_specific_ec_partner_atype() { + let toml_str = format!( + r#"{} + [[ec.partners]] + name = "PAIR Partner" + source_domain = "google.com" + openrtb_atype = 571187 + api_token = "test-vendor-token-32-bytes-minimum" + "#, + crate_test_settings_str(), + ); + + let settings = Settings::from_toml(&toml_str) + .expect("should accept vendor-specific OpenRTB agent type"); + + assert_eq!( + settings.ec.partners[0].openrtb_atype, 571187, + "should preserve PAIR's vendor-specific atype" + ); + } + + #[test] + fn validate_rejects_negative_ec_partner_atype() { + let toml_str = format!( + r#"{} + [[ec.partners]] + name = "Invalid Partner" + source_domain = "partner.example.com" + openrtb_atype = -1 + api_token = "test-vendor-token-32-bytes-minimum" + "#, + crate_test_settings_str(), + ); + + let result = Settings::from_toml(&toml_str); + + assert!(result.is_err(), "should reject negative OpenRTB agent type"); + } + #[test] fn validate_accepts_origin_host_header_override() { let toml_str = crate_test_settings_str().replace( @@ -3647,7 +3688,7 @@ origin_host_header_overide = "www.example.com""#, (origin_key, Some("https://origin.test-publisher.com")), (partner_0_name_key, Some("Env Partner 0")), (partner_0_source_domain_key, Some("envpartner0.example.com")), - (partner_0_openrtb_atype_key, Some("1")), + (partner_0_openrtb_atype_key, Some("571187")), (partner_0_bidstream_enabled_key, Some("true")), (partner_0_api_token_key, Some("env-token-0")), (partner_1_name_key, Some("Env Partner 1")), @@ -3666,7 +3707,7 @@ origin_host_header_overide = "www.example.com""#, settings.ec.partners[0].source_domain, "envpartner0.example.com" ); - assert_eq!(settings.ec.partners[0].openrtb_atype, 1); + assert_eq!(settings.ec.partners[0].openrtb_atype, 571187); assert!(settings.ec.partners[0].bidstream_enabled); assert_eq!(settings.ec.partners[0].api_token.expose(), "env-token-0"); assert_eq!(settings.ec.partners[1].name, "Env Partner 1"); diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index a4b1ccff..342e4038 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -35,6 +35,9 @@ import { INCLUDED_PREBID_USER_ID_MODULES } from './_user_ids.generated'; import { PREBID_USER_ID_MODULE_REGISTRY } from './user_id_modules'; const ADAPTER_CODE = 'trustedServer'; +// OpenRTB permits vendor-specific agent types; PAIR uses 571187. +// Keep this range aligned with the signed 32-bit Rust/OpenRTB representation. +const MAX_OPENRTB_ATYPE = 2_147_483_647; const BIDDER_PARAMS_KEY = 'bidderParams'; const ZONE_KEY = 'zone'; const TS_REFRESH_TARGETING_KEYS = [ @@ -281,7 +284,7 @@ function sanitizeAuctionUid(uid: { typeof uid.atype === 'number' && Number.isInteger(uid.atype) && uid.atype >= 0 && - uid.atype <= 255 + uid.atype <= MAX_OPENRTB_ATYPE ) { sanitizedUid.atype = uid.atype; } diff --git a/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs b/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs index 10702f91..a1c77c47 100644 --- a/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs +++ b/crates/trusted-server-js/lib/test/build-prebid-external.test.mjs @@ -1,10 +1,15 @@ +// @vitest-environment node + import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { deriveBundleMetadata, + main, parseArgs, renderIncludedUserIdModulesExport, } from '../build-prebid-external.mjs'; @@ -28,6 +33,33 @@ describe('build-prebid-external metadata', () => { ); }); + it('includes generated User ID metadata in the production external bundle', async () => { + const outputDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), 'trusted-server-prebid-build-test-') + ); + + try { + await main([ + '--adapters', + 'rubicon', + '--user-id-modules', + 'pairIdSystem,lockrAIMIdSystem', + '--out', + outputDirectory, + ]); + + const manifest = JSON.parse( + fs.readFileSync(path.join(outputDirectory, 'manifest.json'), 'utf8') + ); + const bundle = fs.readFileSync(path.join(outputDirectory, manifest.filename), 'utf8'); + + expect(manifest.userIdModules).toEqual(['pairIdSystem', 'lockrAIMIdSystem']); + expect(bundle).toContain('["pairIdSystem","lockrAIMIdSystem"]'); + } finally { + fs.rmSync(outputDirectory, { recursive: true, force: true }); + } + }, 120_000); + it('resolves relative output paths against the current working directory', () => { const parsed = parseArgs(['--adapters', 'rubicon', '--out', 'dist/prebid']); diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index e6b5fd35..726f40b4 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -344,6 +344,10 @@ describe('prebid/installPrebidNpm', () => { source: 'sharedid.org', uids: [{ id: 'shared_123' }, { id: 'shared_456', atype: 3 }], }, + { + source: 'google.com', + uids: [{ id: 'pair_123', atype: 571187 }], + }, ]); const result = spec.buildRequests([ @@ -365,6 +369,10 @@ describe('prebid/installPrebidNpm', () => { source: 'sharedid.org', uids: [{ id: 'shared_123' }, { id: 'shared_456', atype: 3 }], }, + { + source: 'google.com', + uids: [{ id: 'pair_123', atype: 571187 }], + }, ]); }); @@ -398,7 +406,7 @@ describe('prebid/installPrebidNpm', () => { }, { id: 'uid-bad-atype', - atype: 999, + atype: 2_147_483_648, ext: { keep: true }, }, { diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 0951b781..879eb4b9 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -22,6 +22,7 @@ pull_sync_concurrency = 3 # [[ec.partners]] # name = "Example Partner" # source_domain = "partner.example.com" +# OpenRTB agent type; vendor-specific values are supported (PAIR uses 571187). # openrtb_atype = 3 # bidstream_enabled = true # api_token = "replace-with-partner-api-token-32-bytes-minimum" From b52b415755fbffd63935a94ad3d14f8165b82440 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Sat, 11 Jul 2026 00:32:35 +0530 Subject: [PATCH 08/44] Dump full SSAT prebid responses in ts-debug auction comment The server-side auction stream path only emitted a summary counter (ssp/mediator/winning/time), so an operator seeing winning=0 could not tell whether prebid returned nothing, errored, or bid below the floor. Serialize the full provider_responses (and mediator_response) into the ts-debug HTML comment so the SSAT surfaces the same prebid server response detail available from the /auction endpoint. Bid creative and metadata are attacker/partner-influenced, so neutralize the '-->' and '--!>' comment terminators before embedding to keep the dump inside the comment and out of the live DOM. --- crates/trusted-server-core/src/publisher.rs | 92 ++++++++++++++++++++- 1 file changed, 91 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 6b0ea3a5..c1441012 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -871,8 +871,28 @@ pub(crate) fn prepend_auction_debug_comment( Some(r) => format!("ok({}_bids)", r.bids.len()), None => "none".to_string(), }; + // Full per-provider (and mediator) dump so the operator can see exactly what + // each SSP returned — `status` (nobid vs error vs success), every `bids` + // entry, and `metadata` (which carries PBS `ext.errors` / `ext.debug.httpcalls` + // when prebid `debug=true`) — without needing log access. + // + // `Bid.creative` and provider metadata are attacker/partner-influenced and + // may contain `-->` (or the `--!>` variant), which would terminate the HTML + // comment early and leak the remaining markup into the live DOM. Neutralise + // both terminators before embedding so the dump stays inside the comment. + let neutralise_comment_terminators = + |json: String| -> String { json.replace("-->", "-- >").replace("--!>", "-- !>") }; + let providers_dump = serde_json::to_string_pretty(&result.provider_responses) + .map(neutralise_comment_terminators) + .unwrap_or_else(|e| format!("")); + let mediator_dump = serde_json::to_string_pretty(&result.mediator_response) + .map(neutralise_comment_terminators) + .unwrap_or_else(|e| format!("")); let debug_comment = format!( - "", + "", result.winning_bids.len(), result.total_time_ms, ); @@ -2439,6 +2459,76 @@ mod tests { use super::*; use crate::auction::types::{AdFormat, AdSlot, MediaType}; use crate::integrations::IntegrationRegistry; + + #[test] + fn auction_debug_comment_dumps_provider_status_and_neutralises_terminators() { + use crate::auction::orchestrator::OrchestrationResult; + use crate::auction::types::AuctionResponse; + + // One provider that returned nothing (the `winning=0` case) and one that + // returned a bid whose creative embeds an HTML-comment terminator. + let no_bid = AuctionResponse::no_bid("prebid", 665); + let mut bid = make_test_bid_with_creative("
evil-->break
"); + bid.slot_id = "ad-header-0".to_string(); + let with_bid = AuctionResponse::success("aps", vec![bid], 42); + + let result = OrchestrationResult { + provider_responses: vec![no_bid, with_bid], + mediator_response: None, + winning_bids: std::collections::HashMap::new(), + total_time_ms: 665, + metadata: std::collections::HashMap::new(), + }; + + let state = Arc::new(Mutex::new(Some("BIDS_SCRIPT".to_string()))); + prepend_auction_debug_comment("stream", &result, &state); + let comment = state + .lock() + .expect("should lock state") + .clone() + .expect("should have comment"); + + assert!( + comment.contains("\"status\": \"nobid\""), + "should surface the no-bid provider status: {comment}" + ); + assert!( + comment.contains("provider_responses="), + "should dump the provider_responses payload" + ); + // The creative's `-->` must be neutralised so the only comment terminator + // is the trailing one — otherwise embedded markup would leak into the DOM. + assert_eq!( + comment.matches("-->").count(), + 1, + "creative `-->` must be neutralised, leaving only the closing terminator: {comment}" + ); + assert!( + comment.contains("evil-- >break"), + "should retain the creative content with the terminator neutralised" + ); + } + + fn make_test_bid_with_creative(creative: &str) -> Bid { + Bid { + slot_id: "slot".to_string(), + price: Some(1.0), + currency: "USD".to_string(), + creative: Some(creative.to_string()), + adomain: None, + bidder: "seat".to_string(), + width: 300, + height: 250, + nurl: None, + burl: None, + ad_id: None, + cache_id: None, + cache_host: None, + cache_path: None, + metadata: Default::default(), + } + } + use crate::platform::test_support::{ build_services_with_http_client, noop_services, StubHttpClient, }; From 48db8bf6a56dd31fd2a2b7334354b12f882b8fd2 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Sat, 11 Jul 2026 00:51:41 +0530 Subject: [PATCH 09/44] Surface prebid HTTP error status and body in auction dump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Prebid Server returns a non-2xx status, the parser returned a bare AuctionResponse::error with empty metadata — indistinguishable in the ts-debug dump from a transport, parse, or timeout failure, all of which tag error_type. An operator seeing status=error with metadata={} had no way to know the upstream HTTP code without log access. Attach error_type=http_status, the status code, and a 512-byte body snippet to the error response metadata so the auction dump shows exactly why prebid errored (e.g. a 4xx from a PBS rejecting the request). --- .../src/integrations/prebid.rs | 51 ++++++++++++++++++- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 32a1c2a8..c6fccc2c 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -2124,14 +2124,23 @@ impl AuctionProvider for PrebidAuctionProvider { if !status.is_success() { log::warn!("Prebid returned non-success status: {}", status,); + let body_preview = String::from_utf8_lossy(&body_bytes); if log::log_enabled!(log::Level::Trace) { - let body_preview = String::from_utf8_lossy(&body_bytes); log::trace!( "Prebid error response body: {}", &body_preview[..body_preview.floor_char_boundary(1000)] ); } - return Ok(AuctionResponse::error("prebid", response_time_ms)); + // Surface the HTTP status and a body snippet on the response metadata + // so the ts-debug auction dump shows *why* prebid errored (e.g. a 4xx + // from a PBS that rejects the unsigned server-side request) without + // needing log access. A bare `AuctionResponse::error` yields empty + // metadata, which is indistinguishable from other failures in the dump. + let body_snippet = body_preview[..body_preview.floor_char_boundary(512)].to_string(); + return Ok(AuctionResponse::error("prebid", response_time_ms) + .with_metadata("error_type", serde_json::json!("http_status")) + .with_metadata("status", serde_json::json!(status.as_u16())) + .with_metadata("body", serde_json::json!(body_snippet))); } let response_json: Json = @@ -2341,6 +2350,44 @@ mod tests { ); } + #[test] + fn parse_response_attaches_status_and_body_metadata_on_http_error() { + use crate::auction::types::BidStatus; + + let provider = PrebidAuctionProvider::new(base_config()); + let response = PlatformResponse::new( + edgezero_core::http::response_builder() + .status(403) + .body(EdgeBody::from(br#"{"error":"missing signature"}"#.to_vec())) + .expect("should build test response"), + ); + + let result = futures::executor::block_on(provider.parse_response(response, 643)) + .expect("should return Ok(error response) for non-success status"); + + assert_eq!( + result.status, + BidStatus::Error, + "non-success HTTP status should map to an error response" + ); + assert_eq!( + result.metadata["error_type"], + json!("http_status"), + "should tag the error path so the auction dump is distinguishable" + ); + assert_eq!( + result.metadata["status"], + json!(403), + "should surface the upstream HTTP status code" + ); + assert!( + result.metadata["body"] + .as_str() + .is_some_and(|body| body.contains("missing signature")), + "should include the response body snippet" + ); + } + fn test_sri(algorithm: &str, digest: &[u8]) -> String { format!("{algorithm}-{}", TEST_BASE64_STANDARD.encode(digest)) } From d5871da84ec21af5c8118c402b5e2243b0731c0f Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 13 Jul 2026 13:12:30 +0530 Subject: [PATCH 10/44] Stop leaking prebid error body into the public auction response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review of the SSAT debug-dump change: - The PBS non-2xx response body was attached to AuctionResponse.metadata, which ProviderSummary clones verbatim into ext.orchestrator.provider_details on the public /auction response — violating the documented invariant in auction/orchestrator.rs. Drop the body from metadata, keep only the numeric status, and log the snippet server-side at warn. - Register ERROR_TYPE_HTTP_STATUS and match it in provider_status so PBS HTTP errors get their own telemetry bucket instead of the transport_error fallback. - Bound the ts-debug dump: compact serialization capped at 256 KiB, and skip the mediator_response line when no mediator ran. - Correct the auction_html_comment and prepend_auction_debug_comment docs to state the comment now embeds raw SSP creative markup (never enable in prod). - Keep the targeted two-replace terminator neutralisation: a single replace("--", ...) re-forms -->/--!> at odd dash-run junctions and is not equivalent. Add a table-driven test over the comment-terminator vectors. - Hoist test-local imports to module scope per CLAUDE.md. --- .../src/auction/orchestrator.rs | 5 + .../src/auction/telemetry.rs | 1 + .../src/integrations/prebid.rs | 58 +++--- crates/trusted-server-core/src/publisher.rs | 189 +++++++++++------- crates/trusted-server-core/src/settings.rs | 8 +- 5 files changed, 165 insertions(+), 96 deletions(-) diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 145059d9..45d90e76 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -96,6 +96,11 @@ const ERROR_TYPE_PARSE_RESPONSE: &str = "parse_response"; const ERROR_TYPE_LAUNCH_FAILED: &str = "launch_failed"; const ERROR_TYPE_TRANSPORT: &str = "transport"; const ERROR_TYPE_TIMEOUT: &str = "timeout"; +/// A non-2xx HTTP status from an upstream SSP (e.g. a PBS 4xx/5xx). Distinct +/// from [`ERROR_TYPE_TRANSPORT`] (a connection-level failure) so telemetry can +/// bucket it separately. `pub(crate)` so producers such as the prebid provider +/// tag errors with the exact value the telemetry layer recognises. +pub(crate) const ERROR_TYPE_HTTP_STATUS: &str = "http_status"; // SECURITY: the returned string is included verbatim (truncated to // PROVIDER_ERROR_MESSAGE_CHARS) in the public /auction response via diff --git a/crates/trusted-server-core/src/auction/telemetry.rs b/crates/trusted-server-core/src/auction/telemetry.rs index 4819cef6..ac92a98f 100644 --- a/crates/trusted-server-core/src/auction/telemetry.rs +++ b/crates/trusted-server-core/src/auction/telemetry.rs @@ -800,6 +800,7 @@ fn provider_status(response: &AuctionResponse) -> &'static str { Some("parse_response") => "parse_error", Some("transport") => "transport_error", Some("timeout") => "timeout", + Some("http_status") => "http_status_error", _ => "transport_error", }, BidStatus::Pending => "timeout", diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index c6fccc2c..22c972f5 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -2123,24 +2123,25 @@ impl AuctionProvider for PrebidAuctionProvider { })?; if !status.is_success() { - log::warn!("Prebid returned non-success status: {}", status,); let body_preview = String::from_utf8_lossy(&body_bytes); - if log::log_enabled!(log::Level::Trace) { - log::trace!( - "Prebid error response body: {}", - &body_preview[..body_preview.floor_char_boundary(1000)] - ); - } - // Surface the HTTP status and a body snippet on the response metadata - // so the ts-debug auction dump shows *why* prebid errored (e.g. a 4xx - // from a PBS that rejects the unsigned server-side request) without - // needing log access. A bare `AuctionResponse::error` yields empty - // metadata, which is indistinguishable from other failures in the dump. - let body_snippet = body_preview[..body_preview.floor_char_boundary(512)].to_string(); + // SECURITY: the PBS response body is upstream-controlled and may leak + // internal detail (hostnames, stack traces, auth hints). Per the + // invariant documented in `auction/orchestrator.rs`, it MUST NOT reach + // the public `/auction` response, which happens if it lands in + // `AuctionResponse.metadata` (cloned verbatim into + // `ext.orchestrator.provider_details[].metadata`). Log the snippet + // server-side and surface only the numeric HTTP status — enough for an + // operator to tell an error from a no-bid without publishing the body. + log::warn!( + "Prebid returned non-success status {status}: {}", + &body_preview[..body_preview.floor_char_boundary(512)] + ); return Ok(AuctionResponse::error("prebid", response_time_ms) - .with_metadata("error_type", serde_json::json!("http_status")) - .with_metadata("status", serde_json::json!(status.as_u16())) - .with_metadata("body", serde_json::json!(body_snippet))); + .with_metadata( + "error_type", + serde_json::json!(crate::auction::orchestrator::ERROR_TYPE_HTTP_STATUS), + ) + .with_metadata("status", serde_json::json!(status.as_u16()))); } let response_json: Json = @@ -2242,7 +2243,8 @@ mod tests { use super::*; use crate::auction::test_support::create_test_auction_context as shared_test_auction_context; use crate::auction::types::{ - AdFormat, AdSlot, AuctionContext, AuctionRequest, DeviceInfo, PublisherInfo, UserInfo, + AdFormat, AdSlot, AuctionContext, AuctionRequest, BidStatus, DeviceInfo, PublisherInfo, + UserInfo, }; use crate::consent::{ConsentContext, ConsentSource}; @@ -2351,14 +2353,14 @@ mod tests { } #[test] - fn parse_response_attaches_status_and_body_metadata_on_http_error() { - use crate::auction::types::BidStatus; - + fn parse_response_attaches_status_metadata_without_leaking_body_on_http_error() { let provider = PrebidAuctionProvider::new(base_config()); let response = PlatformResponse::new( edgezero_core::http::response_builder() .status(403) - .body(EdgeBody::from(br#"{"error":"missing signature"}"#.to_vec())) + .body(EdgeBody::from( + br#"{"error":"upstream-secret-detail"}"#.to_vec(), + )) .expect("should build test response"), ); @@ -2373,18 +2375,24 @@ mod tests { assert_eq!( result.metadata["error_type"], json!("http_status"), - "should tag the error path so the auction dump is distinguishable" + "should tag the error path so telemetry buckets it as an http status error" ); assert_eq!( result.metadata["status"], json!(403), "should surface the upstream HTTP status code" ); + // SECURITY: the upstream response body must never reach the public + // /auction response via AuctionResponse.metadata. + assert!( + !result.metadata.contains_key("body"), + "upstream response body must not be surfaced on the response metadata" + ); assert!( - result.metadata["body"] + !result.metadata.values().any(|v| v .as_str() - .is_some_and(|body| body.contains("missing signature")), - "should include the response body snippet" + .is_some_and(|s| s.contains("upstream-secret-detail"))), + "no metadata value may contain the upstream body" ); } diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index c1441012..56d3c77f 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -855,12 +855,23 @@ pub(crate) fn write_bids_to_state( *ad_bids_state.lock().expect("should lock bid state") = Some(bids_script); } -/// Prepend an HTML comment summarising the auction result onto the shared -/// `ad_bids_state` so it lands directly before the injected bids `` breakout and `U+2028/2029`. Requirement: a regression test proving a +hostile `adm` containing `` (and `U+2028/2029`) cannot break out of the +injected `` / + `U+2028/2029` `adm` is escaped so `build_bids_script` output stays inside the + `" + ) +} +``` + +- [ ] **Step 4: Update the caller** at `~853/854` to pass `settings.debug.inject_adm_for_testing`; update the empty-bids helper at `~2033` and any test expectations that pin the old script string. + +- [ ] **Step 5: Run — expect PASS.** `cargo test-fastly bids_script_emits_inject_adm_for_testing_flag` + +- [ ] **Step 6: Commit** — `git commit -m "Emit injectAdmForTesting flag on window.tsjs with bids"` + +--- + +## Task 3: Escaping regression — hostile `adm` cannot break out of `` + `U+2028` adm is neutralized in the emitted script. + +```rust +#[test] +fn build_bids_script_escapes_hostile_adm() { + let mut winning = std::collections::HashMap::new(); + let mut bid = /* Bid, price Some(1.0), creative Some("\u{2028}") */; + winning.insert("s".to_string(), bid); + let map = build_bid_map(&winning, PriceGranularity::Dense, true, false); + let script = build_bids_script(&map, false); + // Raw must not survive; U+2028 must be unicode-escaped. + assert!(!script.contains("" - ) -} -``` - -- [ ] **Step 4: Update `build_bids_script` callers:** - - `write_bids_to_state` (`~854`): pass the `inject_adm_for_testing` param threaded in Task 1. - - `build_empty_bids_script` (`~2032`) has **no settings access** — pass `false` (no bids ⇒ no `adm` ⇒ flag inert). Documented tradeoff: an empty *initial* nav on a testing build emits `injectAdmForTesting=false`, so a later SPA-loaded `adm` won't fire the test bypass; acceptable (production is always `false`). - - Fix any test that pins the old `bids` `\u{2028}") */; - winning.insert("s".to_string(), bid); - let map = build_bid_map(&winning, PriceGranularity::Dense, true, false); - let script = build_bids_script(&map, false); - // Raw must not survive; U+2028 must be unicode-escaped. - assert!(!script.contains("\u{2028}"), + ); + let map = build_bid_map(&winning, PriceGranularity::Dense, false); + let script = build_bids_script(&map); + assert!( + !script.contains("` breakout and `U+2028/2029`. Requirement: a regression test proving a -hostile `adm` containing `` (and `U+2028/2029`) cannot break out of the -injected `` breakout and `U+2028/2029`. **This is the guarantee trusted-server +directly provides**, pinned by a hostile-`adm` regression test. + +Frame isolation of the rendered creative is **not** guaranteed by TS on the +bridge path: `injectAdmIntoSlot` sets `sandbox=ADM_IFRAME_SANDBOX`, but the +bridge renderer hands `adm` to the PUC-provided `mkFrame`, which TS neither sets +nor verifies a sandbox on. Bridge isolation therefore depends on the Prebid +Universal Creative implementation, not on TS. ## Components changed | Unit | Change | | --- | --- | -| `build_bid_map` (Rust) | Split `include_adm` → `render_adm` (always) + `debug_bid` (testing). Always insert `adm` for winners. | -| `build_bid_map` callers | Pass `render_adm = true`; `debug_bid = inject_adm_for_testing`. | -| tsjs config injection (Rust→JS) | Surface `injectAdmForTesting` flag. | -| `gpt/index.ts` `injectAdmIntoSlot` call site | Gate on the injected `injectAdmForTesting` flag, not bare `bid.adm`. | +| `build_bid_map` (Rust) | Always insert `adm` when `bid.creative` is `Some`. Rename `include_adm` → `include_debug_bid`, gating only the `debug_bid` blob. | +| `build_bid_map` callers | Pass `include_debug_bid = inject_adm_for_testing`. | +| `gpt/index.ts` `injectAdmIntoSlot` call site | Gate on `bid.adm && bid.debug_bid`. | +| bridge/`ad_init` tests (JS) | Rename "debug adm" → "inline/local adm"; confirm existing coverage. | + +No `build_bids_script` change, no `window.tsjs` flag, no `TsjsApi` change. ## Data flow (after) @@ -110,40 +122,42 @@ SSAT auction → winner (bid.creative held) → build_bid_map inserts adm → build_bids_script (html_escape_for_script) → window.tsjs.bids → hb_pb targeting → GAM competes ├ GAM picks TS line item → PUC "Prebid Request" - │ → bridge replies with local adm → RENDER (no round trip) + │ → bridge replies with local adm → RENDER (no round trip) + beacons │ → (adm absent) → PBS Cache fetch → RENDER (fallback) └ GAM has higher demand → GAM serves its own creative ``` -## Testing - -- **Rust**: `build_bid_map` includes `adm` for winners on the production path; - `debug_bid` present only under the testing flag; a hostile `` / - `U+2028/2029` `adm` is escaped so `build_bids_script` output stays inside the - `` / `U+2028/2029` + `adm` is escaped so `build_bids_script` output stays inside the `\u{2028}"), - ); + let mut bid = make_bid("s", 1.50, "kargo", "abc123", "https://ssp/win", "https://ssp/bill"); + // Both line/paragraph separators — the spec promises escaping for each. + bid.creative = Some("\u{2028}\u{2029}".to_string()); + winning.insert("s".to_string(), bid); let map = build_bid_map(&winning, PriceGranularity::Dense, false); let script = build_bids_script(&map); assert!( @@ -115,8 +119,8 @@ fn build_bids_script_escapes_hostile_adm() { "should not let a hostile adm break out of the script context" ); assert!( - !script.contains('\u{2028}'), - "should unicode-escape U+2028 in the adm" + !script.contains('\u{2028}') && !script.contains('\u{2029}'), + "should unicode-escape both U+2028 and U+2029 in the adm" ); } ``` @@ -135,12 +139,15 @@ Run: `cargo test-fastly build_bids_script_escapes_hostile_adm` - Modify: `crates/trusted-server-js/lib/src/integrations/gpt/index.ts:~599` - Test: `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts` -- [ ] **Step 1: Write failing vitest** — bypass does NOT fire in production (no `debug_bid`), even with `bid.adm`. +- [ ] **Step 1: Write failing vitest — observable behavior (not a spy).** `injectAdmIntoSlot` is module-private, so assert its *effect* on the DOM: ```ts -// window.tsjs.bids = { 'ad-header-0': { adm: '
x
' } } // no debug_bid -// simulate slotRenderEnded for ad-header-0 -// spy on injectAdmIntoSlot → assert NOT called (the bridge handles render) +// Setup: +// bids['ad-header-0'] = { adm: '' } // NO debug_bid +// place an existing GAM iframe (src="about:blank") in the slot div +// capture the slotRenderEnded listener, fire it for 'ad-header-0' +// Assert (production): the GAM iframe src stays 'about:blank' +// — the bypass did not fire; the render bridge handles it. ``` - [ ] **Step 2: Run — expect FAIL.** `cd crates/trusted-server-js/lib && npx vitest run ad_init` @@ -156,7 +163,7 @@ if (bid.adm && bid.debug_bid) { } ``` -- [ ] **Step 4: Add companion test** — with `bid.debug_bid` present, `injectAdmIntoSlot` IS called. +- [ ] **Step 4: Add companion test (testing mode)** — same setup but with `bid.debug_bid` present. Fire `slotRenderEnded` → assert the slot iframe's `src` **changes to** the creative URL (`https://cdn.example/creative.html`), proving `injectAdmIntoSlot` ran. - [ ] **Step 5: Run — expect PASS.** @@ -194,8 +201,9 @@ concurrency + beacon dedup. Do **not** duplicate them. cargo clippy-spin-wasm ``` - [ ] **Step 4:** `cd crates/trusted-server-js/lib && npx vitest run && npm run format && node build-all.mjs` -- [ ] **Step 5:** Manual: with `[debug].auction_html_comment` off, load a nav page; confirm the winning creative renders **without** a request to `hb_cache_host` (Network tab) and GAM still received `hb_pb`. -- [ ] **Step 6: Commit** any format fixes. +- [ ] **Step 5:** Docs format (these spec/plan docs changed): `cd docs && npm run format` +- [ ] **Step 6:** Manual: with `[debug].auction_html_comment` off, load a nav page; confirm the winning creative renders **without** a request to `hb_cache_host` (Network tab) and GAM still received `hb_pb`. +- [ ] **Step 7: Commit** any format fixes. --- From 6649875643b5c70b2cf218fbf477a9fa9eaae47a Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 13 Jul 2026 22:53:35 +0530 Subject: [PATCH 16/44] Always include adm in bid map; gate only debug_bid blob build_bid_map now always inserts the winning creative as adm so the pbRender bridge can render it locally (no PBS Cache round trip); the verbose debug_bid blob and the GAM-bypass gate stay behind inject_adm_for_testing. Rename the param include_adm -> include_debug_bid and thread it through write_bids_to_state. Reconcile the by-default test to the new behavior, drop the now-redundant debug-only-adm test, and pin script-context escaping for a hostile adm ( + U+2028/U+2029). --- crates/trusted-server-core/src/publisher.rs | 66 ++++++++++++--------- 1 file changed, 37 insertions(+), 29 deletions(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 6b0ea3a5..f32d3289 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -843,14 +843,14 @@ pub(crate) fn write_bids_to_state( winning_bids: &std::collections::HashMap, price_granularity: PriceGranularity, ad_bids_state: &Arc>>, - inject_adm: bool, + include_debug_bid: bool, ) { log::debug!( "write_bids_to_state: {} winning bid(s): [{}]", winning_bids.len(), winning_bids.keys().cloned().collect::>().join(", ") ); - let bid_map = build_bid_map(winning_bids, price_granularity, inject_adm); + let bid_map = build_bid_map(winning_bids, price_granularity, include_debug_bid); let bids_script = build_bids_script(&bid_map); *ad_bids_state.lock().expect("should lock bid state") = Some(bids_script); } @@ -1933,7 +1933,7 @@ fn html_escape_for_script(s: &str) -> String { pub(crate) fn build_bid_map( winning_bids: &std::collections::HashMap, granularity: crate::price_bucket::PriceGranularity, - include_adm: bool, + include_debug_bid: bool, ) -> serde_json::Map { winning_bids .iter() @@ -1978,12 +1978,16 @@ pub(crate) fn build_bid_map( if let Some(ref burl) = bid.burl { obj.insert("burl".to_string(), serde_json::Value::String(burl.clone())); } - // Include raw creative markup only for explicit debug injection. - // The pbRender bridge can use it while PBS Cache is unavailable. - if include_adm { - if let Some(ref adm) = bid.creative { - obj.insert("adm".to_string(), serde_json::Value::String(adm.clone())); - } + // Always include the winning creative so the pbRender bridge can + // render it locally when GAM serves the Prebid Universal Creative + // — no PBS Cache round trip. The `hb_cache_*` coordinates above + // remain as the fallback for an absent `adm`. + if let Some(ref adm) = bid.creative { + obj.insert("adm".to_string(), serde_json::Value::String(adm.clone())); + } + // Verbose per-bid debug blob only under the testing flag; also + // doubles as the client-side gate for the direct GAM-replace path. + if include_debug_bid { obj.insert( "debug_bid".to_string(), serde_json::json!({ @@ -4071,7 +4075,7 @@ mod tests { } #[test] - fn client_bid_map_omits_adm_by_default() { + fn client_bid_map_includes_adm_and_omits_debug_bid_by_default() { let mut winning_bids = HashMap::new(); let mut bid = make_bid( "atf_sidebar_ad", @@ -4084,6 +4088,9 @@ mod tests { bid.creative = Some("
Creative
".to_string()); winning_bids.insert("atf_sidebar_ad".to_string(), bid); + // Production path (include_debug_bid = false): the creative is always + // included so the bridge can render it locally, but the verbose + // debug_bid blob is not. let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); let obj = map .get("atf_sidebar_ad") @@ -4091,41 +4098,42 @@ mod tests { .as_object() .expect("should be object"); - assert!( - obj.get("adm").is_none(), - "should omit adm when debug injection is disabled" + assert_eq!( + obj.get("adm").and_then(|v| v.as_str()), + Some("
Creative
"), + "should include creative markup for local rendering by default" ); assert!( obj.get("debug_bid").is_none(), - "should omit debug bid when debug injection is disabled" + "should omit the debug_bid blob when debug injection is disabled" ); } #[test] - fn client_bid_map_includes_adm_when_debug_injection_enabled() { + fn build_bids_script_escapes_hostile_adm() { let mut winning_bids = HashMap::new(); let mut bid = make_bid( - "atf_sidebar_ad", + "s", 1.50, "kargo", "abc123", "https://ssp/win", "https://ssp/bill", ); - bid.creative = Some("
Creative
".to_string()); - winning_bids.insert("atf_sidebar_ad".to_string(), bid); - - let map = build_bid_map(&winning_bids, PriceGranularity::Dense, true); - let obj = map - .get("atf_sidebar_ad") - .expect("should have bid entry") - .as_object() - .expect("should be object"); + // A hostile creative that tries to break out of the \u{2028}\u{2029}".to_string()); + winning_bids.insert("s".to_string(), bid); - assert_eq!( - obj.get("adm").and_then(|v| v.as_str()), - Some("
Creative
"), - "should include adm when debug injection is enabled" + let map = build_bid_map(&winning_bids, PriceGranularity::Dense, false); + let script = build_bids_script(&map); + assert!( + !script.contains("` + `U+2028` adm is neutralized in the emitted script. @@ -136,10 +138,11 @@ Run: `cargo test-fastly build_bids_script_escapes_hostile_adm` ## Task 3: Gate the GAM-bypass (`injectAdmIntoSlot`) on `bid.debug_bid` **Files:** + - Modify: `crates/trusted-server-js/lib/src/integrations/gpt/index.ts:~599` - Test: `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts` -- [ ] **Step 1: Write failing vitest — observable behavior (not a spy).** `injectAdmIntoSlot` is module-private, so assert its *effect* on the DOM: +- [ ] **Step 1: Write failing vitest — observable behavior (not a spy).** `injectAdmIntoSlot` is module-private, so assert its _effect_ on the DOM: ```ts // Setup: @@ -159,7 +162,7 @@ Run: `cargo test-fastly build_bids_script_escapes_hostile_adm` // when inject_adm_for_testing is on, so it doubles as the per-bid gate — no // global flag needed, and it is correct across SPA auction responses. if (bid.adm && bid.debug_bid) { - injectAdmIntoSlot(divId, bid.adm); + injectAdmIntoSlot(divId, bid.adm) } ``` @@ -174,6 +177,7 @@ if (bid.adm && bid.debug_bid) { ## Task 4: Reconcile existing bridge tests (no duplicates) **Files:** + - Modify: `crates/trusted-server-js/lib/test/integrations/gpt/ad_init.test.ts` `ad_init.test.ts` already covers: PBS Cache fetch when `adm` absent; local `adm` @@ -208,7 +212,8 @@ concurrency + beacon dedup. Do **not** duplicate them. --- ## Notes -- Do NOT remove `hb_cache_host`/`hb_cache_path` — they are the fallback for an **absent** `adm`. Render failure *after* `adm` is supplied is not detectable and does not fall back (spec Risks). + +- Do NOT remove `hb_cache_host`/`hb_cache_path` — they are the fallback for an **absent** `adm`. Render failure _after_ `adm` is supplied is not detectable and does not fall back (spec Risks). - Do NOT ship the `debug_bid` blob in production (Task 1 keeps it behind the flag). - No global `window.tsjs` flag, no `TsjsApi` change — the bypass gate is the per-bid `debug_bid`. - Page-weight cost (inline creatives, uncacheable response) accepted per spec; size-capping out of scope. diff --git a/docs/superpowers/specs/2026-07-13-ssat-render-inline-creative-design.md b/docs/superpowers/specs/2026-07-13-ssat-render-inline-creative-design.md index 8c826d63..8dee3f45 100644 --- a/docs/superpowers/specs/2026-07-13-ssat-render-inline-creative-design.md +++ b/docs/superpowers/specs/2026-07-13-ssat-render-inline-creative-design.md @@ -14,7 +14,7 @@ time from PBS Cache: https://?uuid= ``` -This is an extra network round trip *after* the GAM call, even though +This is an extra network round trip _after_ the GAM call, even though trusted-server already holds the winning creative markup (`bid.creative`) from the server-side auction it just ran. The client-side `/auction` flow never does this — Prebid.js renders the winner from the copy it already has in the browser. @@ -27,7 +27,7 @@ while keeping GAM in the loop (the header bid still competes against GAM's own demand via `hb_pb`). Non-goal: bypassing GAM. SSAT winners must still compete in GAM; we only remove -the round trip that happens *after* GAM has already picked the TS line item. +the round trip that happens _after_ GAM has already picked the TS line item. ## Current flow (verified in code) @@ -47,7 +47,7 @@ the round trip that happens *after* GAM has already picked the TS line item. - **fetches from PBS Cache** using `hb_cache_host`/`hb_cache_path` (the round trip we want to remove). 5. A separate consumer, `injectAdmIntoSlot` ([gpt/index.ts:599]), fires on - `if (bid.adm)` and **replaces the GAM creative directly** — a GAM *bypass*. + `if (bid.adm)` and **replaces the GAM creative directly** — a GAM _bypass_. Its "testing only" status is a comment, not an actual gate. ## Design @@ -55,6 +55,7 @@ the round trip that happens *after* GAM has already picked the TS line item. ### 1. Always include the render `adm`; keep `debug_bid` gated `build_bid_map`: + - **Always** insert `adm` (from `bid.creative`) for a winner when present — there is no runtime reason to withhold it, so it is not parameterized. - Insert the verbose `debug_bid` blob **only** when the testing flag is set. The @@ -62,7 +63,7 @@ the round trip that happens *after* GAM has already picked the TS line item. `hb_cache_host`/`hb_cache_path` remain inserted unconditionally. -### 2. Bridge renders local `adm`; cache is the fallback for an *absent* `adm` +### 2. Bridge renders local `adm`; cache is the fallback for an _absent_ `adm` `installTsRenderBridge` already prefers `matchedBid.adm` and falls back to PBS Cache. Once `adm` is present in production, the local render becomes the default @@ -70,7 +71,7 @@ and the round trip disappears. **Fallback scope (corrected):** the bridge posts the markup to the PUC and returns; it receives **no render-success signal**. So the PBS Cache fallback -fires only when `adm` is **absent or empty** — *not* when `adm` is present but +fires only when `adm` is **absent or empty** — _not_ when `adm` is present but fails to render. Render failures after `adm` is supplied are not currently detectable and do not trigger fallback. @@ -84,7 +85,7 @@ the bypass on the per-bid `debug_bid` field, which is already present **iff** ```ts if (bid.adm && bid.debug_bid) { - injectAdmIntoSlot(divId, bid.adm); + injectAdmIntoSlot(divId, bid.adm) } ``` @@ -106,12 +107,12 @@ Universal Creative implementation, not on TS. ## Components changed -| Unit | Change | -| --- | --- | -| `build_bid_map` (Rust) | Always insert `adm` when `bid.creative` is `Some`. Rename `include_adm` → `include_debug_bid`, gating only the `debug_bid` blob. | -| `build_bid_map` callers | Pass `include_debug_bid = inject_adm_for_testing`. | -| `gpt/index.ts` `injectAdmIntoSlot` call site | Gate on `bid.adm && bid.debug_bid`. | -| bridge/`ad_init` tests (JS) | Rename "debug adm" → "inline/local adm"; confirm existing coverage. | +| Unit | Change | +| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `build_bid_map` (Rust) | Always insert `adm` when `bid.creative` is `Some`. Rename `include_adm` → `include_debug_bid`, gating only the `debug_bid` blob. | +| `build_bid_map` callers | Pass `include_debug_bid = inject_adm_for_testing`. | +| `gpt/index.ts` `injectAdmIntoSlot` call site | Gate on `bid.adm && bid.debug_bid`. | +| bridge/`ad_init` tests (JS) | Rename "debug adm" → "inline/local adm"; confirm existing coverage. | No `build_bids_script` change, no `window.tsjs` flag, no `TsjsApi` change. @@ -129,7 +130,7 @@ SSAT auction → winner (bid.creative held) → build_bid_map inserts adm ## Precondition -This changes only the render bridge's *data source* — local `adm` vs a PBS Cache +This changes only the render bridge's _data source_ — local `adm` vs a PBS Cache fetch — **when GAM's Prebid line item already serves the PUC**. It does not change GAM competition, nor whether the PUC fires. A publisher without Prebid line items in GAM sees no behavioral change. From c8ae53f78014458241753f53db630420ff119132 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 14 Jul 2026 15:49:42 +0530 Subject: [PATCH 19/44] Harden Fastly publisher streaming against review findings Resolve five correctness and resource-safety findings from the PR #867 review of the end-to-end Fastly publisher streaming path. - Drive the deflate decoder to StreamEnd at finalization so a valid stream that exactly fills the internal output buffer is no longer rejected as truncated; the inflater is also drained after all input is consumed within a chunk. - Decode concatenated (multi-member) gzip bodies via MultiGzDecoder on both the streaming decoder and the buffered read pipeline so adapters agree. - Enforce the decoded-body cap during decompression through a bounded sink shared by the gzip and brotli codecs, so a compression bomb errors before its expanded bytes are buffered instead of after a full chunk expands; the deflate codec charges each produced block as it is emitted. - Drop the body of bodiless responses (HEAD, 204, 205, 304) in both the streaming and buffered finalizer Buffered arms, and add RESET_CONTENT to response_carries_body, so a buffered-unmodified stream body is never streamed to the client for a response that must be bodiless. - Keep the dispatched-auction guard armed across the collection await and disarm it only once collection reaches a terminal result, so a body dropped while collection is pending still logs the discarded SSP work. Add regression tests for the deflate output-buffer boundary, multi-member gzip, bodiless buffered stream bodies, and the auction guard sentinel. --- crates/trusted-server-core/src/publisher.rs | 166 +++++++- .../src/streaming_processor.rs | 391 +++++++++++++++--- 2 files changed, 484 insertions(+), 73 deletions(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 4d7e38cf..3f916020 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -612,23 +612,40 @@ fn passthrough_finish_segments( /// error paths that can still await (see [`abandon_hold_auction`]). struct DispatchedAuctionGuard { dispatched: Option, + /// Stays `true` from dispatch until collection (or telemetry-emitting + /// abandonment) reaches a terminal result. [`Self::take`] removes the + /// dispatched auction to hand it to the async collector but deliberately + /// leaves the guard armed, so a drop *while collection is still pending* — + /// a client disconnect at the collection await point — still logs the + /// loss. [`Self::disarm`] clears it only once collection has completed. + armed: bool, } impl DispatchedAuctionGuard { fn new(dispatched: DispatchedAuction) -> Self { Self { dispatched: Some(dispatched), + armed: true, } } + /// Remove the dispatched auction to begin collection. The guard stays armed + /// until [`Self::disarm`] is called, so a drop before collection reaches a + /// terminal result is still reported. fn take(&mut self) -> Option { self.dispatched.take() } + + /// Disarm the drop warning once collection (or telemetry-emitting + /// abandonment) has reached a terminal result. + fn disarm(&mut self) { + self.armed = false; + } } impl Drop for DispatchedAuctionGuard { fn drop(&mut self) { - if self.dispatched.is_some() { + if self.armed { log::warn!( "Dispatched server-side auction dropped without collection; SSP bid responses discarded (publisher body stream aborted or never polled)" ); @@ -668,6 +685,10 @@ async fn abandon_hold_auction( reason, ) .await; + // Abandonment with telemetry is a terminal result, so the drop warning + // is no longer warranted. (A drop *during* the emit above still fires + // it, since the guard stays armed until here.) + state.dispatched.disarm(); } } @@ -721,6 +742,9 @@ async fn hold_step_decoded_chunk( collect_refs.settings, ) .await; + // Collection reached a terminal result; disarm only now so a drop + // while the collect await above was still pending is reported. + state.dispatched.disarm(); let held = state .hold @@ -835,6 +859,9 @@ async fn hold_finish_segments( collect_refs.settings, ) .await; + // Collection reached a terminal result; disarm only now so a drop while + // the collect await above was still pending is reported. + state.dispatched.disarm(); let held = hold.finish(); if let Some(encoded) = process_and_encode_chunk( @@ -1046,7 +1073,17 @@ pub async fn buffer_publisher_response_async( services: &RuntimeServices, ) -> Result, Report> { match publisher_response { - PublisherResponse::Buffered(response) => Ok(response), + PublisherResponse::Buffered(mut response) => { + // A buffered-unmodified response can carry an origin body (a stream + // on streaming-capable adapters). A bodiless response (HEAD, 204, + // 205, 304) must stay bodiless, so drop the body while preserving + // metadata such as `Content-Length`, matching the streaming + // finalizer. + if !response_carries_body(method, response.status()) { + *response.body_mut() = EdgeBody::empty(); + } + Ok(response) + } PublisherResponse::Stream { mut response, body, @@ -1113,7 +1150,18 @@ pub async fn publisher_response_into_streaming_response( services: RuntimeServices, ) -> Result, Report> { match publisher_response { - PublisherResponse::Buffered(response) => Ok(response), + PublisherResponse::Buffered(mut response) => { + // Fastly requests the origin body as a stream before the response is + // classified, so a buffered-unmodified response can still hold an + // `EdgeBody::Stream`. A bodiless response (HEAD, 204, 205, 304) must + // stay bodiless — `send_edgezero_response` streams any + // `EdgeBody::Stream` to the client — so drop the body while + // preserving metadata such as `Content-Length`. + if !response_carries_body(method, response.status()) { + *response.body_mut() = EdgeBody::empty(); + } + Ok(response) + } PublisherResponse::PassThrough { mut response, body } => { if response_carries_body(method, response.status()) { *response.body_mut() = body; @@ -1196,6 +1244,10 @@ pub async fn publisher_response_into_streaming_response( &settings, ) .await; + // Collection reached a terminal result; disarm only now + // so a drop while the collect await above was still + // pending is reported. + guard.disarm(); } } @@ -1268,12 +1320,15 @@ pub async fn publisher_response_into_streaming_response( /// Returns `true` when a buffered publisher response should carry a body and a /// recomputed `Content-Length`. /// -/// `HEAD` responses and bodiless statuses (204, 304) carry no body; rewriting -/// their `Content-Length` to the (empty) buffered length would mislead clients -/// and caches, so the origin metadata is preserved instead. +/// `HEAD` responses and bodiless statuses (204, 205, 304) carry no body; +/// rewriting their `Content-Length` to the (empty) buffered length — or +/// streaming an origin body for them at all — would mislead clients and caches +/// and violate HTTP framing, so the origin metadata is preserved and the body +/// is dropped instead. fn response_carries_body(method: &Method, status: StatusCode) -> bool { *method != Method::HEAD && status != StatusCode::NO_CONTENT + && status != StatusCode::RESET_CONTENT && status != StatusCode::NOT_MODIFIED } @@ -3755,12 +3810,44 @@ mod tests { !super::response_carries_body(&Method::GET, StatusCode::NO_CONTENT), "204 responses must not get a recomputed Content-Length" ); + assert!( + !super::response_carries_body(&Method::GET, StatusCode::RESET_CONTENT), + "205 responses must not get a recomputed Content-Length" + ); assert!( !super::response_carries_body(&Method::GET, StatusCode::NOT_MODIFIED), "304 responses must not get a recomputed Content-Length" ); } + #[test] + fn dispatched_auction_guard_stays_armed_until_collection_completes() { + // `take()` hands the dispatched auction to the async collector, but the + // guard must stay armed across the collection await so a drop while + // collection is still pending (a client disconnect at the await point) + // still logs the loss. Only `disarm()` — called once collection reaches + // a terminal result — clears the warning. + let mut guard = DispatchedAuctionGuard::new(DispatchedAuction::empty_for_test( + test_auction_request(), + 10, + )); + assert!(guard.armed, "a freshly dispatched guard should be armed"); + + let _dispatched = guard + .take() + .expect("guard should yield the dispatched auction for collection"); + assert!( + guard.armed, + "guard must stay armed across the collection await so a drop mid-collection is reported" + ); + + guard.disarm(); + assert!( + !guard.armed, + "guard must disarm once collection reaches a terminal result" + ); + } + fn response_body_string(response: http::Response) -> String { String::from_utf8( response @@ -5354,6 +5441,73 @@ mod tests { ); } + #[test] + fn publisher_response_streaming_finalize_drops_bodiless_buffered_stream_body() { + // Fastly requests the origin body as a stream before classification, so + // a buffered-unmodified response can hold an `EdgeBody::Stream`. The + // adapter streams any `EdgeBody::Stream` to the client, so bodiless + // responses must be normalized to carry no body while keeping metadata. + let settings = Arc::new(create_test_settings()); + let registry = Arc::new( + IntegrationRegistry::new(&settings).expect("should create integration registry"), + ); + let orchestrator = Arc::new(AuctionOrchestrator::new(settings.auction.clone())); + + let cases = [ + (Method::HEAD, StatusCode::OK), + (Method::GET, StatusCode::NO_CONTENT), + (Method::GET, StatusCode::RESET_CONTENT), + (Method::GET, StatusCode::NOT_MODIFIED), + ]; + + for (method, status) in cases { + let response = Response::builder() + .status(status) + .header(header::CONTENT_LENGTH, "42") + .body(EdgeBody::stream(futures::stream::iter(vec![ + bytes::Bytes::from_static(b"origin body bytes that must not reach the client"), + ]))) + .expect("should build response"); + let publisher_response = PublisherResponse::Buffered(response); + + let response = futures::executor::block_on(publisher_response_into_streaming_response( + publisher_response, + &method, + Arc::clone(&settings), + registry.as_ref(), + Arc::clone(&orchestrator), + noop_services(), + )) + .expect("should finalize buffered response"); + + assert!( + !matches!(response.body(), EdgeBody::Stream(_)), + "bodiless {method} {status} must not carry a streaming body" + ); + assert_eq!( + response + .headers() + .get(header::CONTENT_LENGTH) + .and_then(|v| v.to_str().ok()), + Some("42"), + "bodiless {method} {status} must preserve the origin Content-Length" + ); + + let drained = futures::executor::block_on( + response + .into_body() + .into_bytes_bounded(settings.publisher.max_buffered_body_bytes), + ) + .expect("body should drain") + .to_vec(); + assert!( + drained.is_empty(), + "bodiless {method} {status} must deliver zero body bytes, got {} bytes", + drained.len() + ); + } + } + #[test] fn publisher_response_streaming_finalize_processes_gzip_stream() { let compressed = diff --git a/crates/trusted-server-core/src/streaming_processor.rs b/crates/trusted-server-core/src/streaming_processor.rs index 963c69da..e7e9a92b 100644 --- a/crates/trusted-server-core/src/streaming_processor.rs +++ b/crates/trusted-server-core/src/streaming_processor.rs @@ -19,7 +19,7 @@ //! streaming interface. See `crate::platform` module doc for the //! authoritative note. -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::io::{self, Read, Write}; use std::rc::Rc; @@ -27,7 +27,7 @@ use brotli::enc::writer::CompressorWriter; use brotli::enc::BrotliEncoderParams; use brotli::Decompressor; use error_stack::{Report, ResultExt as _}; -use flate2::read::{GzDecoder, ZlibDecoder}; +use flate2::read::{MultiGzDecoder, ZlibDecoder}; use flate2::write::{GzEncoder, ZlibEncoder}; use crate::error::TrustedServerError; @@ -144,7 +144,10 @@ impl StreamingPipeline

{ ) { (Compression::None, Compression::None) => self.process_chunks(input, output), (Compression::Gzip, Compression::Gzip) => { - let decoder = GzDecoder::new(input); + // Multi-member decoder: RFC 1952 permits concatenated gzip + // members, so a single-member reader would stop after the first. + // Matches the streaming `BodyStreamDecoder` gzip codec. + let decoder = MultiGzDecoder::new(input); let mut encoder = GzEncoder::new(output, flate2::Compression::default()); self.process_chunks(decoder, &mut encoder)?; encoder.finish().change_context(TrustedServerError::Proxy { @@ -153,7 +156,7 @@ impl StreamingPipeline

{ Ok(()) } (Compression::Gzip, Compression::None) => { - self.process_chunks(GzDecoder::new(input), output) + self.process_chunks(MultiGzDecoder::new(input), output) } (Compression::Deflate, Compression::Deflate) => { let decoder = ZlibDecoder::new(input); @@ -360,27 +363,106 @@ pub(crate) const STREAM_CHUNK_SIZE: usize = 8192; /// out of the internal buffer after every push. Write-based decoders are /// used because the async publisher path cannot wrap a blocking `Read`. /// -/// Decoded output is capped cumulatively: the chunk source only bounds raw -/// (still compressed) bytes, and a decompression bomb can expand ~1000x past -/// that, so the decoder enforces its own ceiling on the total bytes it emits. +/// Decoded output is capped cumulatively and the cap is enforced *during* +/// decompression, not after: the chunk source only bounds raw (still +/// compressed) bytes, and a decompression bomb can expand ~1000x past that, so +/// a small compressed chunk must not be allowed to fully expand before the +/// ceiling is checked. The gzip and brotli codecs decode into a +/// [`BoundedDecodeSink`] that errors the moment a write would exceed the limit; +/// the deflate codec charges each produced output block as it is emitted. /// /// Every codec validates end-of-stream at [`Self::finish`] so a truncated /// origin body errors instead of silently truncating the page: gzip via its -/// trailer checksum, brotli via `close()`, and deflate via an explicit -/// [`flate2::Status::StreamEnd`] check (`write::ZlibDecoder` accepts -/// truncated input silently, so the deflate arm drives [`flate2::Decompress`] -/// directly). +/// trailer checksum, brotli via `close()`, and deflate by driving +/// [`flate2::Decompress`] to its [`flate2::Status::StreamEnd`] marker (the +/// `write`-based zlib decoder accepts truncated input silently, so the deflate +/// arm drives [`flate2::Decompress`] directly). Concatenated gzip members +/// (RFC 1952) are decoded via [`flate2::write::MultiGzDecoder`]. pub(crate) struct BodyStreamDecoder { codec: BodyStreamDecoderCodec, - decoded_bytes: usize, + /// Cumulative decoded byte count, shared with the codec sinks so the cap is + /// enforced from inside the decompressor writes rather than after them. + decoded_bytes: Rc>, max_decoded_bytes: usize, } enum BodyStreamDecoderCodec { None, - Gzip(flate2::write::GzDecoder>), + Gzip(flate2::write::MultiGzDecoder), Deflate(DeflateStreamDecoder), - Brotli(Box>>), + Brotli(Box>), +} + +/// A [`Write`] sink that buffers decoded bytes while enforcing a shared +/// cumulative decode budget. +/// +/// The gzip and brotli decoders write their decompressed output here as they +/// process input. Rejecting the write as soon as it would push the cumulative +/// decoded total past `max_decoded_bytes` makes the cap a hard ceiling on +/// Wasm-heap growth: a decompression bomb errors before its expanded bytes are +/// buffered, rather than after a full chunk has already expanded. +struct BoundedDecodeSink { + buffer: Vec, + decoded_bytes: Rc>, + max_decoded_bytes: usize, +} + +impl BoundedDecodeSink { + fn new(decoded_bytes: Rc>, max_decoded_bytes: usize) -> Self { + Self { + buffer: Vec::new(), + decoded_bytes, + max_decoded_bytes, + } + } +} + +impl Write for BoundedDecodeSink { + fn write(&mut self, data: &[u8]) -> io::Result { + let next = self + .decoded_bytes + .get() + .checked_add(data.len()) + .ok_or_else(|| { + io::Error::other("publisher origin body decoded byte count overflowed") + })?; + if next > self.max_decoded_bytes { + return Err(io::Error::other(format!( + "publisher origin body decoded size exceeded {}-byte streaming limit", + self.max_decoded_bytes + ))); + } + self.decoded_bytes.set(next); + self.buffer.extend_from_slice(data); + Ok(data.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +/// Charge `len` decoded bytes against `decoded_bytes`, erroring if the +/// cumulative total would exceed `max_decoded_bytes`. +fn charge_decoded( + decoded_bytes: &Cell, + max_decoded_bytes: usize, + len: usize, +) -> Result<(), Report> { + let next = decoded_bytes.get().checked_add(len).ok_or_else(|| { + Report::new(TrustedServerError::Proxy { + message: "publisher origin body decoded byte count overflowed".to_string(), + }) + })?; + if next > max_decoded_bytes { + return Err(Report::new(TrustedServerError::Proxy { + message: format!( + "publisher origin body decoded size exceeded {max_decoded_bytes}-byte streaming limit" + ), + })); + } + decoded_bytes.set(next); + Ok(()) } /// Streaming zlib decoder that tracks whether the stream reached its end @@ -388,22 +470,40 @@ enum BodyStreamDecoderCodec { struct DeflateStreamDecoder { decompress: flate2::Decompress, stream_ended: bool, + decoded_bytes: Rc>, + max_decoded_bytes: usize, } impl DeflateStreamDecoder { - fn new() -> Self { + fn new(decoded_bytes: Rc>, max_decoded_bytes: usize) -> Self { Self { decompress: flate2::Decompress::new(true), stream_ended: false, + decoded_bytes, + max_decoded_bytes, } } + /// Charge `len` decoded bytes against the shared budget. + fn charge(&self, len: usize) -> Result<(), Report> { + charge_decoded(&self.decoded_bytes, self.max_decoded_bytes, len) + } + + /// Decode as much of `chunk` as possible, draining any output the inflater + /// can still produce once all input is consumed. + /// + /// flate2 fills the output buffer up to its capacity, so a chunk that + /// exactly fills the buffer leaves decoded bytes (and possibly the + /// end-of-stream marker) pending with all input already consumed. The loop + /// keeps driving the inflater — reserving more output space — until it + /// makes no further progress, so those pending bytes are never stranded and + /// a valid stream is not mistaken for a truncated one at `finish`. fn decode(&mut self, chunk: &[u8]) -> Result, Report> { let mut output = Vec::with_capacity(STREAM_CHUNK_SIZE); let mut offset = 0usize; // Trailing bytes after the zlib end marker are ignored, matching the // read-based decoder used by the buffered pipeline. - while offset < chunk.len() && !self.stream_ended { + while !self.stream_ended { if output.len() == output.capacity() { output.reserve(STREAM_CHUNK_SIZE); } @@ -418,36 +518,85 @@ impl DeflateStreamDecoder { let consumed = (self.decompress.total_in() - before_in) as usize; let produced = (self.decompress.total_out() - before_out) as usize; offset += consumed; + self.charge(produced)?; match status { flate2::Status::StreamEnd => self.stream_ended = true, flate2::Status::Ok | flate2::Status::BufError => { + // Stop only when the inflater is starved for input: it made + // no progress and there is still spare output capacity, so + // the stall is missing input (arriving in a later chunk, or + // resolved at `finish`), not an exhausted output buffer. if consumed == 0 && produced == 0 && output.len() < output.capacity() { - return Err(Report::new(TrustedServerError::Proxy { - message: "deflate publisher body decoder made no progress".to_string(), - })); + break; } } } } Ok(output) } + + /// Drive the inflater to completion at end of input, draining the final + /// decoded bytes and validating the end-of-stream marker. + /// + /// A valid stream whose last decoded byte exactly filled the previous + /// output buffer still has its end marker pending here; a genuinely + /// truncated stream makes no further progress and errors. + fn finish(&mut self) -> Result, Report> { + let mut output = Vec::new(); + while !self.stream_ended { + if output.len() == output.capacity() { + output.reserve(STREAM_CHUNK_SIZE); + } + let before_out = self.decompress.total_out(); + let status = self + .decompress + .decompress_vec(&[], &mut output, flate2::FlushDecompress::Finish) + .change_context(TrustedServerError::Proxy { + message: "Failed to finalize deflate publisher body decoder".to_string(), + })?; + let produced = (self.decompress.total_out() - before_out) as usize; + self.charge(produced)?; + match status { + flate2::Status::StreamEnd => self.stream_ended = true, + flate2::Status::Ok | flate2::Status::BufError => { + if produced == 0 { + break; + } + } + } + } + if !self.stream_ended { + return Err(Report::new(TrustedServerError::Proxy { + message: "Failed to finalize deflate publisher body decoder: truncated stream" + .to_string(), + })); + } + Ok(output) + } } impl BodyStreamDecoder { pub(crate) fn new(compression: Compression, max_decoded_bytes: usize) -> Self { + let decoded_bytes = Rc::new(Cell::new(0usize)); let codec = match compression { Compression::None => BodyStreamDecoderCodec::None, - Compression::Gzip => { - BodyStreamDecoderCodec::Gzip(flate2::write::GzDecoder::new(Vec::new())) - } - Compression::Deflate => BodyStreamDecoderCodec::Deflate(DeflateStreamDecoder::new()), - Compression::Brotli => BodyStreamDecoderCodec::Brotli(Box::new( - brotli::DecompressorWriter::new(Vec::new(), STREAM_CHUNK_SIZE), + Compression::Gzip => BodyStreamDecoderCodec::Gzip(flate2::write::MultiGzDecoder::new( + BoundedDecodeSink::new(Rc::clone(&decoded_bytes), max_decoded_bytes), + )), + Compression::Deflate => BodyStreamDecoderCodec::Deflate(DeflateStreamDecoder::new( + Rc::clone(&decoded_bytes), + max_decoded_bytes, )), + Compression::Brotli => { + BodyStreamDecoderCodec::Brotli(Box::new(brotli::DecompressorWriter::new( + BoundedDecodeSink::new(Rc::clone(&decoded_bytes), max_decoded_bytes), + STREAM_CHUNK_SIZE, + ))) + } }; Self { codec, - decoded_bytes: 0, + decoded_bytes, max_decoded_bytes, } } @@ -456,51 +605,52 @@ impl BodyStreamDecoder { &mut self, chunk: bytes::Bytes, ) -> Result> { - let decoded = match &mut self.codec { - BodyStreamDecoderCodec::None => chunk, + match &mut self.codec { + BodyStreamDecoderCodec::None => { + // No sink guards the pass-through path, so charge the raw chunk + // directly against the shared budget. + charge_decoded(&self.decoded_bytes, self.max_decoded_bytes, chunk.len())?; + Ok(chunk) + } BodyStreamDecoderCodec::Gzip(decoder) => { decoder .write_all(&chunk) .change_context(TrustedServerError::Proxy { message: "Failed to decode gzip publisher body chunk".to_string(), })?; - bytes::Bytes::from(std::mem::take(decoder.get_mut())) + // The sink charged the decoded bytes during `write_all`. + Ok(bytes::Bytes::from(std::mem::take( + &mut decoder.get_mut().buffer, + ))) + } + BodyStreamDecoderCodec::Deflate(decoder) => { + Ok(bytes::Bytes::from(decoder.decode(&chunk)?)) } - BodyStreamDecoderCodec::Deflate(decoder) => bytes::Bytes::from(decoder.decode(&chunk)?), BodyStreamDecoderCodec::Brotli(decoder) => { decoder .write_all(&chunk) .change_context(TrustedServerError::Proxy { message: "Failed to decode brotli publisher body chunk".to_string(), })?; - bytes::Bytes::from(std::mem::take(decoder.get_mut())) + Ok(bytes::Bytes::from(std::mem::take( + &mut decoder.get_mut().buffer, + ))) } - }; - self.track_decoded(decoded.len())?; - Ok(decoded) + } } pub(crate) fn finish(&mut self) -> Result, Report> { - let tail = match &mut self.codec { - BodyStreamDecoderCodec::None => Vec::new(), + match &mut self.codec { + BodyStreamDecoderCodec::None => Ok(Vec::new()), BodyStreamDecoderCodec::Gzip(decoder) => { decoder .try_finish() .change_context(TrustedServerError::Proxy { message: "Failed to finalize gzip publisher body decoder".to_string(), })?; - std::mem::take(decoder.get_mut()) - } - BodyStreamDecoderCodec::Deflate(decoder) => { - if !decoder.stream_ended { - return Err(Report::new(TrustedServerError::Proxy { - message: - "Failed to finalize deflate publisher body decoder: truncated stream" - .to_string(), - })); - } - Vec::new() + Ok(std::mem::take(&mut decoder.get_mut().buffer)) } + BodyStreamDecoderCodec::Deflate(decoder) => decoder.finish(), BodyStreamDecoderCodec::Brotli(decoder) => { // `close()` (not `flush()`): flush accepts a truncated brotli // stream silently, while close validates end-of-stream and @@ -508,28 +658,9 @@ impl BodyStreamDecoder { decoder.close().change_context(TrustedServerError::Proxy { message: "Failed to finalize brotli publisher body decoder".to_string(), })?; - std::mem::take(decoder.get_mut()) + Ok(std::mem::take(&mut decoder.get_mut().buffer)) } - }; - self.track_decoded(tail.len())?; - Ok(tail) - } - - fn track_decoded(&mut self, len: usize) -> Result<(), Report> { - self.decoded_bytes = self.decoded_bytes.checked_add(len).ok_or_else(|| { - Report::new(TrustedServerError::Proxy { - message: "publisher origin body decoded byte count overflowed".to_string(), - }) - })?; - if self.decoded_bytes > self.max_decoded_bytes { - return Err(Report::new(TrustedServerError::Proxy { - message: format!( - "publisher origin body decoded size exceeded {}-byte streaming limit", - self.max_decoded_bytes - ), - })); } - Ok(()) } } @@ -704,6 +835,132 @@ mod tests { ); } + #[test] + fn body_stream_decoder_decodes_deflate_filling_output_buffer_exactly() { + // A decoded length one byte past the decoder's internal output buffer + // (`STREAM_CHUNK_SIZE`) hits the boundary where flate2 consumes all + // input while exactly filling the output buffer and returns + // `Status::Ok` with the stream-end marker still pending. The decoder + // must drive the inflater to completion instead of reporting a + // truncated stream. + let payload = vec![b'a'; STREAM_CHUNK_SIZE + 1]; + let compressed = { + let mut encoder = + flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + encoder + .write_all(&payload) + .expect("should write deflate test input"); + encoder.finish().expect("should finish deflate encoding") + }; + let mut decoder = BodyStreamDecoder::new(Compression::Deflate, usize::MAX); + + let mut decoded = decoder + .decode_chunk(bytes::Bytes::from(compressed)) + .expect("complete deflate stream should decode") + .to_vec(); + decoded.extend( + decoder + .finish() + .expect("a complete deflate stream must not report truncation"), + ); + + assert_eq!( + decoded, payload, + "should decode the full payload across the output-buffer boundary" + ); + } + + #[test] + fn body_stream_decoder_decodes_deflate_split_across_many_chunks() { + let payload = vec![b'x'; STREAM_CHUNK_SIZE * 3 + 7]; + let compressed = { + let mut encoder = + flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + encoder + .write_all(&payload) + .expect("should write deflate test input"); + encoder.finish().expect("should finish deflate encoding") + }; + let mut decoder = BodyStreamDecoder::new(Compression::Deflate, usize::MAX); + + let mut decoded = Vec::new(); + // Feed the compressed stream a few bytes at a time to exercise many + // input split points, including splits inside the end-of-stream marker. + for piece in compressed.chunks(3) { + decoded.extend( + decoder + .decode_chunk(bytes::Bytes::copy_from_slice(piece)) + .expect("partial deflate input should decode incrementally"), + ); + } + decoded.extend( + decoder + .finish() + .expect("a complete deflate stream must finalize"), + ); + + assert_eq!( + decoded, payload, + "should decode the full payload regardless of input split points" + ); + } + + fn gzip_member(data: &[u8]) -> Vec { + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder + .write_all(data) + .expect("should write gzip test input"); + encoder.finish().expect("should finish gzip encoding") + } + + #[test] + fn body_stream_decoder_decodes_multi_member_gzip_single_chunk() { + let mut compressed = gzip_member(b"first member "); + compressed.extend(gzip_member(b"second member")); + let mut decoder = BodyStreamDecoder::new(Compression::Gzip, usize::MAX); + + let mut decoded = decoder + .decode_chunk(bytes::Bytes::from(compressed)) + .expect("a multi-member gzip body must decode all members") + .to_vec(); + decoded.extend( + decoder + .finish() + .expect("a multi-member gzip body must finalize"), + ); + + assert_eq!( + decoded, b"first member second member", + "should concatenate the decoded output of every gzip member" + ); + } + + #[test] + fn body_stream_decoder_decodes_multi_member_gzip_split_across_chunks() { + let mut compressed = gzip_member(b"alpha"); + compressed.extend(gzip_member(b"omega")); + let mut decoder = BodyStreamDecoder::new(Compression::Gzip, usize::MAX); + + let mut decoded = Vec::new(); + for piece in compressed.chunks(4) { + decoded.extend( + decoder + .decode_chunk(bytes::Bytes::copy_from_slice(piece)) + .expect("multi-member gzip should decode across chunk boundaries"), + ); + } + decoded.extend( + decoder + .finish() + .expect("a multi-member gzip body must finalize"), + ); + + assert_eq!( + decoded, b"alphaomega", + "should decode both gzip members split across chunk boundaries" + ); + } + /// Verify that `lol_html` fragments text nodes when input chunks split /// mid-text-node. Script rewriters must be fragment-safe — they accumulate /// text fragments internally until `is_last_in_text_node` is true. From 5512d8507cbfeee9777ddbb09be2f44ba734fc86 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 14 Jul 2026 18:10:20 +0530 Subject: [PATCH 20/44] Address auction transport-timeout review findings Resolve the PR review by making transport-timeout canonicalization a platform capability and hardening auction backend-name correlation. - Move quantization behind PlatformBackend::canonicalize_transport_timeout_ms. Fastly floors budget-derived timeouts to a 250ms quantum with a bounded sub-quantum ladder [200,150,100,50]; other adapters use the exact remaining budget so bidder deadlines (Prebid tmax, APS timeout) are not shortened where no connection-pooling benefit exists. - Bound sub-quantum backend-name cardinality: exact 1-249ms values no longer pass through, capping the budget-derived names a single origin can mint toward the per-service dynamic backend limit. - Add a provider discriminator to PlatformBackendSpec, folded into every adapter's backend name, so two providers sharing one origin no longer collide on the response-correlation key. Reject a duplicate backend_to_provider insertion with an attributed launch failure instead of silently overwriting and misattributing a response. - Make the orchestrator call-site tests deterministic: record predicted and registered transport timeouts separately and assert exact equality via a controllable platform backend, and enumerate the sub-quantum ladder to assert a bounded name cardinality. - Correct the timeout-semantics comments that overstated absolute-deadline enforcement; the Fastly connect/first-byte/between-bytes timeouts bound connection, first-byte, and inactivity, not total response time. A true absolute deadline carried through the platform HTTP API remains follow-up work (#849). --- .../src/platform.rs | 12 +- .../src/platform.rs | 9 +- .../src/backend.rs | 35 +- .../src/platform.rs | 200 +++++ .../src/tinybird.rs | 1 + .../src/platform.rs | 9 +- .../src/auction/orchestrator.rs | 794 ++++++++++-------- .../trusted-server-core/src/ec/pull_sync.rs | 1 + .../src/integrations/datadome/protection.rs | 1 + .../src/integrations/mod.rs | 4 + .../src/platform/test_support.rs | 28 + .../src/platform/traits.rs | 22 + .../trusted-server-core/src/platform/types.rs | 10 + crates/trusted-server-core/src/proxy.rs | 2 + crates/trusted-server-core/src/publisher.rs | 1 + 15 files changed, 772 insertions(+), 357 deletions(-) diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index 461b567d..a511daab 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -158,11 +158,19 @@ impl PlatformBackend for AxumPlatformBackend { let port = spec .port .unwrap_or(if spec.scheme == "https" { 443 } else { 80 }); + // Keep two providers that share an origin on distinct names so auction + // response correlation cannot cross providers. + let discriminator = spec + .discriminator + .as_deref() + .map(|d| format!("_p_{}", normalize_env_segment(d))) + .unwrap_or_default(); Ok(format!( - "{}_{}_{}", + "{}_{}_{}{}", normalize_env_segment(&spec.scheme), normalize_env_segment(&spec.host), port, + discriminator, )) } @@ -644,6 +652,7 @@ mod tests { first_byte_timeout: Duration::from_secs(15), between_bytes_timeout: Duration::from_secs(15), host_header_override: None, + discriminator: None, }; let name1 = backend.predict_name(&spec).expect("should return a name"); let name2 = backend @@ -664,6 +673,7 @@ mod tests { first_byte_timeout: Duration::from_secs(15), between_bytes_timeout: Duration::from_secs(15), host_header_override: None, + discriminator: None, }; assert_eq!( backend.predict_name(&spec).expect("should return name"), diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index a01c4d97..9467abb7 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -71,8 +71,15 @@ impl PlatformBackend for NoopBackend { } else { "_nocert" }; + // Keep two providers that share an origin on distinct names so auction + // response correlation cannot cross providers. + let discriminator = spec + .discriminator + .as_deref() + .map(|d| format!("_p_{d}")) + .unwrap_or_default(); Ok(format!( - "{}_{}_{}_{timeout_ms}ms{cert_suffix}", + "{}_{}_{}_{timeout_ms}ms{cert_suffix}{discriminator}", spec.scheme, spec.host, port )) } diff --git a/crates/trusted-server-adapter-fastly/src/backend.rs b/crates/trusted-server-adapter-fastly/src/backend.rs index 4056c81d..7205a8a0 100644 --- a/crates/trusted-server-adapter-fastly/src/backend.rs +++ b/crates/trusted-server-adapter-fastly/src/backend.rs @@ -64,6 +64,7 @@ pub struct BackendConfig<'a> { first_byte_timeout: Duration, between_bytes_timeout: Duration, host_header_override: Option<&'a str>, + discriminator: Option<&'a str>, } impl<'a> BackendConfig<'a> { @@ -81,6 +82,7 @@ impl<'a> BackendConfig<'a> { first_byte_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, between_bytes_timeout: DEFAULT_BETWEEN_BYTES_TIMEOUT, host_header_override: None, + discriminator: None, } } @@ -128,14 +130,30 @@ impl<'a> BackendConfig<'a> { self } + /// Set an optional stable discriminator folded into the backend name. + /// + /// Two callers targeting the same origin with the same transport timeout + /// otherwise share a backend name. Auction response correlation keys on the + /// backend name, so a shared name would let one provider's response be + /// parsed as another's. A per-provider discriminator keeps the names + /// distinct while staying stable across requests. + #[must_use] + pub fn discriminator(mut self, discriminator: Option<&'a str>) -> Self { + self.discriminator = discriminator; + self + } + /// Compute the deterministic backend name and resolved port without /// registering anything. /// - /// The name encodes scheme, host, port, certificate setting, and - /// first-byte timeout so that backends with different configurations - /// never collide. Including the timeout prevents "first-registration-wins" - /// poisoning where a later request for the same origin with a tighter - /// timeout would silently inherit the original registration's value. + /// The name encodes scheme, host, port, certificate setting, optional + /// discriminator, and the first-byte/between-bytes timeouts so that + /// backends with different configurations never collide. Including the + /// timeout prevents "first-registration-wins" poisoning where a later + /// request for the same origin with a tighter timeout would silently + /// inherit the original registration's value. Including the discriminator + /// keeps two callers that target the same origin with the same timeout + /// (e.g. two auction providers behind one gateway) on distinct backends. fn compute_name(&self) -> Result<(String, u16), Report> { if self.host.is_empty() { return Err(Report::new(TrustedServerError::Proxy { @@ -174,13 +192,18 @@ impl<'a> BackendConfig<'a> { } else { "_nocert" }; + let discriminator_suffix = self + .discriminator + .map(|d| format!("_p_{}", sanitize_backend_name_component(d))) + .unwrap_or_default(); let first_byte_timeout_ms = self.first_byte_timeout.as_millis(); let between_bytes_timeout_ms = self.between_bytes_timeout.as_millis(); let backend_name = format!( - "backend_{}{}{}_fb{}_bb{}", + "backend_{}{}{}{}_fb{}_bb{}", sanitize_backend_name_component(&name_base), host_override_suffix, cert_suffix, + discriminator_suffix, first_byte_timeout_ms, between_bytes_timeout_ms ); diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index c5bb60b9..c89508d3 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -156,6 +156,40 @@ fn backend_config_from_spec(spec: &PlatformBackendSpec) -> BackendConfig<'_> { .certificate_check(spec.certificate_check) .first_byte_timeout(spec.first_byte_timeout) .between_bytes_timeout(spec.between_bytes_timeout) + .discriminator(spec.discriminator.as_deref()) +} + +/// Transport-timeout quantum for auction backends (see +/// [`FastlyPlatformBackend::canonicalize_transport_timeout_ms`]). +const TRANSPORT_TIMEOUT_QUANTUM_MS: u32 = 250; + +/// Coarse rungs for budget-bound transport timeouts below one quantum, +/// ordered high to low. +/// +/// A budget-bound value at or above one quantum is floored to a +/// [`TRANSPORT_TIMEOUT_QUANTUM_MS`] multiple. Below one quantum, passing the +/// exact wall-clock remainder through would mint a distinct backend name for +/// every millisecond in `1..250`, so the near-exhausted tail alone could +/// exceed Fastly's per-service dynamic backend limit. Snapping to this finite +/// ladder instead bounds the number of budget-derived names an origin can +/// produce. Budgets below the smallest rung round to zero, which callers treat +/// as "budget exhausted — skip the launch". +const SUB_QUANTUM_LADDER_MS: [u32; 4] = [200, 150, 100, 50]; + +/// Round a budget-bound transport timeout down to a stable bucket. +/// +/// At or above one quantum, floors to a [`TRANSPORT_TIMEOUT_QUANTUM_MS`] +/// multiple. Below one quantum, snaps down to the greatest +/// [`SUB_QUANTUM_LADDER_MS`] rung no larger than `remaining_ms` (or zero). +fn quantize_transport_timeout_ms(remaining_ms: u32) -> u32 { + let floored = (remaining_ms / TRANSPORT_TIMEOUT_QUANTUM_MS) * TRANSPORT_TIMEOUT_QUANTUM_MS; + if floored > 0 { + return floored; + } + SUB_QUANTUM_LADDER_MS + .into_iter() + .find(|&rung| rung <= remaining_ms) + .unwrap_or(0) } impl PlatformBackend for FastlyPlatformBackend { @@ -170,6 +204,28 @@ impl PlatformBackend for FastlyPlatformBackend { .ensure() .change_context(PlatformError::Backend) } + + /// Quantize the transport timeout so budget-derived values do not mint a + /// new dynamic backend name on every request. + /// + /// Fastly embeds the first-byte and between-bytes timeouts in the dynamic + /// backend name (see [`BackendConfig`]) and pools connections per backend + /// name. A per-request wall-clock budget would otherwise defeat that + /// pooling and accumulate registrations toward the per-service dynamic + /// backend limit. + /// + /// A provider's own configured timeout is a constant, so when it is the + /// binding constraint it is returned verbatim — including sub-quantum + /// configured values, which must not be rounded away or the provider could + /// never launch. Only the budget-bound value is snapped to a stable bucket + /// via [`quantize_transport_timeout_ms`]. Rounding down never extends a + /// transport cap past the remaining budget. + fn canonicalize_transport_timeout_ms(&self, remaining_ms: u32, configured_ms: u32) -> u32 { + if remaining_ms >= configured_ms { + return configured_ms; + } + quantize_transport_timeout_ms(remaining_ms) + } } // --------------------------------------------------------------------------- @@ -637,6 +693,7 @@ mod tests { certificate_check: true, first_byte_timeout: Duration::from_secs(15), between_bytes_timeout: Duration::from_secs(15), + discriminator: None, }; let name = backend @@ -660,6 +717,7 @@ mod tests { certificate_check: true, first_byte_timeout: Duration::from_secs(15), between_bytes_timeout: Duration::from_secs(15), + discriminator: None, }; let name = backend @@ -683,6 +741,7 @@ mod tests { certificate_check: false, first_byte_timeout: Duration::from_secs(15), between_bytes_timeout: Duration::from_secs(15), + discriminator: None, }; let name = backend @@ -706,6 +765,7 @@ mod tests { certificate_check: true, first_byte_timeout: Duration::from_secs(15), between_bytes_timeout: Duration::from_secs(15), + discriminator: None, }; let result = backend.predict_name(&spec); @@ -724,6 +784,7 @@ mod tests { certificate_check: true, first_byte_timeout: Duration::from_millis(2000), between_bytes_timeout: Duration::from_millis(2000), + discriminator: None, }; let name = backend @@ -752,6 +813,7 @@ mod tests { certificate_check: true, first_byte_timeout: Duration::from_millis(750), between_bytes_timeout: Duration::from_millis(750), + discriminator: None, }; let predicted = backend @@ -937,4 +999,142 @@ mod tests { "should describe the unsupported streaming body: {err:?}" ); } + + // --- FastlyPlatformBackend::canonicalize_transport_timeout_ms ----------- + + #[test] + fn canonicalize_prefers_configured_timeout_when_budget_allows() { + let backend = FastlyPlatformBackend; + assert_eq!( + backend.canonicalize_transport_timeout_ms(2000, 1000), + 1000, + "should use the configured timeout verbatim when the budget allows" + ); + assert_eq!( + backend.canonicalize_transport_timeout_ms(2000, 100), + 100, + "should preserve a sub-quantum configured constant — it is name-stable on its own" + ); + } + + #[test] + fn canonicalize_floors_budget_bound_value_to_quantum() { + let backend = FastlyPlatformBackend; + assert_eq!( + backend.canonicalize_transport_timeout_ms(999, 2000), + 750, + "should floor a 999ms budget to the 750ms quantum bucket" + ); + assert_eq!( + backend.canonicalize_transport_timeout_ms(300, 2000), + 250, + "should floor a tight budget down to one quantum" + ); + assert_eq!( + backend.canonicalize_transport_timeout_ms(250, 2000), + 250, + "should keep an exact quantum multiple" + ); + } + + #[test] + fn canonicalize_snaps_sub_quantum_budget_to_bounded_ladder() { + let backend = FastlyPlatformBackend; + // Exact wall-clock values in 1..250 must NOT pass through — that is the + // unbounded-cardinality regression this ladder closes. + assert_eq!( + backend.canonicalize_transport_timeout_ms(249, 2000), + 200, + "should snap a sub-quantum budget down to the greatest ladder rung, not pass 249 through" + ); + assert_eq!(backend.canonicalize_transport_timeout_ms(200, 2000), 200); + assert_eq!(backend.canonicalize_transport_timeout_ms(150, 2000), 150); + assert_eq!(backend.canonicalize_transport_timeout_ms(100, 2000), 100); + assert_eq!(backend.canonicalize_transport_timeout_ms(50, 2000), 50); + assert_eq!( + backend.canonicalize_transport_timeout_ms(49, 2000), + 0, + "a budget below the smallest rung rounds to zero (launch skipped)" + ); + assert_eq!( + backend.canonicalize_transport_timeout_ms(0, 1000), + 0, + "an exhausted budget canonicalizes to zero" + ); + assert_eq!( + backend.canonicalize_transport_timeout_ms(100, 0), + 0, + "a zero configured timeout canonicalizes to zero" + ); + } + + #[test] + fn canonicalize_budget_derived_names_stay_within_a_safe_cardinality() { + // Enumerate every reachable remaining budget for a normal 2000ms + // ceiling and confirm the number of distinct backend-name-bearing + // transport values an origin can mint stays far below Fastly's + // per-service dynamic backend limit (documented default 200). + let backend = FastlyPlatformBackend; + let configured = 2000; + let mut distinct = std::collections::BTreeSet::new(); + for remaining in 0..=configured { + let value = backend.canonicalize_transport_timeout_ms(remaining, configured); + if value > 0 { + distinct.insert(value); + } + // No arbitrary clock-derived value may leak: every canonical value + // is either a quantum multiple or one of the bounded ladder rungs. + assert!( + value == 0 + || value % TRANSPORT_TIMEOUT_QUANTUM_MS == 0 + || SUB_QUANTUM_LADDER_MS.contains(&value), + "canonical value {value}ms (from remaining {remaining}ms) is neither a quantum \ + multiple nor a ladder rung" + ); + } + assert!( + distinct.len() <= 16, + "budget-derived transport values should stay well under the dynamic backend limit, \ + got {} distinct values: {distinct:?}", + distinct.len() + ); + } + + // --- FastlyPlatformBackend::predict_name discriminator ------------------ + + #[test] + fn predict_name_includes_provider_discriminator() { + let backend = FastlyPlatformBackend; + let base = PlatformBackendSpec { + scheme: "https".to_string(), + host: "gateway.example.com".to_string(), + port: None, + host_header_override: None, + certificate_check: true, + first_byte_timeout: Duration::from_millis(750), + between_bytes_timeout: Duration::from_millis(750), + discriminator: Some("prebid".to_string()), + }; + let prebid_name = backend + .predict_name(&base) + .expect("should predict name with discriminator"); + assert!( + prebid_name.contains("_p_prebid"), + "should fold the provider discriminator into the name, got {prebid_name}" + ); + + // Same origin + same transport timeout, different provider → distinct + // backend names, so auction response correlation cannot cross them. + let aps = PlatformBackendSpec { + discriminator: Some("aps".to_string()), + ..base.clone() + }; + let aps_name = backend + .predict_name(&aps) + .expect("should predict name for the second provider"); + assert_ne!( + prebid_name, aps_name, + "two providers on one origin must not share a backend name" + ); + } } diff --git a/crates/trusted-server-adapter-fastly/src/tinybird.rs b/crates/trusted-server-adapter-fastly/src/tinybird.rs index b5d332a6..8df6dbe6 100644 --- a/crates/trusted-server-adapter-fastly/src/tinybird.rs +++ b/crates/trusted-server-adapter-fastly/src/tinybird.rs @@ -212,6 +212,7 @@ fn tinybird_backend_spec(api_host: &str) -> PlatformBackendSpec { certificate_check: true, first_byte_timeout: TINYBIRD_FIRST_BYTE_TIMEOUT, between_bytes_timeout: TINYBIRD_BETWEEN_BYTES_TIMEOUT, + discriminator: None, } } diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index 1e13ca30..492f1a51 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -92,8 +92,15 @@ impl PlatformBackend for NoopBackend { } else { "_nocert" }; + // Keep two providers that share an origin on distinct names so auction + // response correlation cannot cross providers. + let discriminator = spec + .discriminator + .as_deref() + .map(|d| format!("_p_{d}")) + .unwrap_or_default(); Ok(format!( - "{}_{}_{}_{timeout_ms}ms{cert_suffix}", + "{}_{}_{}_{timeout_ms}ms{cert_suffix}{discriminator}", spec.scheme, spec.host, port )) } diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index d884a022..02a87cdb 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -157,64 +157,6 @@ fn remaining_budget_ms(start: Instant, timeout_ms: u32) -> u32 { timeout_ms.saturating_sub(elapsed) } -/// Transport-timeout quantum for auction backends. -/// -/// See [`quantize_transport_timeout_ms`] for why provider transport timeouts -/// are rounded to this granularity. -const TRANSPORT_TIMEOUT_QUANTUM_MS: u32 = 250; - -/// Round a transport timeout down to a [`TRANSPORT_TIMEOUT_QUANTUM_MS`] multiple. -/// -/// The Fastly adapter embeds the first-byte and between-bytes timeouts in the -/// dynamic backend name so a registration can never be silently reused with a -/// different transport configuration. Deriving those timeouts from the -/// remaining wall-clock budget minted a new backend name on nearly every -/// request, which defeated cross-request TCP/TLS connection reuse (Fastly -/// pools connections per backend name) and accumulated registrations toward -/// the per-service dynamic backend limit. -/// -/// Quantizing the value — not just the name — keeps the registered backend -/// configuration aligned with its name. Rounding down never extends a -/// transport cap past the auction deadline, which matters on the mediator and -/// dispatched-collect paths where the backend timeouts (not a select-loop -/// deadline check) bound the `` hold. -#[inline] -fn quantize_transport_timeout_ms(timeout_ms: u32) -> u32 { - (timeout_ms / TRANSPORT_TIMEOUT_QUANTUM_MS) * TRANSPORT_TIMEOUT_QUANTUM_MS -} - -/// Compute the transport timeout for a provider launch from the remaining -/// auction budget and the provider's configured timeout. -/// -/// The configured timeout is a per-provider constant, so using it verbatim -/// already yields a stable backend name — including configured values below -/// one quantum, which must not be rounded away or the provider could never -/// launch. Only when the remaining budget is the binding constraint does the -/// wall-clock-derived value enter the name, and that value is quantized via -/// [`quantize_transport_timeout_ms`] so it cannot mint a new backend name on -/// every request. -/// -/// A remaining budget below one quantum is passed through exactly rather -/// than rounded to zero: rounding up would extend the transport cap past the -/// deadline, and rounding down would skip the launch and hard-fail auctions -/// whose configured budget is under one quantum. Name churn in this regime -/// is bounded to sub-quantum values and matches the pre-quantization -/// behavior. The result never exceeds `remaining_ms` and is zero only when -/// `remaining_ms` or `configured_ms` is zero, which callers treat as -/// "budget exhausted — skip the launch". -#[inline] -fn effective_transport_timeout_ms(remaining_ms: u32, configured_ms: u32) -> u32 { - if remaining_ms >= configured_ms { - return configured_ms; - } - let quantized = quantize_transport_timeout_ms(remaining_ms); - if quantized == 0 { - remaining_ms - } else { - quantized - } -} - /// Manages auction execution across multiple providers. pub struct AuctionOrchestrator { config: AuctionConfig, @@ -338,11 +280,16 @@ impl AuctionOrchestrator { // Give the mediator only the remaining time from the auction // deadline, not the full timeout — the bidding phase already // consumed part of it, and the mediator has no select-loop - // deadline backstop. Quantized for backend-name stability (see - // effective_transport_timeout_ms). + // deadline backstop. The platform canonicalizes the value for + // backend-name stability (see + // `PlatformBackend::canonicalize_transport_timeout_ms`); it never + // exceeds the remaining budget. See the transport-deadline note on + // `run_providers_parallel` for the limits of this bound. let remaining_ms = remaining_budget_ms(mediation_start, context.timeout_ms); - let mediator_timeout = - effective_transport_timeout_ms(remaining_ms, mediator.timeout_ms()); + let mediator_timeout = context + .services + .backend() + .canonicalize_transport_timeout_ms(remaining_ms, mediator.timeout_ms()); if mediator_timeout == 0 { log::warn!("Auction timeout exhausted during bidding phase; skipping mediator"); @@ -525,11 +472,14 @@ impl AuctionOrchestrator { // Give each provider only the remaining time from the auction // deadline so that backend transport timeouts do not extend past - // the overall budget, quantized for backend-name stability (see - // effective_transport_timeout_ms). + // the overall budget. The platform canonicalizes the value for + // backend-name stability (see + // `PlatformBackend::canonicalize_transport_timeout_ms`). let remaining_ms = remaining_budget_ms(auction_start, context.timeout_ms); - let effective_timeout = - effective_transport_timeout_ms(remaining_ms, provider.timeout_ms()); + let effective_timeout = context + .services + .backend() + .canonicalize_transport_timeout_ms(remaining_ms, provider.timeout_ms()); if effective_timeout == 0 { log::warn!("Auction timeout exhausted before launching provider request; skipping"); @@ -580,15 +530,35 @@ impl AuctionOrchestrator { ); backend_name.clone() }); - backend_to_provider.insert( - request_backend_name.clone(), - (provider.provider_name(), start_time, provider.as_ref()), - ); - pending_requests.push(pending); - log::debug!( - "Request to '{}' launched successfully", - provider.provider_name() - ); + // Responses are correlated back to providers by backend + // name. If another provider this auction already claimed + // this name (e.g. two providers on one origin whose specs + // canonicalize to the same backend), inserting here would + // silently overwrite the first mapping and misattribute or + // drop a response. Fail this launch attributably instead. + if backend_to_provider.contains_key(&request_backend_name) { + let response_time_ms = start_time.elapsed().as_millis() as u64; + log::warn!( + "Provider '{}' resolved to backend name '{}' already claimed by another \ + provider this auction; skipping launch to avoid response misattribution", + provider.provider_name(), + request_backend_name, + ); + responses.push(provider_launch_failed_response( + provider.provider_name(), + response_time_ms, + )); + } else { + backend_to_provider.insert( + request_backend_name.clone(), + (provider.provider_name(), start_time, provider.as_ref()), + ); + pending_requests.push(pending); + log::debug!( + "Request to '{}' launched successfully", + provider.provider_name() + ); + } } Err(e) => { let response_time_ms = start_time.elapsed().as_millis() as u64; @@ -621,14 +591,25 @@ impl AuctionOrchestrator { ); // Phase 2: Wait for responses using select() to process as they become ready. - // Enforce the auction deadline: after each select() returns, check - // elapsed time and drop remaining requests if the timeout is exceeded. + // After each select() returns, check elapsed time and drop remaining + // requests once the auction deadline passes. // - // NOTE: `select()` blocks until at least one backend responds and, on - // some adapters, buffers the selected response body before returning. - // Hard deadline enforcement therefore depends on every backend's - // first-byte and between-bytes timeouts being set to at most the - // remaining auction budget, which Phase 1 above guarantees. + // TRANSPORT-DEADLINE NOTE: this select loop is the only *absolute* + // wall-clock bound on the parallel path — it drops still-pending + // requests once `auction_start.elapsed()` exceeds the deadline. The + // per-backend transport timeouts set in Phase 1 are a complementary, + // not equivalent, bound: Fastly's connect timeout is a fixed ~1s, the + // first-byte timeout only starts after the connection is established, + // and the between-bytes timeout is an inactivity timer that resets on + // every byte received. A backend that connects slowly or trickles one + // byte just inside the between-bytes window can therefore outlive the + // configured budget. Bounding them to the remaining budget (Phase 1) + // guarantees they never *extend past* the deadline by their own + // configuration, but does not by itself enforce a hard total-response + // deadline. Paths without this select loop (the mediator and the + // dispatched-collect body read) inherit that weaker bound; a true + // absolute deadline carried through the platform HTTP API is tracked + // as follow-up work (see the streaming/deadline effort, #849). let mut remaining = pending_requests; while !remaining.is_empty() { @@ -937,11 +918,13 @@ impl AuctionOrchestrator { continue; } - // Remaining budget quantized for backend-name stability (see - // effective_transport_timeout_ms). + // Remaining budget canonicalized by the platform for backend-name + // stability (see `PlatformBackend::canonicalize_transport_timeout_ms`). let remaining_ms = remaining_budget_ms(auction_start, context.timeout_ms); - let effective_timeout = - effective_transport_timeout_ms(remaining_ms, provider.timeout_ms()); + let effective_timeout = context + .services + .backend() + .canonicalize_transport_timeout_ms(remaining_ms, provider.timeout_ms()); if effective_timeout == 0 { log::warn!( @@ -974,21 +957,39 @@ impl AuctionOrchestrator { let start_time = Instant::now(); match provider.request_bids(request, &provider_context).await { Ok(pending) => { - log::info!( - "Dispatching bid request to '{}' (backend: {}, budget: {}ms)", - provider.provider_name(), - backend_name, - effective_timeout - ); - backend_to_provider.insert( - backend_name.clone(), - ( - provider.provider_name().to_string(), - start_time, - Arc::clone(provider), - ), - ); - pending_requests.push(pending.with_backend_name(backend_name)); + // See the parallel path: a backend name already claimed by + // another provider this auction would misattribute the + // collected response, so fail this launch attributably + // rather than overwrite the mapping. + if backend_to_provider.contains_key(&backend_name) { + let response_time_ms = start_time.elapsed().as_millis() as u64; + log::warn!( + "Provider '{}' resolved to backend name '{}' already claimed by another \ + provider this auction; skipping dispatch to avoid response misattribution", + provider.provider_name(), + backend_name, + ); + launch_responses.push(provider_launch_failed_response( + provider.provider_name(), + response_time_ms, + )); + } else { + log::info!( + "Dispatching bid request to '{}' (backend: {}, budget: {}ms)", + provider.provider_name(), + backend_name, + effective_timeout + ); + backend_to_provider.insert( + backend_name.clone(), + ( + provider.provider_name().to_string(), + start_time, + Arc::clone(provider), + ), + ); + pending_requests.push(pending.with_backend_name(backend_name)); + } } Err(e) => { let response_time_ms = start_time.elapsed().as_millis() as u64; @@ -1189,21 +1190,27 @@ impl AuctionOrchestrator { match self.providers.get(mediator_name.as_str()) { Some(mediator) => { // Cap the mediator at whichever is tighter: its own configured - // timeout or the remaining auction budget (A_deadline). The old - // comment here claimed origin drain could exhaust the budget before - // collection, but SSP backends are given first-byte and between-bytes - // timeouts equal to effective_timeout (capped at their provider - // timeout) at dispatch time, so they cannot run past A_deadline - // independently. Giving the mediator an uncapped timeout lets it run - // past A_deadline, violating the bounded hold invariant. - // The mediator's only time bound on this path is its - // backend transport timeout, so the effective value must - // never exceed the remaining budget. Quantized for - // backend-name stability (see - // effective_transport_timeout_ms). + // timeout or the remaining auction budget (A_deadline). Giving + // the mediator an uncapped timeout would let it hold `` + // well past A_deadline, so the effective value must never + // exceed the remaining budget. + // + // Caveat: unlike the parallel select loop, this path has no + // absolute wall-clock backstop around the mediator call, and a + // backend transport timeout bounds first-byte/inactivity rather + // than total response time (see the transport-deadline note on + // `run_providers_parallel`). Capping the value to the remaining + // budget therefore prevents the mediator from *extending* the + // hold by its own configuration, but a slow-connecting or + // byte-trickling mediator can still overrun; a true absolute + // deadline is tracked as follow-up (#849). + // + // The platform canonicalizes the value for backend-name + // stability (see `PlatformBackend::canonicalize_transport_timeout_ms`). let remaining = remaining_budget_ms(auction_start, timeout_ms); - let mediator_timeout = - effective_transport_timeout_ms(remaining, mediator.timeout_ms()); + let mediator_timeout = services + .backend() + .canonicalize_transport_timeout_ms(remaining, mediator.timeout_ms()); if mediator_timeout == 0 { log::warn!( "A_deadline exhausted before mediator '{}' — returning {} SSP bids without mediation", @@ -1397,9 +1404,13 @@ mod tests { MediaType, PublisherInfo, UserInfo, }; use crate::error::TrustedServerError; - use crate::platform::test_support::{build_services_with_http_client, StubHttpClient}; + use crate::platform::test_support::{ + build_services_with_backend_and_http_client, build_services_with_http_client, + StubHttpClient, + }; use crate::platform::{ - PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, RuntimeServices, + PlatformBackend, PlatformBackendSpec, PlatformError, PlatformHttpRequest, + PlatformPendingRequest, PlatformResponse, RuntimeServices, }; use crate::test_support::tests::crate_test_settings_str; use error_stack::{Report, ResultExt}; @@ -1412,15 +1423,19 @@ mod tests { // Minimal test double for AuctionProvider // --------------------------------------------------------------------------- - /// Minimal stub provider. Optionally records every transport timeout it - /// observes — the value passed to `backend_name` and the - /// `context.timeout_ms` handed to `request_bids` — so tests can assert - /// the orchestrator quantizes them. + /// Minimal stub provider. Optionally records the transport timeouts it + /// observes, keeping the value passed to `backend_name` (which derives the + /// predicted backend name) separate from the `context.timeout_ms` handed to + /// `request_bids` (which configures the registered request). Recording them + /// separately lets tests assert the orchestrator hands the *same* + /// canonicalized value to both — a divergence would land responses in the + /// "unknown backend" branch and drop bids. struct StubAuctionProvider { name: &'static str, backend: &'static str, configured_timeout_ms: u32, - observed_timeouts: Option>>>, + predicted_timeouts: Option>>>, + request_timeouts: Option>>>, } impl StubAuctionProvider { @@ -1429,7 +1444,8 @@ mod tests { name, backend, configured_timeout_ms: 2000, - observed_timeouts: None, + predicted_timeouts: None, + request_timeouts: None, } } @@ -1437,18 +1453,20 @@ mod tests { name: &'static str, backend: &'static str, configured_timeout_ms: u32, - observed_timeouts: Arc>>, + predicted_timeouts: Arc>>, + request_timeouts: Arc>>, ) -> Self { Self { name, backend, configured_timeout_ms, - observed_timeouts: Some(observed_timeouts), + predicted_timeouts: Some(predicted_timeouts), + request_timeouts: Some(request_timeouts), } } - fn record(&self, timeout_ms: u32) { - if let Some(observed) = &self.observed_timeouts { + fn record(slot: &Option>>>, timeout_ms: u32) { + if let Some(observed) = slot { observed .lock() .expect("should lock observed timeouts") @@ -1468,7 +1486,7 @@ mod tests { _request: &AuctionRequest, context: &AuctionContext<'_>, ) -> Result> { - self.record(context.timeout_ms); + Self::record(&self.request_timeouts, context.timeout_ms); let req = PlatformHttpRequest::new( http::Request::builder() .method("POST") @@ -1504,7 +1522,7 @@ mod tests { } fn backend_name(&self, _services: &RuntimeServices, timeout_ms: u32) -> Option { - self.record(timeout_ms); + Self::record(&self.predicted_timeouts, timeout_ms); Some(self.backend.to_string()) } } @@ -2038,94 +2056,71 @@ mod tests { ); } - #[test] - fn quantize_transport_timeout_floors_to_quantum() { - assert_eq!( - super::quantize_transport_timeout_ms(0), - 0, - "should keep zero at zero" - ); - assert_eq!( - super::quantize_transport_timeout_ms(249), - 0, - "should floor a sub-quantum budget to zero" - ); - assert_eq!( - super::quantize_transport_timeout_ms(250), - 250, - "should keep an exact quantum multiple unchanged" - ); - assert_eq!( - super::quantize_transport_timeout_ms(999), - 750, - "should floor to the next-lower quantum multiple" - ); - assert_eq!( - super::quantize_transport_timeout_ms(2000), - 2000, - "should keep a larger exact quantum multiple unchanged" - ); + /// Test backend whose [`PlatformBackend::canonicalize_transport_timeout_ms`] + /// returns a fixed value regardless of the wall-clock budget, so the + /// orchestrator's transport-timeout wiring can be asserted without timing + /// flakiness. Records every `(remaining_ms, configured_ms)` pair it sees. + /// + /// The exact quantization arithmetic lives in the Fastly adapter (the only + /// platform that overrides `canonicalize_transport_timeout_ms`); these core + /// tests only prove the orchestrator applies whatever the platform returns + /// and applies it identically to the predicted name and the launched + /// request. + struct CanonicalTimeoutBackend { + canonical_ms: u32, + calls: Arc>>, } - #[test] - fn effective_transport_timeout_prefers_configured_constant() { - assert_eq!( - super::effective_transport_timeout_ms(2000, 1000), - 1000, - "should use the configured timeout verbatim when the budget allows" - ); - assert_eq!( - super::effective_transport_timeout_ms(2000, 100), - 100, - "should preserve a sub-quantum configured timeout — quantizing it away would permanently disable the provider" - ); - assert_eq!( - super::effective_transport_timeout_ms(999, 2000), - 750, - "should quantize the budget-bound value down to the 750ms bucket" - ); - assert_eq!( - super::effective_transport_timeout_ms(300, 2000), - 250, - "should quantize a tight budget down to one quantum" - ); - assert_eq!( - super::effective_transport_timeout_ms(200, 2000), - 200, - "should pass a sub-quantum budget through exactly instead of rounding to zero" - ); - assert_eq!( - super::effective_transport_timeout_ms(50, 100), - 50, - "should pass through when the budget is below both the quantum and the configured timeout" - ); - assert_eq!( - super::effective_transport_timeout_ms(0, 1000), - 0, - "should return zero for an exhausted budget so the launch is skipped" - ); - assert_eq!( - super::effective_transport_timeout_ms(100, 0), - 0, - "should return zero for a zero configured timeout so the launch is skipped" - ); + impl CanonicalTimeoutBackend { + fn new(canonical_ms: u32, calls: Arc>>) -> Self { + Self { + canonical_ms, + calls, + } + } + } + + impl PlatformBackend for CanonicalTimeoutBackend { + fn predict_name( + &self, + _spec: &PlatformBackendSpec, + ) -> Result> { + Ok("stub-backend".to_owned()) + } + + fn ensure(&self, _spec: &PlatformBackendSpec) -> Result> { + Ok("stub-backend".to_owned()) + } + + fn canonicalize_transport_timeout_ms(&self, remaining_ms: u32, configured_ms: u32) -> u32 { + self.calls + .lock() + .expect("should lock canonicalize calls") + .push((remaining_ms, configured_ms)); + self.canonical_ms + } } #[test] - fn sub_quantum_configured_timeout_still_launches_provider() { + fn parallel_launch_applies_canonical_timeout_to_name_and_request() { futures::executor::block_on(async { - // A provider whose configured timeout is below one quantum must - // still launch with its exact configured value: the constant is - // name-stable on its own, so only budget-derived values are - // quantized. + // The orchestrator must hand the platform-canonicalized value to + // BOTH `backend_name` (which derives the correlation key) and + // `request_bids` (via `context.timeout_ms`). Recording them + // separately and asserting exact equality catches a regression that + // predicts one bucket but registers another — which would drop the + // response into the "unknown backend" branch. let stub = Arc::new(StubHttpClient::new()); stub.push_response(200, b"{}".to_vec()); - let services = build_services_with_http_client(stub); + let calls = Arc::new(Mutex::new(Vec::new())); + let backend = Arc::new(CanonicalTimeoutBackend::new(750, Arc::clone(&calls))); + let services = build_services_with_backend_and_http_client(backend, stub); // SAFETY: `Box::leak` creates a `'static` reference for test use only. // The leaked allocation is bounded to the test process lifetime. let services: &'static RuntimeServices = Box::leak(Box::new(services)); - let observed = Arc::new(Mutex::new(Vec::new())); + let predicted = Arc::new(Mutex::new(Vec::new())); + let requested = Arc::new(Mutex::new(Vec::new())); let config = AuctionConfig { enabled: true, providers: vec!["bidder".to_string()], @@ -2137,8 +2132,9 @@ mod tests { orchestrator.register_provider(Arc::new(StubAuctionProvider::recording( "bidder", "bidder-backend", - 100, - Arc::clone(&observed), + 1000, + Arc::clone(&predicted), + Arc::clone(&requested), ))); let request = create_test_auction_request(); @@ -2161,49 +2157,62 @@ mod tests { .await .expect("should complete auction"); - let observed = observed.lock().expect("should lock observed timeouts"); + let predicted = predicted.lock().expect("should lock predicted"); + let requested = requested.lock().expect("should lock requested"); + assert_eq!( + *predicted, + vec![750], + "backend_name should receive the canonicalized value" + ); + assert_eq!( + *requested, + vec![750], + "request_bids should receive the same canonicalized value" + ); + assert_eq!( + *predicted, *requested, + "predicted and registered transport timeouts must be identical" + ); + + let calls = calls.lock().expect("should lock calls"); + assert_eq!(calls.len(), 1, "should canonicalize once for the launch"); + let (remaining_ms, configured_ms) = calls[0]; + assert_eq!( + configured_ms, 1000, + "should pass the provider's configured timeout as the configured bound" + ); assert!( - !observed.is_empty(), - "should launch the sub-quantum-configured provider" + remaining_ms > 0 && remaining_ms <= 2000, + "should pass the live remaining budget, got {remaining_ms}ms" ); - for timeout in observed.iter() { - assert_eq!( - *timeout, 100, - "should pass the configured 100ms timeout through unchanged" - ); - } }); } #[test] - fn parallel_path_quantizes_provider_transport_timeout() { + fn zero_canonical_timeout_skips_parallel_launch() { futures::executor::block_on(async { - // A 999ms budget must reach the provider as the 750ms quantum - // bucket — both in backend_name (which derives the Fastly backend - // name) and in context.timeout_ms (which configures the backend - // and payload deadlines) — so the backend name stays stable - // across requests with slightly different remaining budgets. + // A platform that canonicalizes to zero signals "budget exhausted"; + // the orchestrator must skip the launch. With the only provider + // skipped, no requests launch and the auction errors. let stub = Arc::new(StubHttpClient::new()); - stub.push_response(200, b"{}".to_vec()); - let services = build_services_with_http_client(stub); + let calls = Arc::new(Mutex::new(Vec::new())); + let backend = Arc::new(CanonicalTimeoutBackend::new(0, Arc::clone(&calls))); + let services = build_services_with_backend_and_http_client(backend, stub); // SAFETY: `Box::leak` creates a `'static` reference for test use only. // The leaked allocation is bounded to the test process lifetime. let services: &'static RuntimeServices = Box::leak(Box::new(services)); - let observed = Arc::new(Mutex::new(Vec::new())); let config = AuctionConfig { enabled: true, providers: vec!["bidder".to_string()], - timeout_ms: 999, + timeout_ms: 2000, mediator: None, ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider::recording( + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( "bidder", "bidder-backend", - 2000, - Arc::clone(&observed), ))); let request = create_test_auction_request(); @@ -2216,60 +2225,55 @@ mod tests { let context = AuctionContext { settings: &settings, request: &req, - timeout_ms: 999, + timeout_ms: 2000, provider_responses: None, services, }; - orchestrator - .run_auction(&request, &context) - .await - .expect("should complete auction"); - - let observed = observed.lock().expect("should lock observed timeouts"); + let result = orchestrator.run_auction(&request, &context).await; assert!( - !observed.is_empty(), - "should record provider transport timeouts" + result.is_err(), + "should error when the only provider is skipped for an exhausted budget" ); - for timeout in observed.iter() { - assert!( - *timeout % super::TRANSPORT_TIMEOUT_QUANTUM_MS == 0 - && *timeout > 0 - && *timeout <= 750, - "should floor the 999ms budget to a quantum bucket at or below 750ms, got {timeout}ms" - ); - } }); } #[test] - fn sub_quantum_budget_launches_with_exact_remaining_timeout() { + fn synchronous_mediation_applies_canonical_timeout_to_mediator() { futures::executor::block_on(async { - // A configured auction budget below one quantum must still launch - // providers with the exact remaining budget — rounding it to zero - // would hard-fail every auction for publishers with sub-250ms - // budgets. + // The mediator runs after the bidding phase and has no select-loop + // backstop; it must still receive the platform-canonicalized value + // for both prediction and request. let stub = Arc::new(StubHttpClient::new()); - stub.push_response(200, b"{}".to_vec()); - let services = build_services_with_http_client(stub); + stub.push_response(200, b"{}".to_vec()); // bidder send_async + stub.push_response(200, b"{}".to_vec()); // mediator send_async + let calls = Arc::new(Mutex::new(Vec::new())); + let backend = Arc::new(CanonicalTimeoutBackend::new(500, Arc::clone(&calls))); + let services = build_services_with_backend_and_http_client(backend, stub); // SAFETY: `Box::leak` creates a `'static` reference for test use only. // The leaked allocation is bounded to the test process lifetime. let services: &'static RuntimeServices = Box::leak(Box::new(services)); - let observed = Arc::new(Mutex::new(Vec::new())); + let predicted = Arc::new(Mutex::new(Vec::new())); + let requested = Arc::new(Mutex::new(Vec::new())); let config = AuctionConfig { enabled: true, providers: vec!["bidder".to_string()], - timeout_ms: 200, - mediator: None, + mediator: Some("mediator".to_string()), + timeout_ms: 2000, ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider::recording( + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( "bidder", "bidder-backend", + ))); + orchestrator.register_provider(Arc::new(StubAuctionProvider::recording( + "mediator", + "mediator-backend", 2000, - Arc::clone(&observed), + Arc::clone(&predicted), + Arc::clone(&requested), ))); let request = create_test_auction_request(); @@ -2282,67 +2286,76 @@ mod tests { let context = AuctionContext { settings: &settings, request: &req, - timeout_ms: 200, + timeout_ms: 2000, provider_responses: None, services, }; - let result = orchestrator + orchestrator .run_auction(&request, &context) .await - .expect("should complete auction with a sub-quantum budget"); + .expect("should complete mediated auction"); - assert_eq!( - result.provider_responses.len(), - 1, - "should launch the provider despite the sub-quantum budget" - ); - let observed = observed.lock().expect("should lock observed timeouts"); + let predicted = predicted.lock().expect("should lock predicted"); + let requested = requested.lock().expect("should lock requested"); + // The orchestrator hands the mediator its budget through + // `context.timeout_ms` and calls `request_bids` directly; it does not + // call the mediator's `backend_name` (the mediator self-registers its + // backend), so only the request side is observed here. assert!( - !observed.is_empty(), - "should record provider transport timeouts" + predicted.is_empty(), + "orchestrator should not separately predict a backend name for the mediator" + ); + assert_eq!( + *requested, + vec![500], + "mediator request should use the canonical value" ); - for timeout in observed.iter() { - assert!( - *timeout > 0 && *timeout <= 200, - "should pass the exact sub-quantum remaining budget through, got {timeout}ms" - ); - } }); } #[test] - fn synchronous_mediation_quantizes_mediator_timeout() { + fn dispatched_collect_applies_canonical_timeout_to_both_paths() { futures::executor::block_on(async { - // The mediator has no select-loop deadline backstop, so its - // transport timeout must be quantized by rounding down: a - // quantum-aligned value no larger than the remaining budget. + // Same wiring invariant on the split dispatch/collect path used by + // publisher page rendering: the dispatched bidder and the collected + // mediator both receive the canonicalized value for prediction and + // request. let stub = Arc::new(StubHttpClient::new()); stub.push_response(200, b"{}".to_vec()); // bidder send_async stub.push_response(200, b"{}".to_vec()); // mediator send_async - let services = build_services_with_http_client(stub); + let calls = Arc::new(Mutex::new(Vec::new())); + let backend = Arc::new(CanonicalTimeoutBackend::new(500, Arc::clone(&calls))); + let services = build_services_with_backend_and_http_client(backend, stub); // SAFETY: `Box::leak` creates a `'static` reference for test use only. // The leaked allocation is bounded to the test process lifetime. let services: &'static RuntimeServices = Box::leak(Box::new(services)); - let observed = Arc::new(Mutex::new(Vec::new())); + let bidder_predicted = Arc::new(Mutex::new(Vec::new())); + let bidder_requested = Arc::new(Mutex::new(Vec::new())); + let mediator_predicted = Arc::new(Mutex::new(Vec::new())); + let mediator_requested = Arc::new(Mutex::new(Vec::new())); let config = AuctionConfig { enabled: true, providers: vec!["bidder".to_string()], mediator: Some("mediator".to_string()), - timeout_ms: 999, + timeout_ms: 2000, ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + orchestrator.register_provider(Arc::new(StubAuctionProvider::recording( "bidder", "bidder-backend", + 2000, + Arc::clone(&bidder_predicted), + Arc::clone(&bidder_requested), ))); orchestrator.register_provider(Arc::new(StubAuctionProvider::recording( "mediator", "mediator-backend", 2000, - Arc::clone(&observed), + Arc::clone(&mediator_predicted), + Arc::clone(&mediator_requested), ))); let request = create_test_auction_request(); @@ -2355,65 +2368,168 @@ mod tests { let context = AuctionContext { settings: &settings, request: &req, - timeout_ms: 999, + timeout_ms: 2000, provider_responses: None, services, }; + let dispatched = match orchestrator.dispatch_auction(&request, &context).await { + DispatchAuctionOutcome::Dispatched(dispatched) => dispatched, + _ => panic!("should dispatch the bidder request"), + }; orchestrator + .collect_dispatched_auction(dispatched, services, &context) + .await; + + let bidder_predicted = bidder_predicted + .lock() + .expect("should lock bidder predicted"); + let bidder_requested = bidder_requested + .lock() + .expect("should lock bidder requested"); + assert_eq!( + *bidder_predicted, + vec![500], + "dispatched bidder name should use canonical value" + ); + assert_eq!( + *bidder_requested, + vec![500], + "dispatched bidder request should use canonical value" + ); + assert_eq!( + *bidder_predicted, *bidder_requested, + "dispatched bidder predicted and registered timeouts must be identical" + ); + + let mediator_predicted = mediator_predicted + .lock() + .expect("should lock mediator predicted"); + let mediator_requested = mediator_requested + .lock() + .expect("should lock mediator requested"); + // As on the synchronous path, the orchestrator calls the mediator's + // `request_bids` directly without predicting a backend name for it. + assert!( + mediator_predicted.is_empty(), + "orchestrator should not separately predict a backend name for the mediator" + ); + assert_eq!( + *mediator_requested, + vec![500], + "mediator request should use the canonical value" + ); + }); + } + + #[test] + fn parallel_duplicate_backend_name_fails_second_provider_attributably() { + futures::executor::block_on(async { + // Two providers that canonicalize to the SAME backend name (e.g. two + // auction providers behind one gateway origin). The correlation map + // keys on backend name, so the second must not silently overwrite + // the first — it must fail attributably so no bid is misparsed or + // lost. + let stub = Arc::new(StubHttpClient::new()); + stub.push_response(200, b"{}".to_vec()); // provider-a send_async + stub.push_response(200, b"{}".to_vec()); // provider-b send_async (dropped after guard) + let services = build_services_with_http_client(stub); + // SAFETY: `Box::leak` creates a `'static` reference for test use only. + // The leaked allocation is bounded to the test process lifetime. + let services: &'static RuntimeServices = Box::leak(Box::new(services)); + + let config = AuctionConfig { + enabled: true, + providers: vec!["provider-a".to_string(), "provider-b".to_string()], + timeout_ms: 2000, + mediator: None, + ..Default::default() + }; + let mut orchestrator = AuctionOrchestrator::new(config); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-a", + "shared-backend", + ))); + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-b", + "shared-backend", + ))); + + let request = create_test_auction_request(); + let settings = create_test_settings(); + let req = http::Request::builder() + .method(http::Method::GET) + .uri("https://example.com/test") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let context = AuctionContext { + settings: &settings, + request: &req, + timeout_ms: 2000, + provider_responses: None, + services, + }; + + let result = orchestrator .run_auction(&request, &context) .await - .expect("should complete mediated auction"); + .expect("should complete auction despite the name collision"); - let observed = observed.lock().expect("should lock observed timeouts"); - assert!(!observed.is_empty(), "should run the mediator"); - for timeout in observed.iter() { - assert!( - *timeout % super::TRANSPORT_TIMEOUT_QUANTUM_MS == 0, - "mediator timeout {timeout}ms should be quantum-aligned" - ); - assert!( - *timeout > 0 && *timeout <= 750, - "mediator timeout {timeout}ms should be positive and floored below the 999ms budget" - ); - } + assert_eq!( + result.provider_responses.len(), + 2, + "should account for both providers" + ); + let provider_a = result + .provider_responses + .iter() + .find(|r| r.provider == "provider-a") + .expect("should have provider-a response"); + let provider_b = result + .provider_responses + .iter() + .find(|r| r.provider == "provider-b") + .expect("should have provider-b response"); + assert_eq!( + provider_a.status, + BidStatus::Success, + "the first provider on the shared name should launch and succeed" + ); + assert_eq!( + provider_b.status, + BidStatus::Error, + "the second provider on the shared name should fail attributably, not be dropped" + ); }); } #[test] - fn dispatched_collect_quantizes_mediator_timeout() { + fn dispatched_duplicate_backend_name_fails_second_provider_attributably() { futures::executor::block_on(async { - // Same invariant as the synchronous path, on the split - // dispatch/collect path used by publisher page rendering. + // Same collision defense on the dispatch/collect path. let stub = Arc::new(StubHttpClient::new()); - stub.push_response(200, b"{}".to_vec()); // bidder send_async - stub.push_response(200, b"{}".to_vec()); // mediator send_async + stub.push_response(200, b"{}".to_vec()); // provider-a send_async + stub.push_response(200, b"{}".to_vec()); // provider-b send_async (dropped after guard) let services = build_services_with_http_client(stub); // SAFETY: `Box::leak` creates a `'static` reference for test use only. // The leaked allocation is bounded to the test process lifetime. let services: &'static RuntimeServices = Box::leak(Box::new(services)); - let observed_bidder = Arc::new(Mutex::new(Vec::new())); - let observed_mediator = Arc::new(Mutex::new(Vec::new())); let config = AuctionConfig { enabled: true, - providers: vec!["bidder".to_string()], - mediator: Some("mediator".to_string()), - timeout_ms: 999, + providers: vec!["provider-a".to_string(), "provider-b".to_string()], + timeout_ms: 2000, + mediator: None, ..Default::default() }; let mut orchestrator = AuctionOrchestrator::new(config); - orchestrator.register_provider(Arc::new(StubAuctionProvider::recording( - "bidder", - "bidder-backend", - 2000, - Arc::clone(&observed_bidder), + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-a", + "shared-backend", ))); - orchestrator.register_provider(Arc::new(StubAuctionProvider::recording( - "mediator", - "mediator-backend", - 2000, - Arc::clone(&observed_mediator), + orchestrator.register_provider(Arc::new(StubAuctionProvider::new( + "provider-b", + "shared-backend", ))); let request = create_test_auction_request(); @@ -2426,47 +2542,29 @@ mod tests { let context = AuctionContext { settings: &settings, request: &req, - timeout_ms: 999, + timeout_ms: 2000, provider_responses: None, services, }; let dispatched = match orchestrator.dispatch_auction(&request, &context).await { DispatchAuctionOutcome::Dispatched(dispatched) => dispatched, - _ => panic!("should dispatch the bidder request"), + _ => panic!("should dispatch the first provider despite the name collision"), }; - orchestrator + let result = orchestrator .collect_dispatched_auction(dispatched, services, &context) .await; - let observed_bidder = observed_bidder.lock().expect("should lock bidder timeouts"); - assert!( - !observed_bidder.is_empty(), - "should record dispatched bidder timeouts" + let provider_b = result + .provider_responses + .iter() + .find(|r| r.provider == "provider-b") + .expect("should have provider-b response"); + assert_eq!( + provider_b.status, + BidStatus::Error, + "the second provider on the shared name should fail attributably, not be dropped" ); - for timeout in observed_bidder.iter() { - assert!( - *timeout % super::TRANSPORT_TIMEOUT_QUANTUM_MS == 0 - && *timeout > 0 - && *timeout <= 750, - "dispatched bidder timeout should floor 999ms to a quantum bucket at or below 750ms, got {timeout}ms" - ); - } - - let observed_mediator = observed_mediator - .lock() - .expect("should lock mediator timeouts"); - assert!(!observed_mediator.is_empty(), "should run the mediator"); - for timeout in observed_mediator.iter() { - assert!( - *timeout % super::TRANSPORT_TIMEOUT_QUANTUM_MS == 0, - "mediator timeout {timeout}ms should be quantum-aligned" - ); - assert!( - *timeout > 0 && *timeout <= 750, - "mediator timeout {timeout}ms should be positive and floored below the 999ms budget" - ); - } }); } diff --git a/crates/trusted-server-core/src/ec/pull_sync.rs b/crates/trusted-server-core/src/ec/pull_sync.rs index fa096d59..833898b5 100644 --- a/crates/trusted-server-core/src/ec/pull_sync.rs +++ b/crates/trusted-server-core/src/ec/pull_sync.rs @@ -174,6 +174,7 @@ pub fn dispatch_pull_sync( certificate_check: settings.proxy.certificate_check, first_byte_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, between_bytes_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, + discriminator: None, }) { Ok(name) => name, Err(err) => { diff --git a/crates/trusted-server-core/src/integrations/datadome/protection.rs b/crates/trusted-server-core/src/integrations/datadome/protection.rs index 717ad46e..eedcf7cd 100644 --- a/crates/trusted-server-core/src/integrations/datadome/protection.rs +++ b/crates/trusted-server-core/src/integrations/datadome/protection.rs @@ -160,6 +160,7 @@ impl DataDomeIntegration { certificate_check: true, first_byte_timeout: Duration::from_millis(u64::from(self.config.timeout_ms)), between_bytes_timeout: Duration::from_millis(u64::from(self.config.timeout_ms)), + discriminator: None, }; services.backend().ensure(&spec).change_context(Self::error( diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index 2052215f..75b4693f 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -153,6 +153,10 @@ fn integration_backend_spec( certificate_check, first_byte_timeout, between_bytes_timeout: first_byte_timeout, + // Distinguish this integration's backend from any other provider that + // targets the same origin, so auction response correlation by backend + // name cannot cross providers. + discriminator: Some(integration.to_string()), }) } diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index ee7201fb..d6ffee23 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -649,6 +649,33 @@ pub(crate) fn noop_services_with_client_ip(ip: IpAddr) -> RuntimeServices { .build() } +/// Build a [`RuntimeServices`] with a caller-supplied [`PlatformBackend`] and +/// HTTP client. +/// +/// Lets auction tests inject a backend whose +/// [`PlatformBackend::canonicalize_transport_timeout_ms`] returns a controlled +/// value, so the orchestrator's transport-timeout wiring can be asserted +/// deterministically without depending on wall-clock timing. +pub(crate) fn build_services_with_backend_and_http_client( + backend: Arc, + http_client: Arc, +) -> RuntimeServices { + RuntimeServices::builder() + .config_store(Arc::new(NoopConfigStore)) + .secret_store(Arc::new(NoopSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(backend) + .http_client(http_client) + .geo(Arc::new(NoopGeo)) + .client_info(ClientInfo { + client_ip: None, + tls_protocol: None, + tls_cipher: None, + ..ClientInfo::default() + }) + .build() +} + /// Build a [`RuntimeServices`] with a custom secret store, [`StubBackend`], and HTTP client. pub(crate) fn build_services_with_secret_and_http_client( secret_store: impl PlatformSecretStore + 'static, @@ -856,6 +883,7 @@ mod tests { certificate_check: true, first_byte_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, between_bytes_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, + discriminator: None, }; let name = stub.ensure(&spec).expect("should return a backend name"); assert_eq!(name, "stub-backend", "should return fixed name"); diff --git a/crates/trusted-server-core/src/platform/traits.rs b/crates/trusted-server-core/src/platform/traits.rs index ecc886c9..c6af0a30 100644 --- a/crates/trusted-server-core/src/platform/traits.rs +++ b/crates/trusted-server-core/src/platform/traits.rs @@ -110,6 +110,28 @@ pub trait PlatformBackend: Send + Sync { /// Returns [`PlatformError::Backend`] when the backend cannot be /// registered on the platform. fn ensure(&self, spec: &PlatformBackendSpec) -> Result>; + + /// Canonicalize a per-provider transport timeout for backend-name stability. + /// + /// `remaining_ms` is the wall-clock budget left in the auction and + /// `configured_ms` is the provider's own configured timeout. The returned + /// value is used both to derive the dynamic backend name and as the + /// provider's request deadline, so it must be identical for prediction and + /// registration of the same launch. + /// + /// Adapters that embed the transport timeout in the dynamic backend name + /// (Fastly) override this to round budget-derived values to a coarse + /// ladder, so per-request wall-clock jitter neither defeats cross-request + /// connection pooling nor accumulates registrations toward the per-service + /// dynamic backend limit. + /// + /// The default returns the exact budget-bound value + /// (`remaining_ms.min(configured_ms)`): adapters that neither register nor + /// enforce a backend-name transport timeout gain nothing from rounding and + /// must not shorten bidder deadlines for no benefit. + fn canonicalize_transport_timeout_ms(&self, remaining_ms: u32, configured_ms: u32) -> u32 { + remaining_ms.min(configured_ms) + } } /// Synchronous, object-safe geo lookup. diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs index a81c2410..a39a2643 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -143,6 +143,16 @@ pub struct PlatformBackendSpec { pub first_byte_timeout: Duration, /// Maximum time to wait between response body bytes. pub between_bytes_timeout: Duration, + /// Optional stable discriminator folded into the backend name. + /// + /// Two callers can target the same origin (scheme, host, port, TLS) with + /// the same transport timeout yet need distinct dynamic backends — for + /// example two auction providers behind one gateway host. Because the + /// auction orchestrator correlates responses back to providers by backend + /// name, a shared name would let one provider's response be parsed as + /// another's. Setting this to a per-provider/integration identifier keeps + /// their names distinct while remaining stable across requests. + pub discriminator: Option, } /// Cloneable container of platform services for a single request. diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 19c10a80..d1a5cdc5 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -1099,6 +1099,7 @@ pub async fn handle_asset_proxy_request( certificate_check: settings.proxy.certificate_check, first_byte_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, between_bytes_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, + discriminator: None, }) .change_context(TrustedServerError::Proxy { message: "asset backend registration failed".to_string(), @@ -1289,6 +1290,7 @@ async fn proxy_with_redirects( certificate_check: settings.proxy.certificate_check, first_byte_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, between_bytes_timeout: DEFAULT_FIRST_BYTE_TIMEOUT, + discriminator: None, }) .change_context(TrustedServerError::Proxy { message: "backend registration failed".to_string(), diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 6b0ea3a5..7a2b9b43 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -1368,6 +1368,7 @@ pub async fn handle_publisher_request( certificate_check: settings.proxy.certificate_check, first_byte_timeout: DEFAULT_PUBLISHER_FIRST_BYTE_TIMEOUT, between_bytes_timeout: DEFAULT_PUBLISHER_FIRST_BYTE_TIMEOUT, + discriminator: None, }) .change_context(TrustedServerError::Proxy { message: "backend registration failed".to_string(), From 62a2dd054adf18f13e2157ce9a3df4e18101bf80 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 10 Jul 2026 14:56:06 -0500 Subject: [PATCH 21/44] Add Prebid error diagnostics Prebid non-2xx responses were reduced to bare provider errors, making intermittent failures difficult to diagnose. Surface safe HTTP metadata and bounded debug details while correlating server logs with the auction ID. --- .../src/integrations/prebid.rs | 551 ++++++++++++++---- docs/guide/integrations/prebid.md | 28 +- 2 files changed, 450 insertions(+), 129 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 22c972f5..2e765538 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -62,20 +62,134 @@ const ZONE_KEY: &str = "zone"; /// Default currency for `OpenRTB` bid floors and responses. const DEFAULT_CURRENCY: &str = "USD"; -#[cfg(test)] +const PREBID_ERROR_TYPE_UPSTREAM_HTTP: &str = "upstream_http"; +const PREBID_PUBLIC_ERROR_MESSAGE_CHARS: usize = 500; const PREBID_ERROR_BODY_PREVIEW_CHARS: usize = 1000; - -#[cfg(test)] const PREBID_ERROR_BODY_PREVIEW_BYTES: usize = PREBID_ERROR_BODY_PREVIEW_CHARS * 4; +const PREBID_ERROR_JSON_MAX_DEPTH: usize = 6; +const PREBID_ERROR_JSON_KEYS: [&str; 6] = + ["message", "error", "errors", "detail", "title", "reason"]; + +#[derive(Debug, Eq, PartialEq)] +struct BoundedPrebidErrorText { + text: String, + truncated: bool, +} -#[cfg(test)] -fn prebid_body_preview(body: &[u8]) -> String { +fn bounded_prebid_error_text(value: &str, max_chars: usize) -> Option { + let mut text = String::new(); + let mut char_count = 0; + let mut pending_space = false; + let mut truncated = false; + + for character in value.chars() { + if character.is_whitespace() || character.is_control() { + pending_space = !text.is_empty(); + continue; + } + + if pending_space { + if char_count == max_chars { + truncated = true; + break; + } + text.push(' '); + char_count += 1; + pending_space = false; + } + + if char_count == max_chars { + truncated = true; + break; + } + text.push(character); + char_count += 1; + } + + (!text.is_empty()).then_some(BoundedPrebidErrorText { text, truncated }) +} + +fn prebid_body_preview(body: &[u8]) -> Option { let bounded_body = &body[..body.len().min(PREBID_ERROR_BODY_PREVIEW_BYTES)]; + let mut preview = bounded_prebid_error_text( + &String::from_utf8_lossy(bounded_body), + PREBID_ERROR_BODY_PREVIEW_CHARS, + )?; + preview.truncated |= body.len() > bounded_body.len(); + Some(preview) +} - String::from_utf8_lossy(bounded_body) - .chars() - .take(PREBID_ERROR_BODY_PREVIEW_CHARS) - .collect() +fn nested_prebid_json_error_message( + value: &Json, + depth: usize, + allow_direct_string: bool, +) -> Option<&str> { + if depth > PREBID_ERROR_JSON_MAX_DEPTH { + return None; + } + + match value { + Json::String(message) if allow_direct_string => { + (!message.trim().is_empty()).then_some(message.as_str()) + } + Json::Array(values) => values.iter().find_map(|value| { + nested_prebid_json_error_message(value, depth + 1, allow_direct_string) + }), + Json::Object(values) => PREBID_ERROR_JSON_KEYS + .iter() + .find_map(|key| { + values + .get(*key) + .and_then(|value| nested_prebid_json_error_message(value, depth + 1, true)) + }) + .or_else(|| { + values + .values() + .find_map(|value| nested_prebid_json_error_message(value, depth + 1, false)) + }), + _ => None, + } +} + +fn prebid_json_error_message(value: &Json) -> Option<&str> { + let Json::Object(values) = value else { + return None; + }; + + PREBID_ERROR_JSON_KEYS.iter().find_map(|key| { + values + .get(*key) + .and_then(|value| nested_prebid_json_error_message(value, 0, true)) + }) +} + +fn is_plain_text_content_type(content_type: Option<&str>) -> bool { + content_type.is_some_and(|value| { + value + .split(';') + .next() + .is_some_and(|mime| mime.trim().eq_ignore_ascii_case("text/plain")) + }) +} + +fn extract_prebid_error_message( + body: &[u8], + content_type: Option<&str>, +) -> Option { + let candidate = match serde_json::from_slice::(body) { + Ok(value) => prebid_json_error_message(&value)?.to_owned(), + Err(_) if is_plain_text_content_type(content_type) => { + std::str::from_utf8(body).ok()?.to_owned() + } + Err(_) => return None, + }; + + // Do not expose an HTML error page even if an intermediary labels it as text/plain. + if candidate.trim_start().starts_with('<') { + return None; + } + + bounded_prebid_error_text(&candidate, PREBID_PUBLIC_ERROR_MESSAGE_CHARS) } /// CCPA/US-privacy string sent when the `Sec-GPC` header signals opt-out. @@ -1845,6 +1959,113 @@ impl PrebidAuctionProvider { } } + async fn parse_response_inner( + &self, + response: PlatformResponse, + response_time_ms: u64, + auction_id: Option<&str>, + ) -> Result> { + let response = response.response; + let status = response.status(); + let content_type = response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + + // Parse response — collect_response_bounded caps memory from misbehaving providers. + let body_bytes = collect_response_bounded( + response.into_body(), + UPSTREAM_RTB_MAX_RESPONSE_BYTES, + "prebid", + ) + .await + .change_context(TrustedServerError::Prebid { + message: "Failed to read Prebid response body".to_string(), + })?; + + if !status.is_success() { + let auction_id = auction_id.unwrap_or(""); + log::warn!("Prebid auction {auction_id:?} returned non-success status: {status}"); + + if self.config.debug { + match prebid_body_preview(&body_bytes) { + Some(preview) => { + let truncation = if preview.truncated { + " (truncated)" + } else { + "" + }; + log::warn!( + "Prebid auction {auction_id:?} error response body preview{truncation}: {}", + preview.text + ); + } + None => log::warn!( + "Prebid auction {auction_id:?} returned an empty error response body" + ), + } + } + + let status_code = status.as_u16(); + let mut auction_response = + AuctionResponse::error(PREBID_INTEGRATION_ID, response_time_ms) + .with_metadata( + "error_type", + serde_json::json!(PREBID_ERROR_TYPE_UPSTREAM_HTTP), + ) + .with_metadata("http_status", serde_json::json!(status_code)) + .with_metadata( + "message", + serde_json::json!(format!("Prebid Server returned HTTP {status_code}")), + ); + + if self.config.debug { + if let Some(message) = + extract_prebid_error_message(&body_bytes, content_type.as_deref()) + { + auction_response.metadata.insert( + "upstream_message".to_string(), + serde_json::json!(message.text), + ); + auction_response.metadata.insert( + "upstream_message_truncated".to_string(), + serde_json::json!(message.truncated), + ); + } + } + + return Ok(auction_response); + } + + let response_json: Json = + serde_json::from_slice(&body_bytes).change_context(TrustedServerError::Prebid { + message: "Failed to parse Prebid response".to_string(), + })?; + + // Log the full response body when debug is enabled to surface + // ext.debug.httpcalls, resolvedrequest, bidstatus, errors, etc. + if self.config.debug && log::log_enabled!(log::Level::Trace) { + match serde_json::to_string_pretty(&response_json) { + Ok(json) => log::trace!("Prebid OpenRTB response:\n{json}"), + Err(e) => { + log::warn!("Prebid: failed to serialize response for logging: {e}"); + } + } + } + + let mut auction_response = self.parse_openrtb_response(&response_json, response_time_ms); + self.enrich_response_metadata(&response_json, &mut auction_response); + + log::info!( + "Prebid returned {} bids in {}ms", + auction_response.bids.len(), + response_time_ms + ); + + Ok(auction_response) + } + fn should_suppress_bid_notifications(&self, bidder: &str) -> bool { self.config.suppress_nurl || self @@ -2108,68 +2329,19 @@ impl AuctionProvider for PrebidAuctionProvider { response: PlatformResponse, response_time_ms: u64, ) -> Result> { - let response = response.response; - let status = response.status(); - - // Parse response — collect_response_bounded caps memory from misbehaving providers. - let body_bytes = collect_response_bounded( - response.into_body(), - UPSTREAM_RTB_MAX_RESPONSE_BYTES, - "prebid", - ) - .await - .change_context(TrustedServerError::Prebid { - message: "Failed to read Prebid response body".to_string(), - })?; - - if !status.is_success() { - let body_preview = String::from_utf8_lossy(&body_bytes); - // SECURITY: the PBS response body is upstream-controlled and may leak - // internal detail (hostnames, stack traces, auth hints). Per the - // invariant documented in `auction/orchestrator.rs`, it MUST NOT reach - // the public `/auction` response, which happens if it lands in - // `AuctionResponse.metadata` (cloned verbatim into - // `ext.orchestrator.provider_details[].metadata`). Log the snippet - // server-side and surface only the numeric HTTP status — enough for an - // operator to tell an error from a no-bid without publishing the body. - log::warn!( - "Prebid returned non-success status {status}: {}", - &body_preview[..body_preview.floor_char_boundary(512)] - ); - return Ok(AuctionResponse::error("prebid", response_time_ms) - .with_metadata( - "error_type", - serde_json::json!(crate::auction::orchestrator::ERROR_TYPE_HTTP_STATUS), - ) - .with_metadata("status", serde_json::json!(status.as_u16()))); - } - - let response_json: Json = - serde_json::from_slice(&body_bytes).change_context(TrustedServerError::Prebid { - message: "Failed to parse Prebid response".to_string(), - })?; - - // Log the full response body when debug is enabled to surface - // ext.debug.httpcalls, resolvedrequest, bidstatus, errors, etc. - if self.config.debug && log::log_enabled!(log::Level::Trace) { - match serde_json::to_string_pretty(&response_json) { - Ok(json) => log::trace!("Prebid OpenRTB response:\n{json}"), - Err(e) => { - log::warn!("Prebid: failed to serialize response for logging: {e}"); - } - } - } - - let mut auction_response = self.parse_openrtb_response(&response_json, response_time_ms); - self.enrich_response_metadata(&response_json, &mut auction_response); - - log::info!( - "Prebid returned {} bids in {}ms", - auction_response.bids.len(), - response_time_ms - ); + self.parse_response_inner(response, response_time_ms, None) + .await + } - Ok(auction_response) + async fn parse_response_with_context( + &self, + response: PlatformResponse, + response_time_ms: u64, + request: &AuctionRequest, + _context: &AuctionContext<'_>, + ) -> Result> { + self.parse_response_inner(response, response_time_ms, Some(request.id.as_str())) + .await } fn supports_media_type(&self, media_type: &MediaType) -> bool { @@ -2243,8 +2415,7 @@ mod tests { use super::*; use crate::auction::test_support::create_test_auction_context as shared_test_auction_context; use crate::auction::types::{ - AdFormat, AdSlot, AuctionContext, AuctionRequest, BidStatus, DeviceInfo, PublisherInfo, - UserInfo, + AdFormat, AdSlot, AuctionContext, AuctionRequest, DeviceInfo, PublisherInfo, UserInfo, }; use crate::consent::{ConsentContext, ConsentSource}; @@ -2352,50 +2523,6 @@ mod tests { ); } - #[test] - fn parse_response_attaches_status_metadata_without_leaking_body_on_http_error() { - let provider = PrebidAuctionProvider::new(base_config()); - let response = PlatformResponse::new( - edgezero_core::http::response_builder() - .status(403) - .body(EdgeBody::from( - br#"{"error":"upstream-secret-detail"}"#.to_vec(), - )) - .expect("should build test response"), - ); - - let result = futures::executor::block_on(provider.parse_response(response, 643)) - .expect("should return Ok(error response) for non-success status"); - - assert_eq!( - result.status, - BidStatus::Error, - "non-success HTTP status should map to an error response" - ); - assert_eq!( - result.metadata["error_type"], - json!("http_status"), - "should tag the error path so telemetry buckets it as an http status error" - ); - assert_eq!( - result.metadata["status"], - json!(403), - "should surface the upstream HTTP status code" - ); - // SECURITY: the upstream response body must never reach the public - // /auction response via AuctionResponse.metadata. - assert!( - !result.metadata.contains_key("body"), - "upstream response body must not be surfaced on the response metadata" - ); - assert!( - !result.metadata.values().any(|v| v - .as_str() - .is_some_and(|s| s.contains("upstream-secret-detail"))), - "no metadata value may contain the upstream body" - ); - } - fn test_sri(algorithm: &str, digest: &[u8]) -> String { format!("{algorithm}-{}", TEST_BASE64_STANDARD.encode(digest)) } @@ -2430,6 +2557,23 @@ mod tests { .expect("should parse response body as utf-8") } + fn prebid_platform_response( + status: StatusCode, + content_type: Option<&str>, + body: impl Into>, + ) -> PlatformResponse { + let mut builder = http::Response::builder().status(status); + if let Some(content_type) = content_type { + builder = builder.header(header::CONTENT_TYPE, content_type); + } + + PlatformResponse::new( + builder + .body(EdgeBody::from(body.into())) + .expect("should build Prebid platform response"), + ) + } + fn create_test_auction_request() -> AuctionRequest { AuctionRequest { id: "auction-123".to_string(), @@ -4835,27 +4979,42 @@ external_bundle_sri = "sha384-AAAA" ); } + #[test] + fn bounded_prebid_error_text_normalizes_control_characters_and_whitespace() { + let message = bounded_prebid_error_text("\n invalid\trequest\0 payload \r\n", 100) + .expect("should extract bounded text"); + + assert_eq!( + message.text, "invalid request payload", + "should make upstream text safe for one-line responses and logs" + ); + assert!(!message.truncated, "should retain the complete message"); + } + #[test] fn prebid_body_preview_truncates_to_character_limit() { let body = "x".repeat(PREBID_ERROR_BODY_PREVIEW_CHARS + 100); - let preview = prebid_body_preview(body.as_bytes()); + let preview = prebid_body_preview(body.as_bytes()).expect("should build body preview"); assert_eq!( - preview.chars().count(), + preview.text.chars().count(), PREBID_ERROR_BODY_PREVIEW_CHARS, "should cap the upstream body preview" ); + assert!(preview.truncated, "should report body preview truncation"); } #[test] fn prebid_body_preview_handles_non_utf8_lossily() { - let preview = prebid_body_preview(&[b'o', b'k', 0xff, b'!']); + let preview = + prebid_body_preview(&[b'o', b'k', 0xff, b'!']).expect("should build body preview"); assert_eq!( - preview, "ok\u{fffd}!", + preview.text, "ok\u{fffd}!", "should replace invalid UTF-8 bytes without panicking" ); + assert!(!preview.truncated, "should retain the complete preview"); } #[test] @@ -4863,17 +5022,18 @@ external_bundle_sri = "sha384-AAAA" let mut body = vec![b'x'; PREBID_ERROR_BODY_PREVIEW_BYTES]; body.extend_from_slice(&[0xff, b't', b'a', b'i', b'l']); - let preview = prebid_body_preview(&body); + let preview = prebid_body_preview(&body).expect("should build body preview"); assert_eq!( - preview.chars().count(), + preview.text.chars().count(), PREBID_ERROR_BODY_PREVIEW_CHARS, - "should keep the public preview capped" + "should keep the log preview capped" ); assert!( - !preview.contains('\u{fffd}') && !preview.contains("tail"), + !preview.text.contains('\u{fffd}') && !preview.text.contains("tail"), "should not process bytes beyond the bounded preview slice" ); + assert!(preview.truncated, "should report bounded-slice truncation"); } #[test] @@ -4882,17 +5042,152 @@ external_bundle_sri = "sha384-AAAA" body.extend_from_slice("\u{2603}".as_bytes()); body.extend_from_slice(b"tail"); - let preview = prebid_body_preview(&body); + let preview = prebid_body_preview(&body).expect("should build body preview"); assert_eq!( - preview.chars().count(), + preview.text.chars().count(), PREBID_ERROR_BODY_PREVIEW_CHARS, - "should keep the public preview capped" + "should keep the log preview capped" ); assert!( - !preview.contains("tail"), + !preview.text.contains("tail"), "should not include bytes beyond the bounded preview slice" ); + assert!(preview.truncated, "should report partial-body truncation"); + } + + #[test] + fn extract_prebid_error_message_reads_nested_json_message() { + let body = br#"{ + "errors": { + "exampleBidder": [{"code": 1, "message": " invalid\nrequest "}] + } + }"#; + + let message = extract_prebid_error_message(body, Some("application/json")) + .expect("should extract nested JSON error message"); + + assert_eq!(message.text, "invalid request"); + assert!(!message.truncated, "should retain the complete message"); + } + + #[test] + fn extract_prebid_error_message_reads_plain_text() { + let message = extract_prebid_error_message( + b" request rejected\r\nby Prebid Server ", + Some("Text/Plain; charset=utf-8"), + ) + .expect("should extract plain-text error message"); + + assert_eq!(message.text, "request rejected by Prebid Server"); + assert!(!message.truncated, "should retain the complete message"); + } + + #[test] + fn extract_prebid_error_message_rejects_html_and_unknown_json_fields() { + assert!( + extract_prebid_error_message( + b"internal proxy error", + Some("text/plain"), + ) + .is_none(), + "should not expose HTML error pages" + ); + + for body in [ + br#"{"resolvedrequest":{"account":"internal"}}"#.as_slice(), + br#"{"errors":{"resolvedrequest":{"account":"internal"}}}"#.as_slice(), + br#""internal""#.as_slice(), + br#"["internal"]"#.as_slice(), + ] { + assert!( + extract_prebid_error_message(body, Some("application/json")).is_none(), + "should only expose strings associated with allowlisted JSON error fields" + ); + } + } + + #[test] + fn extract_prebid_error_message_truncates_public_message() { + let body = serde_json::to_vec(&json!({ + "message": "x".repeat(PREBID_PUBLIC_ERROR_MESSAGE_CHARS + 100), + })) + .expect("should serialize test error response"); + + let message = extract_prebid_error_message(&body, Some("application/json")) + .expect("should extract JSON error message"); + + assert_eq!( + message.text.chars().count(), + PREBID_PUBLIC_ERROR_MESSAGE_CHARS, + "should cap the browser-visible upstream message" + ); + assert!(message.truncated, "should report public message truncation"); + } + + #[test] + fn non_success_prebid_response_always_includes_safe_http_metadata() { + let provider = PrebidAuctionProvider::new(base_config()); + let response = prebid_platform_response( + StatusCode::BAD_REQUEST, + Some("application/json"), + br#"{"message":"request details should remain hidden"}"#.to_vec(), + ); + + let auction_response = futures::executor::block_on(provider.parse_response(response, 42)) + .expect("should convert upstream HTTP failure to auction response"); + + assert_eq!( + auction_response.status, + crate::auction::types::BidStatus::Error + ); + assert_eq!( + auction_response.metadata["error_type"], + json!(PREBID_ERROR_TYPE_UPSTREAM_HTTP) + ); + assert_eq!(auction_response.metadata["http_status"], json!(400)); + assert_eq!( + auction_response.metadata["message"], + json!("Prebid Server returned HTTP 400") + ); + assert!( + !auction_response.metadata.contains_key("upstream_message"), + "should hide upstream text when Prebid debug is disabled" + ); + } + + #[test] + fn debug_non_success_prebid_response_includes_bounded_upstream_message() { + let mut config = base_config(); + config.debug = true; + let provider = PrebidAuctionProvider::new(config); + let response = prebid_platform_response( + StatusCode::UNPROCESSABLE_ENTITY, + Some("application/json; charset=utf-8"), + br#"{"error":{"message":"imp[0] has no valid bidders"}}"#.to_vec(), + ); + let settings = make_settings(); + let http_request = build_test_request(); + let context = create_test_auction_context(&settings, &http_request); + let auction_request = create_test_auction_request(); + + let auction_response = futures::executor::block_on(provider.parse_response_with_context( + response, + 66, + &auction_request, + &context, + )) + .expect("should convert upstream HTTP failure to debug auction response"); + + assert_eq!(auction_response.metadata["http_status"], json!(422)); + assert_eq!( + auction_response.metadata["upstream_message"], + json!("imp[0] has no valid bidders") + ); + assert_eq!( + auction_response.metadata["upstream_message_truncated"], + json!(false) + ); } fn make_auction_request(slots: Vec) -> AuctionRequest { diff --git a/docs/guide/integrations/prebid.md b/docs/guide/integrations/prebid.md index c1e85a55..b8340cfb 100644 --- a/docs/guide/integrations/prebid.md +++ b/docs/guide/integrations/prebid.md @@ -132,8 +132,34 @@ The Prebid provider extracts metadata from the Prebid Server response and attach | `debug` | `ext.debug` | Prebid Server debug payload (httpcalls, resolvedrequest) | | `bidstatus` | `ext.prebid.bidstatus` | Per-bid status from every invited bidder | +### Upstream HTTP errors + +When Prebid Server returns a non-2xx status, the provider detail always includes a safe error classification, HTTP status, and generic message: + +```json +{ + "error_type": "upstream_http", + "http_status": 400, + "message": "Prebid Server returned HTTP 400" +} +``` + +With `debug = true`, Trusted Server also extracts the first error message from allowlisted JSON fields (`message`, `error`, `errors`, `detail`, `title`, or `reason`) or a plain-text response. The message is normalized to one line and limited to 500 characters: + +```json +{ + "error_type": "upstream_http", + "http_status": 400, + "message": "Prebid Server returned HTTP 400", + "upstream_message": "Invalid request: imp[0] has no valid bidders", + "upstream_message_truncated": false +} +``` + +HTML error pages and unrecognized JSON payloads are not exposed. Debug mode also writes a bounded error-body preview to `tslog`, correlated with the auction ID. + ::: warning -Enabling `debug` increases response sizes and adds overhead. Use it in development or when diagnosing auction issues — not in production. +Enabling `debug` increases response sizes and adds overhead. It can also expose bounded upstream diagnostics to `/auction` callers and logs. Use it temporarily when diagnosing auction issues, not as a permanent production setting. ::: ### Test mode vs. debug From ef619539659d9126c11d6f0b22892af91c9b1163 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Tue, 14 Jul 2026 23:20:24 +0530 Subject: [PATCH 22/44] Suppress fabricated empty Prebid bidder params A configured bidder with no inline params and no matching override expanded to `"bidder": {}`, which PBS rejects. After applying overrides, drop fabricated empty bidders, preserve an explicitly supplied empty object so genuine misconfiguration stays visible, and fall back to the stored-request path when no eligible bidders remain. --- .../src/integrations/prebid.rs | 179 ++++++++++++++++-- 1 file changed, 163 insertions(+), 16 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 32a1c2a8..68d718a1 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::time::Duration; @@ -1424,10 +1424,22 @@ impl PrebidAuctionProvider { // Only pass through keys that are known PBS bidders — skip provider-specific // keys like "aps" which belong to their own separate auction provider. let mut bidder: HashMap = HashMap::new(); + // Bidders the publisher explicitly supplied — including an + // explicit empty `{}`. A configured bidder that exists only + // because `expand_trusted_server_bidders` fabricated an empty + // params object is NOT explicit and must not ship as + // `"bidder": {}` (which PBS rejects). + let mut explicit_bidders: HashSet = HashSet::new(); for (name, params) in &slot.bidders { if name == TRUSTED_SERVER_BIDDER { + if let Some(per_bidder) = + params.get(BIDDER_PARAMS_KEY).and_then(Json::as_object) + { + explicit_bidders.extend(per_bidder.keys().cloned()); + } bidder.extend(expand_trusted_server_bidders(&self.config.bidders, params)); } else if self.config.bidders.iter().any(|b| b == name) { + explicit_bidders.insert(name.clone()); bidder.insert(name.clone(), params.clone()); } else if name != "aps" { // `aps` is intentionally handled by its own provider. Any @@ -1443,16 +1455,32 @@ impl PrebidAuctionProvider { } } - // When no inline PBS bidder params exist (e.g. creative-opportunity slots - // whose PBS params live in stored requests), tell PBS to resolve bidder - // config from the stored request keyed by this slot ID. + // Apply canonical and compatibility-derived rules in normalized + // order. An override rule can populate a bidder that arrived with + // empty params, promoting a fabricated empty into a valid bidder. + for (name, params) in &mut bidder { + self.bid_param_override_engine + .apply(BidParamOverrideFacts { bidder: name, zone }, params); + } + + // Drop bidders that are still an empty object after overrides and + // were not explicitly supplied. Shipping `"bidder": {}` makes PBS + // reject the imp; an explicit empty object is preserved so genuine + // publisher misconfiguration stays visible. + bidder.retain(|name, params| { + let is_empty_object = params.as_object().is_some_and(serde_json::Map::is_empty); + !is_empty_object || explicit_bidders.contains(name) + }); + + // When no eligible PBS bidder params remain (e.g. creative-opportunity + // slots whose PBS params live in stored requests, or a slot whose + // configured bidders all resolved to fabricated empties), tell PBS to + // resolve bidder config from the stored request keyed by this slot ID. // - // This cannot fire for the client /auction path: the JS adapter - // injects a `trustedServer` entry into every ad unit, so `bidder` - // is only empty for server-side creative-opportunity slots with - // no inline provider params (or when `config.bidders` is empty, - // where PBS previously received an empty bidder map and returned - // no bids — a stored-request miss is the same no-bid outcome). + // This cannot fire for a client /auction slot that carries real + // inline params: the JS adapter injects a `trustedServer` entry, and + // any bidder with params survives the drop above. It falls back only + // when nothing eligible remains — the same no-bid outcome as before. let storedrequest = if bidder.is_empty() { Some(ImpStoredRequest { id: slot.id.clone(), @@ -1461,12 +1489,6 @@ impl PrebidAuctionProvider { None }; - // Apply canonical and compatibility-derived rules in normalized order. - for (name, params) in &mut bidder { - self.bid_param_override_engine - .apply(BidParamOverrideFacts { bidder: name, zone }, params); - } - Some(Imp { id: Some(slot.id.clone()), banner: Some(Banner { @@ -5950,6 +5972,131 @@ set = { placementId = "explicit_header" } ); } + #[test] + fn to_openrtb_drops_fabricated_empty_bidder_params() { + // config.bidders lists three, but the slot supplies inline params only + // for kargo. Without a matching override, triplelift and criteo would + // expand to empty `{}` objects — invalid bidder entries PBS rejects. + // They must be dropped; the valid kargo bidder must still ship. + let config = parse_prebid_toml( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +bidders = ["kargo", "triplelift", "criteo"] +"#, + ); + + let slot = make_ts_slot( + "ad-header-0", + &json!({ "kargo": { "placementId": "kn1" } }), + None, + ); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(config, &request); + let params = bidder_params(&ortb); + + assert_eq!( + params["kargo"]["placementId"], "kn1", + "should keep the valid inline bidder" + ); + assert!( + !params.contains_key("triplelift"), + "should drop a fabricated empty bidder with no inline params or override" + ); + assert!( + !params.contains_key("criteo"), + "should drop a fabricated empty bidder with no inline params or override" + ); + } + + #[test] + fn to_openrtb_preserves_an_explicitly_empty_bidder() { + // A publisher-supplied empty `{}` is a real (if misconfigured) signal and + // must survive so the misconfiguration stays visible — unlike a fabricated + // empty, which is dropped. + let config = parse_prebid_toml( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +bidders = ["kargo"] +"#, + ); + + let slot = make_ts_slot("ad-header-0", &json!({ "kargo": {} }), None); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(config, &request); + let params = bidder_params(&ortb); + + assert_eq!( + params["kargo"], + json!({}), + "should preserve an explicitly supplied empty bidder object" + ); + } + + #[test] + fn to_openrtb_keeps_a_fabricated_bidder_that_an_override_populates() { + // criteo has no inline params (fabricated empty), but an override rule + // fills it — so it is valid and must ship, not be dropped. + let config = parse_prebid_toml( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +bidders = ["criteo"] + +[integrations.prebid.bid_param_overrides.criteo] +networkId = 99999 +"#, + ); + + let slot = make_ts_slot("ad-header-0", &json!({}), None); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(config, &request); + let params = bidder_params(&ortb); + + assert_eq!( + params["criteo"]["networkId"], 99999, + "override should populate the fabricated empty bidder, keeping it" + ); + } + + #[test] + fn to_openrtb_falls_back_to_stored_request_when_all_bidders_are_fabricated_empty() { + // config.bidders present, but the slot supplies no inline params and no + // override matches — every configured bidder resolves to a fabricated + // empty and is dropped, leaving PBS to resolve via the stored request. + let config = parse_prebid_toml( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +bidders = ["kargo", "triplelift"] +"#, + ); + + let slot = make_ts_slot("ad-header-0", &json!({}), None); + let request = make_auction_request(vec![slot]); + + let ortb = call_to_openrtb(config, &request); + let ext = ortb.imp[0].ext.as_ref().expect("should have imp ext"); + let prebid = ext.get("prebid").expect("should have prebid in ext"); + + assert!( + prebid.get("bidder").is_none(), + "should drop all fabricated empty bidders" + ); + assert_eq!( + prebid["storedrequest"]["id"], "ad-header-0", + "should fall back to stored request when no eligible bidders remain" + ); + } + #[test] fn to_openrtb_skips_aps_key_from_slot_bidders_in_pbs_request() { let slot = make_slot( From 0acac4b207f26697084c8ca56368615136625911 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 14 Jul 2026 14:07:32 -0500 Subject: [PATCH 23/44] Preserve Prebid ad units across GPT refreshes --- .../lib/src/integrations/prebid/index.ts | 239 +++++++- .../test/integrations/prebid/index.test.ts | 509 ++++++++++++++++++ 2 files changed, 731 insertions(+), 17 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index be839d8f..65932d5b 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -228,6 +228,17 @@ type TrustedServerAdUnit = { mediaTypes?: { banner?: TrustedServerBanner }; bids?: TrustedServerBid[]; }; +type ClientSideBidSnapshot = { bidder: string; params: Record }; +type PublisherAdUnitSnapshot = { + bidderParams: Record>; + clientSideBids: ClientSideBidSnapshot[]; + zone?: string; +}; +type PublisherDeliveryContext = { remainingCodes: Set }; + +let publisherAdUnitSnapshots = new Map(); +let syntheticRefreshAdUnits = new WeakSet(); +const activePublisherDeliveryContexts: PublisherDeliveryContext[] = []; type TrustedServerBidRequest = { adUnitCode?: string; code?: string; @@ -363,6 +374,17 @@ function firstTargetingValue(values: string[] | undefined): string | undefined { * code in order and return the first matching ad unit, so container-backed slots * still recover the publisher's configured params and bidders. */ +function findRefreshSnapshot( + candidateCodes: Array +): PublisherAdUnitSnapshot | undefined { + for (const code of candidateCodes) { + if (!code) continue; + const snapshot = publisherAdUnitSnapshots.get(code); + if (snapshot) return snapshot; + } + return undefined; +} + function findRefreshAdUnit( candidateCodes: Array ): TrustedServerAdUnit | undefined { @@ -375,6 +397,89 @@ function findRefreshAdUnit( return undefined; } +function copyParamValue(value: unknown, seen = new WeakMap()): unknown { + if (Array.isArray(value)) { + const existing = seen.get(value); + if (existing) return existing; + const copy: unknown[] = []; + seen.set(value, copy); + value.forEach((entry) => copy.push(copyParamValue(entry, seen))); + return copy; + } + + if (value && typeof value === 'object') { + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return value; + + const existing = seen.get(value); + if (existing) return existing; + const copy = Object.create(prototype) as Record; + seen.set(value, copy); + for (const [key, entry] of Object.entries(value)) { + Object.defineProperty(copy, key, { + value: copyParamValue(entry, seen), + enumerable: true, + configurable: true, + writable: true, + }); + } + return copy; + } + + return value; +} + +function copyParams(params: Record | undefined): Record { + return copyParamValue(params ?? {}) as Record; +} + +function foldedBidderParams( + bid: TrustedServerBid | undefined +): Record> { + const folded = (bid?.params?.[BIDDER_PARAMS_KEY] ?? {}) as Record< + string, + Record + >; + return Object.fromEntries( + Object.entries(folded).map(([bidder, params]) => [bidder, copyParams(params)]) + ); +} + +function capturePublisherAdUnitSnapshot( + unit: TrustedServerAdUnit, + clientSideBidders: Set +): PublisherAdUnitSnapshot | undefined { + if (typeof unit.code !== 'string' || unit.code.length === 0) return undefined; + + const rawBidderParams: Record> = {}; + const clientSideBids: ClientSideBidSnapshot[] = []; + let existingTsBid: TrustedServerBid | undefined; + + const bids = Array.isArray(unit.bids) ? unit.bids : []; + for (const bid of bids) { + if (!bid?.bidder) continue; + if (bid.bidder === ADAPTER_CODE) { + existingTsBid ??= bid; + continue; + } + if (clientSideBidders.has(bid.bidder)) { + clientSideBids.push({ bidder: bid.bidder, params: copyParams(bid.params) }); + continue; + } + rawBidderParams[bid.bidder] = copyParams(bid.params); + } + + const bidderParams = + Object.keys(rawBidderParams).length > 0 ? rawBidderParams : foldedBidderParams(existingTsBid); + const zone = unit.mediaTypes?.banner?.name; + + return { + bidderParams, + clientSideBids, + ...(zone ? { zone } : {}), + }; +} + /** * Collect the configured client-side bidder entries for a refreshing slot. * @@ -389,6 +494,14 @@ function findRefreshAdUnit( function clientSideBidsForRefresh( candidateCodes: Array ): Array<{ bidder: string; params: Record }> { + const snapshot = findRefreshSnapshot(candidateCodes); + if (snapshot) { + return snapshot.clientSideBids.map((bid) => ({ + bidder: bid.bidder, + params: copyParams(bid.params), + })); + } + const clientSideBidders = new Set(getInjectedConfig()?.clientSideBidders ?? []); if (clientSideBidders.size === 0) return []; @@ -398,7 +511,7 @@ function clientSideBidsForRefresh( const bids: Array<{ bidder: string; params: Record }> = []; for (const bid of match.bids) { if (bid?.bidder && clientSideBidders.has(bid.bidder)) { - bids.push({ bidder: bid.bidder, params: bid.params ?? {} }); + bids.push({ bidder: bid.bidder, params: copyParams(bid.params) }); } } return bids; @@ -420,6 +533,13 @@ function clientSideBidsForRefresh( function serverSideBidderParamsForRefresh( candidateCodes: Array ): Record> { + const snapshot = findRefreshSnapshot(candidateCodes); + if (snapshot) { + return Object.fromEntries( + Object.entries(snapshot.bidderParams).map(([bidder, params]) => [bidder, copyParams(params)]) + ); + } + const match = findRefreshAdUnit(candidateCodes); if (!match?.bids) return {}; @@ -456,6 +576,50 @@ function clearRefreshTargeting(slot: RefreshGptSlot): void { } } +function removePublisherDeliveryContext(context: PublisherDeliveryContext): void { + const index = activePublisherDeliveryContexts.lastIndexOf(context); + if (index >= 0) activePublisherDeliveryContexts.splice(index, 1); +} + +function consumeBarePublisherDeliveryContext(): boolean { + for (let index = activePublisherDeliveryContexts.length - 1; index >= 0; index -= 1) { + const context = activePublisherDeliveryContexts[index]; + if (context.remainingCodes.size === 0) continue; + context.remainingCodes.clear(); + return true; + } + return false; +} + +function consumeExplicitPublisherDeliveryContext(targetSlots: RefreshGptSlot[]): boolean { + if (targetSlots.length === 0) return false; + + for (let index = activePublisherDeliveryContexts.length - 1; index >= 0; index -= 1) { + const context = activePublisherDeliveryContexts[index]; + const coveredCodes: string[] = []; + let allCovered = true; + + for (const slot of targetSlots) { + const injectedSlot = findInjectedSlotForRefresh(slot); + const candidates = [refreshSlotElementId(slot), injectedSlot?.div_id]; + const coveredCode = candidates.find( + (code): code is string => !!code && context.remainingCodes.has(code) + ); + if (!coveredCode) { + allCovered = false; + break; + } + coveredCodes.push(coveredCode); + } + + if (!allCovered) continue; + coveredCodes.forEach((code) => context.remainingCodes.delete(code)); + return true; + } + + return false; +} + function collectAuctionEids(): AuctionEid[] | undefined { if (typeof pbjs.getUserIdsAsEids !== 'function') { return undefined; @@ -492,6 +656,10 @@ function collectAuctionEids(): AuctionEid[] | undefined { * 2. `config` argument — explicit overrides from the publisher's JS */ export function installPrebidNpm(config?: Partial): typeof pbjs { + publisherAdUnitSnapshots = new Map(); + syntheticRefreshAdUnits = new WeakSet(); + activePublisherDeliveryContexts.length = 0; + const injected = getInjectedConfig(); const merged: PrebidNpmConfig = { endpoint: config?.endpoint, @@ -563,9 +731,20 @@ export function installPrebidNpm(config?: Partial): typeof pbjs const opts = requestObj || {}; // eslint-disable-next-line @typescript-eslint/no-explicit-any const adUnits = ((opts as any).adUnits || pbjs.adUnits || []) as TrustedServerAdUnit[]; + const isSyntheticRefresh = + adUnits.length > 0 && adUnits.every((unit) => syntheticRefreshAdUnits.has(unit)); + const publisherAdUnitCodes = new Set(); // Ensure every ad unit has a trustedServer bid entry for (const unit of adUnits) { + if (!syntheticRefreshAdUnits.has(unit)) { + const snapshot = capturePublisherAdUnitSnapshot(unit, clientSideBidders); + if (snapshot && unit.code) { + publisherAdUnitSnapshots.set(unit.code, snapshot); + publisherAdUnitCodes.add(unit.code); + } + } + if (!Array.isArray(unit.bids)) { unit.bids = []; } @@ -649,8 +828,22 @@ export function installPrebidNpm(config?: Partial): typeof pbjs const originalBidsBack = opts.bidsBackHandler; opts.bidsBackHandler = function (...args: unknown[]) { syncPrebidEidsCookie(); - if (typeof originalBidsBack === 'function') { - originalBidsBack.apply(this, args); + if (typeof originalBidsBack !== 'function') return; + if (isSyntheticRefresh || publisherAdUnitCodes.size === 0) { + originalBidsBack.apply(this, args as Parameters); + return; + } + + const context: PublisherDeliveryContext = { + remainingCodes: new Set(publisherAdUnitCodes), + }; + // Delivery attribution is intentionally synchronous and ends as soon as + // the publisher's original callback returns. + activePublisherDeliveryContexts.push(context); + try { + originalBidsBack.apply(this, args as Parameters); + } finally { + removePublisherDeliveryContext(context); } }; @@ -734,6 +927,14 @@ export function installRefreshHandler(timeoutMs = 1500): void { const originalRefresh = pubads.refresh.bind(pubads); pubads.refresh = function (slots?: unknown[], opts?: unknown) { + // For bare refresh() calls (no slots arg), get all registered slots from GPT + // so we can auction the same concrete slot list and avoid stale targeting. + const targetSlots = ( + slots ?? + (pubads as { getSlots?: () => unknown[] }).getSlots?.() ?? + [] + ).filter((slot): slot is RefreshGptSlot => typeof slot === 'object' && slot !== null); + // One-shot bypass for adInit()'s internal refresh: that refresh delivers // freshly applied server-side targeting to GAM and must not be turned // into a client-side auction (which would clear the TS targeting). @@ -743,13 +944,14 @@ export function installRefreshHandler(timeoutMs = 1500): void { return originalRefresh(slots, opts); } - // For bare refresh() calls (no slots arg), get all registered slots from GPT - // so we can auction the same concrete slot list and avoid stale targeting. - const targetSlots = ( - slots ?? - (pubads as { getSlots?: () => unknown[] }).getSlots?.() ?? - [] - ).filter((slot): slot is RefreshGptSlot => typeof slot === 'object' && slot !== null); + const isExplicitSlotList = slots !== undefined; + const hasOnlyValidExplicitSlots = !isExplicitSlotList || targetSlots.length === slots.length; + const isPublisherDeliveryRefresh = isExplicitSlotList + ? hasOnlyValidExplicitSlots && consumeExplicitPublisherDeliveryContext(targetSlots) + : consumeBarePublisherDeliveryContext(); + if (isPublisherDeliveryRefresh) { + return originalRefresh(slots, opts); + } if (!targetSlots.length) { return originalRefresh(slots, opts); @@ -759,8 +961,16 @@ export function installRefreshHandler(timeoutMs = 1500): void { const adUnits = targetSlots.map((slot) => { const injectedSlot = findInjectedSlotForRefresh(slot); + const code = refreshSlotElementId(slot) ?? 'refresh-slot'; + // A TS-owned slot may be defined on `${div_id}-container`, so the GPT + // element id used as the synthetic refresh code can differ from the + // inner `div_id` the publisher keyed their ad unit by. Recover from both. + const candidateCodes = [code, injectedSlot?.div_id]; + const snapshot = findRefreshSnapshot(candidateCodes); const zone = - injectedSlot?.targeting?.[ZONE_KEY] ?? firstTargetingValue(slot.getTargeting?.(ZONE_KEY)); + injectedSlot?.targeting?.[ZONE_KEY] ?? + firstTargetingValue(slot.getTargeting?.(ZONE_KEY)) ?? + snapshot?.zone; const banner: TrustedServerBanner = { sizes: bannerSizesFromInjectedSlot(injectedSlot) ?? @@ -768,12 +978,6 @@ export function installRefreshHandler(timeoutMs = 1500): void { DEFAULT_REFRESH_SIZES, ...(zone ? { name: zone } : {}), }; - - const code = refreshSlotElementId(slot) ?? 'refresh-slot'; - // A TS-owned slot may be defined on `${div_id}-container`, so the GPT - // element id used as the synthetic refresh code can differ from the - // inner `div_id` the publisher keyed their ad unit by. Recover from both. - const candidateCodes = [code, injectedSlot?.div_id]; const tsParams: Record = zone ? { [ZONE_KEY]: zone } : {}; // Carry the publisher's inline server-side (PBS) bidder params captured // on the initial ad unit so refresh/scroll auctions don't drop them. @@ -796,6 +1000,7 @@ export function installRefreshHandler(timeoutMs = 1500): void { // unrelated GPT slots whose targeting this wrapper only cleared for // `targetSlots` — leaving their next request dependent on stale state. const refreshAdUnitCodes = adUnits.map((unit) => unit.code); + adUnits.forEach((unit) => syntheticRefreshAdUnits.add(unit)); pbjs.requestBids({ adUnits, bidsBackHandler: () => { diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 9ad7945c..6435d970 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -617,6 +617,15 @@ describe('prebid/installPrebidNpm', () => { expect(adUnits[0].bids[0].bidder).toBe('trustedServer'); }); + it('normalizes a truthy non-array bids value without throwing', () => { + const pbjs = installPrebidNpm(); + const adUnits = [{ code: 'example-malformed-slot', bids: { malformed: true } }] as any[]; + + expect(() => pbjs.requestBids({ adUnits } as any)).not.toThrow(); + + expect(adUnits[0].bids).toEqual([{ bidder: 'trustedServer', params: { bidderParams: {} } }]); + }); + it('includes zone from mediaTypes.banner.name in trustedServer params', () => { const pbjs = installPrebidNpm(); @@ -1352,6 +1361,506 @@ describe('prebid/installRefreshHandler', () => { }); }); +describe('prebid publisher snapshots and delivery refreshes', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockRequestBids.mockReset(); + mockPbjs.requestBids = mockRequestBids; + mockPbjs.adUnits = []; + mockGetUserIdsAsEids.mockReset(); + mockGetUserIdsAsEids.mockReturnValue([]); + mockGetBidAdapter.mockReturnValue({}); + delete (mockPbjs as any).setTargetingForGPTAsync; + delete (window as any).__tsjs_prebid; + (window as any).tsjs = undefined; + delete (window as any).googletag; + }); + + afterEach(() => { + delete (window as any).__tsjs_prebid; + (window as any).tsjs = undefined; + delete (window as any).googletag; + }); + + function installGpt(slots: any[]) { + const originalRefresh = vi.fn(); + const pubads = { + refresh: originalRefresh, + getSlots: vi.fn(() => slots), + }; + (window as any).googletag = { + cmd: { push: (fn: () => void) => fn() }, + pubads: () => pubads, + }; + installRefreshHandler(640); + return { originalRefresh, pubads }; + } + + function refreshAdUnitFromLastRequest(): any { + const lastCall = mockRequestBids.mock.calls[mockRequestBids.mock.calls.length - 1]; + return lastCall?.[0]?.adUnits?.[0]; + } + + it('recovers inline params, ordered client bids, and zone when pbjs.adUnits is empty', () => { + (window as any).__tsjs_prebid = { clientSideBidders: ['exampleBrowser'] }; + const runtimeInstance = 'example-runtime-instance'; + const code = `example-slot-${runtimeInstance}`; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [{ getWidth: () => 320, getHeight: () => 100 }], + clearTargeting: vi.fn(), + }; + const { pubads } = installGpt([slot]); + const pbjs = installPrebidNpm(); + const firstParams = { placement: 'first' }; + const effectiveParams = { placement: 'effective' }; + + pbjs.requestBids({ + adUnits: [ + { + code, + mediaTypes: { banner: { name: 'example-zone', sizes: [[320, 100]] } }, + bids: [ + { bidder: 'exampleServer', params: firstParams }, + { bidder: 'exampleBrowser', params: { placement: 'browser-one' } }, + { bidder: 'exampleServer', params: effectiveParams }, + { bidder: 'exampleBrowser', params: { placement: 'browser-two' } }, + ], + }, + ], + } as any); + effectiveParams.placement = 'changed-after-auction'; + + pubads.refresh([slot]); + + expect(mockPbjs.adUnits).toEqual([]); + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(refreshAdUnitFromLastRequest()).toEqual({ + code, + mediaTypes: { banner: { name: 'example-zone', sizes: [[320, 100]] } }, + bids: [ + { + bidder: 'trustedServer', + params: { + bidderParams: { exampleServer: { placement: 'effective' } }, + zone: 'example-zone', + }, + }, + { bidder: 'exampleBrowser', params: { placement: 'browser-one' } }, + { bidder: 'exampleBrowser', params: { placement: 'browser-two' } }, + ], + }); + }); + + it('isolates nested bidder-param objects and arrays from later publisher mutation', () => { + (window as any).__tsjs_prebid = { clientSideBidders: ['exampleBrowser'] }; + const code = 'example-nested-params-slot'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { pubads } = installGpt([slot]); + const pbjs = installPrebidNpm(); + const serverParams = { + placement: { + rules: [{ label: 'original-rule' }], + sizes: [300, 250], + }, + }; + const browserParams = { + groups: [{ values: ['original-value'] }], + }; + + pbjs.requestBids({ + adUnits: [ + { + code, + bids: [ + { bidder: 'exampleServer', params: serverParams }, + { bidder: 'exampleBrowser', params: browserParams }, + ], + }, + ], + } as any); + serverParams.placement.rules[0].label = 'changed-rule'; + serverParams.placement.sizes.push(999); + browserParams.groups[0].values[0] = 'changed-value'; + + pubads.refresh([slot]); + + const expectedBids = [ + { + bidder: 'trustedServer', + params: { + bidderParams: { + exampleServer: { + placement: { + rules: [{ label: 'original-rule' }], + sizes: [300, 250], + }, + }, + }, + }, + }, + { + bidder: 'exampleBrowser', + params: { groups: [{ values: ['original-value'] }] }, + }, + ]; + const firstRefreshBids = refreshAdUnitFromLastRequest().bids; + expect(firstRefreshBids).toEqual(expectedBids); + + firstRefreshBids[0].params.bidderParams.exampleServer.placement.rules[0].label = + 'changed-refresh-rule'; + firstRefreshBids[0].params.bidderParams.exampleServer.placement.sizes.push(777); + firstRefreshBids[1].params.groups[0].values[0] = 'changed-refresh-value'; + pubads.refresh([slot]); + + expect(refreshAdUnitFromLastRequest().bids).toEqual(expectedBids); + }); + + it('keeps snapshots across repeated synthetic refreshes and overwrites newer publisher config', () => { + const code = 'example-dynamic-slot'; + const slot = { + getSlotElementId: () => code, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { pubads } = installGpt([slot]); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [ + { + code, + mediaTypes: { banner: { name: 'example-zone-one', sizes: [[300, 250]] } }, + bids: [{ bidder: 'exampleServer', params: { placement: 'one' } }], + }, + ], + } as any); + pubads.refresh([slot]); + expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ + bidderParams: { exampleServer: { placement: 'one' } }, + zone: 'example-zone-one', + }); + + pubads.refresh([slot]); + expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ + bidderParams: { exampleServer: { placement: 'one' } }, + zone: 'example-zone-one', + }); + + pbjs.requestBids({ + adUnits: [ + { + code, + mediaTypes: { banner: { name: 'example-zone-two', sizes: [[300, 250]] } }, + bids: [{ bidder: 'exampleServer', params: { placement: 'two' } }], + }, + ], + } as any); + pubads.refresh([slot]); + + expect(refreshAdUnitFromLastRequest().bids[0].params).toEqual({ + bidderParams: { exampleServer: { placement: 'two' } }, + zone: 'example-zone-two', + }); + }); + + it('does not cross-contaminate dynamic-code snapshots and retains the global fallback', () => { + const slotOne = { + getSlotElementId: () => 'example-code-one', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const slotTwo = { + getSlotElementId: () => 'example-code-two', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const globalSlot = { + getSlotElementId: () => 'example-global-code', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { pubads } = installGpt([slotOne, slotTwo, globalSlot]); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [ + { + code: 'example-code-one', + bids: [{ bidder: 'exampleServer', params: { placement: 'one' } }], + }, + { + code: 'example-code-two', + bids: [{ bidder: 'exampleServer', params: { placement: 'two' } }], + }, + ], + } as any); + mockPbjs.adUnits = [ + { + code: 'example-global-code', + bids: [{ bidder: 'exampleFallback', params: { placement: 'global' } }], + }, + ]; + + pubads.refresh([slotOne]); + expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ + exampleServer: { placement: 'one' }, + }); + pubads.refresh([slotTwo]); + expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ + exampleServer: { placement: 'two' }, + }); + pubads.refresh([globalSlot]); + expect(refreshAdUnitFromLastRequest().bids[0].params.bidderParams).toEqual({ + exampleFallback: { placement: 'global' }, + }); + }); + + it('bypasses explicit covered subset delivery refreshes without clearing targeting', () => { + const slotOne = { + getSlotElementId: () => 'example-covered-one', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const slotTwo = { + getSlotElementId: () => 'example-covered-two-container', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + (window as any).tsjs = { + adSlots: [{ div_id: 'example-covered-two', formats: [[300, 250]], targeting: {} }], + }; + const { originalRefresh, pubads } = installGpt([slotOne, slotTwo]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [ + { code: 'example-covered-one', bids: [{ bidder: 'exampleServer', params: {} }] }, + { code: 'example-covered-two', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => { + pubads.refresh([slotOne]); + pubads.refresh([slotTwo]); + }, + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(slotOne.clearTargeting).not.toHaveBeenCalled(); + expect(slotTwo.clearTargeting).not.toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenCalledTimes(2); + expect(originalRefresh).toHaveBeenNthCalledWith(1, [slotOne], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [slotTwo], undefined); + }); + + it('bypasses a bare delivery refresh even when GPT includes a GAM-only extra slot', () => { + const coveredSlot = { + getSlotElementId: () => 'example-covered', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const gamOnlySlot = { + getSlotElementId: () => 'example-gam-only-interstitial', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([coveredSlot, gamOnlySlot]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ code: 'example-covered', bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => pubads.refresh(), + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(coveredSlot.clearTargeting).not.toHaveBeenCalled(); + expect(gamOnlySlot.clearTargeting).not.toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith(undefined, undefined); + }); + + it('keeps explicit unrelated and mixed delivery lists on the synthetic path', () => { + const coveredSlot = { + getSlotElementId: () => 'example-covered', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const unrelatedSlot = { + getSlotElementId: () => 'example-unrelated', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([coveredSlot, unrelatedSlot]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [{ code: 'example-covered', bids: [{ bidder: 'exampleServer', params: {} }] }], + bidsBackHandler: () => { + pubads.refresh([unrelatedSlot]); + pubads.refresh([coveredSlot, unrelatedSlot]); + }, + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(3); + expect(mockRequestBids.mock.calls[1][0].adUnits.map((unit: any) => unit.code)).toEqual([ + 'example-unrelated', + ]); + expect(mockRequestBids.mock.calls[2][0].adUnits.map((unit: any) => unit.code)).toEqual([ + 'example-covered', + 'example-unrelated', + ]); + expect(coveredSlot.clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('ts_initial'); + expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('hb_cache_path'); + expect(originalRefresh).toHaveBeenCalledTimes(2); + expect(originalRefresh).toHaveBeenNthCalledWith(1, [unrelatedSlot], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [coveredSlot, unrelatedSlot], undefined); + }); + + it('treats a microtask refresh after publisher delivery as an independent auction', async () => { + const slot = { + getSlotElementId: () => 'example-deferred-refresh', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + let deferredRefresh: Promise | undefined; + + pbjs.requestBids({ + adUnits: [ + { code: 'example-deferred-refresh', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => { + deferredRefresh = Promise.resolve().then(() => pubads.refresh([slot])); + }, + } as any); + await deferredRefresh; + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(slot.clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + }); + + it('keeps nested publisher delivery contexts isolated during reentrant auctions', () => { + const outerSlot = { + getSlotElementId: () => 'example-outer-delivery', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const innerSlot = { + getSlotElementId: () => 'example-inner-delivery', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([outerSlot, innerSlot]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [ + { code: 'example-outer-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => { + pbjs.requestBids({ + adUnits: [ + { code: 'example-inner-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => pubads.refresh([innerSlot]), + } as any); + pubads.refresh([outerSlot]); + }, + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(innerSlot.clearTargeting).not.toHaveBeenCalled(); + expect(outerSlot.clearTargeting).not.toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenNthCalledWith(1, [innerSlot], undefined); + expect(originalRefresh).toHaveBeenNthCalledWith(2, [outerSlot], undefined); + }); + + it('cleans delivery context after a publisher callback throws', () => { + const slot = { + getSlotElementId: () => 'example-throwing-callback', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + expect(() => + pbjs.requestBids({ + adUnits: [ + { + code: 'example-throwing-callback', + bids: [{ bidder: 'exampleServer', params: {} }], + }, + ], + bidsBackHandler: () => { + throw new Error('example callback failure'); + }, + } as any) + ).toThrow('example callback failure'); + + pubads.refresh([slot]); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(slot.clearTargeting).toHaveBeenCalled(); + expect(originalRefresh).toHaveBeenCalledTimes(1); + }); + + it('completes an internal synthetic refresh once without recursion', () => { + const slot = { + getSlotElementId: () => 'example-independent-refresh', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + installPrebidNpm(); + + pubads.refresh([slot]); + + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + }); +}); + describe('prebid/client-side bidders', () => { beforeEach(() => { vi.clearAllMocks(); From c59a064e6ef27a822b1986df65135b64d63ef8d5 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 14 Jul 2026 16:21:19 -0500 Subject: [PATCH 24/44] Handle deferred Prebid delivery refreshes --- .../lib/src/integrations/prebid/index.ts | 101 ++++++++-- .../test/integrations/prebid/index.test.ts | 188 +++++++++++++++++- 2 files changed, 259 insertions(+), 30 deletions(-) diff --git a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts index 65932d5b..e01cdd87 100644 --- a/crates/trusted-server-js/lib/src/integrations/prebid/index.ts +++ b/crates/trusted-server-js/lib/src/integrations/prebid/index.ts @@ -45,6 +45,7 @@ const TS_REFRESH_TARGETING_KEYS = [ 'hb_cache_host', 'hb_cache_path', ] as const; +const PUBLISHER_DELIVERY_CONTEXT_TIMEOUT_MS = 1000; /** Configuration options for the Prebid integration. */ export interface PrebidNpmConfig { @@ -234,7 +235,12 @@ type PublisherAdUnitSnapshot = { clientSideBids: ClientSideBidSnapshot[]; zone?: string; }; -type PublisherDeliveryContext = { remainingCodes: Set }; +type PublisherDeliveryContext = { + remainingCodes: Set; + retainForTargetedRefresh: boolean; + cleanupTimer?: ReturnType; +}; +type SetTargetingForGptAsync = (...args: unknown[]) => unknown; let publisherAdUnitSnapshots = new Map(); let syntheticRefreshAdUnits = new WeakSet(); @@ -577,15 +583,32 @@ function clearRefreshTargeting(slot: RefreshGptSlot): void { } function removePublisherDeliveryContext(context: PublisherDeliveryContext): void { + if (context.cleanupTimer !== undefined) { + clearTimeout(context.cleanupTimer); + context.cleanupTimer = undefined; + } const index = activePublisherDeliveryContexts.lastIndexOf(context); if (index >= 0) activePublisherDeliveryContexts.splice(index, 1); } +function targetingCoversPublisherDeliveryContext( + adUnitCodes: unknown, + context: PublisherDeliveryContext +): boolean { + if (adUnitCodes === undefined) return context.remainingCodes.size > 0; + const codes = typeof adUnitCodes === 'string' ? [adUnitCodes] : adUnitCodes; + return ( + Array.isArray(codes) && + codes.some((code) => typeof code === 'string' && context.remainingCodes.has(code)) + ); +} + function consumeBarePublisherDeliveryContext(): boolean { for (let index = activePublisherDeliveryContexts.length - 1; index >= 0; index -= 1) { const context = activePublisherDeliveryContexts[index]; if (context.remainingCodes.size === 0) continue; context.remainingCodes.clear(); + removePublisherDeliveryContext(context); return true; } return false; @@ -594,30 +617,35 @@ function consumeBarePublisherDeliveryContext(): boolean { function consumeExplicitPublisherDeliveryContext(targetSlots: RefreshGptSlot[]): boolean { if (targetSlots.length === 0) return false; - for (let index = activePublisherDeliveryContexts.length - 1; index >= 0; index -= 1) { - const context = activePublisherDeliveryContexts[index]; - const coveredCodes: string[] = []; - let allCovered = true; - - for (const slot of targetSlots) { - const injectedSlot = findInjectedSlotForRefresh(slot); - const candidates = [refreshSlotElementId(slot), injectedSlot?.div_id]; + // Publishers may include GAM-only slots in the same explicit refresh that + // delivers a completed Prebid auction. Attribute the call to delivery when + // any slot is covered, while consuming only the covered codes so an + // unrelated-only refresh still follows the synthetic auction path. + const matches = new Map>(); + for (const slot of targetSlots) { + const injectedSlot = findInjectedSlotForRefresh(slot); + const candidates = [refreshSlotElementId(slot), injectedSlot?.div_id]; + + for (let index = activePublisherDeliveryContexts.length - 1; index >= 0; index -= 1) { + const context = activePublisherDeliveryContexts[index]; const coveredCode = candidates.find( (code): code is string => !!code && context.remainingCodes.has(code) ); - if (!coveredCode) { - allCovered = false; - break; - } - coveredCodes.push(coveredCode); + if (!coveredCode) continue; + + const contextMatches = matches.get(context) ?? new Set(); + contextMatches.add(coveredCode); + matches.set(context, contextMatches); + break; } + } - if (!allCovered) continue; + if (matches.size === 0) return false; + for (const [context, coveredCodes] of matches) { coveredCodes.forEach((code) => context.remainingCodes.delete(code)); - return true; + if (context.remainingCodes.size === 0) removePublisherDeliveryContext(context); } - - return false; + return true; } function collectAuctionEids(): AuctionEid[] | undefined { @@ -658,7 +686,7 @@ function collectAuctionEids(): AuctionEid[] | undefined { export function installPrebidNpm(config?: Partial): typeof pbjs { publisherAdUnitSnapshots = new Map(); syntheticRefreshAdUnits = new WeakSet(); - activePublisherDeliveryContexts.length = 0; + [...activePublisherDeliveryContexts].forEach(removePublisherDeliveryContext); const injected = getInjectedConfig(); const merged: PrebidNpmConfig = { @@ -836,14 +864,43 @@ export function installPrebidNpm(config?: Partial): typeof pbjs const context: PublisherDeliveryContext = { remainingCodes: new Set(publisherAdUnitCodes), + retainForTargetedRefresh: false, + }; + const targetingPbjs = pbjs as unknown as { + setTargetingForGPTAsync?: SetTargetingForGptAsync; }; - // Delivery attribution is intentionally synchronous and ends as soon as - // the publisher's original callback returns. + const originalSetTargeting = targetingPbjs.setTargetingForGPTAsync; + let targetingWrapper: SetTargetingForGptAsync | undefined; + if (typeof originalSetTargeting === 'function') { + targetingWrapper = (...targetingArgs: unknown[]) => { + const result = originalSetTargeting.apply(targetingPbjs, targetingArgs); + if (targetingCoversPublisherDeliveryContext(targetingArgs[0], context)) { + context.retainForTargetedRefresh = true; + } + return result; + }; + targetingPbjs.setTargetingForGPTAsync = targetingWrapper; + } + activePublisherDeliveryContexts.push(context); try { originalBidsBack.apply(this, args as Parameters); } finally { - removePublisherDeliveryContext(context); + if (targetingWrapper && targetingPbjs.setTargetingForGPTAsync === targetingWrapper) { + targetingPbjs.setTargetingForGPTAsync = originalSetTargeting; + } + if (context.retainForTargetedRefresh && context.remainingCodes.size > 0) { + // Some publisher wrappers set targeting in bidsBackHandler, return, + // and schedule the matching GPT refresh shortly afterward. Retain + // this one-shot context only after that targeting signal, with a + // bounded expiry so a later independent refresh remains independent. + context.cleanupTimer = setTimeout( + () => removePublisherDeliveryContext(context), + PUBLISHER_DELIVERY_CONTEXT_TIMEOUT_MS + ); + } else { + removePublisherDeliveryContext(context); + } } }; diff --git a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts index 6435d970..bdb336fc 100644 --- a/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts +++ b/crates/trusted-server-js/lib/test/integrations/prebid/index.test.ts @@ -1694,7 +1694,7 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(originalRefresh).toHaveBeenCalledWith(undefined, undefined); }); - it('keeps explicit unrelated and mixed delivery lists on the synthetic path', () => { + it('keeps explicit unrelated lists synthetic and bypasses mixed delivery lists', () => { const coveredSlot = { getSlotElementId: () => 'example-covered', getTargeting: () => [], @@ -1721,15 +1721,11 @@ describe('prebid publisher snapshots and delivery refreshes', () => { }, } as any); - expect(mockRequestBids).toHaveBeenCalledTimes(3); + expect(mockRequestBids).toHaveBeenCalledTimes(2); expect(mockRequestBids.mock.calls[1][0].adUnits.map((unit: any) => unit.code)).toEqual([ 'example-unrelated', ]); - expect(mockRequestBids.mock.calls[2][0].adUnits.map((unit: any) => unit.code)).toEqual([ - 'example-covered', - 'example-unrelated', - ]); - expect(coveredSlot.clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(coveredSlot.clearTargeting).not.toHaveBeenCalled(); expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('ts_initial'); expect(unrelatedSlot.clearTargeting).toHaveBeenCalledWith('hb_cache_path'); expect(originalRefresh).toHaveBeenCalledTimes(2); @@ -1737,7 +1733,183 @@ describe('prebid publisher snapshots and delivery refreshes', () => { expect(originalRefresh).toHaveBeenNthCalledWith(2, [coveredSlot, unrelatedSlot], undefined); }); - it('treats a microtask refresh after publisher delivery as an independent auction', async () => { + it('bypasses an explicit delivery refresh with four covered slots and a GAM-only extra', () => { + const coveredSlots = Array.from({ length: 4 }, (_, index) => ({ + getSlotElementId: () => `example-covered-${index}`, + getTargeting: () => [], + clearTargeting: vi.fn(), + })); + const gamOnlySlot = { + getSlotElementId: () => 'example-gam-only-interstitial', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const refreshSlots = [...coveredSlots, gamOnlySlot]; + const { originalRefresh, pubads } = installGpt(refreshSlots); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: coveredSlots.map((_, index) => ({ + code: `example-covered-${index}`, + bids: [{ bidder: 'exampleServer', params: { placement: index } }], + })), + bidsBackHandler: () => pubads.refresh(refreshSlots), + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(1); + refreshSlots.forEach((slot) => expect(slot.clearTargeting).not.toHaveBeenCalled()); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith(refreshSlots, undefined); + }); + + it('bypasses a targeted delivery refresh shortly after the publisher callback returns', () => { + vi.useFakeTimers(); + try { + const coveredSlots = Array.from({ length: 4 }, (_, index) => ({ + getSlotElementId: () => `example-targeted-${index}`, + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + })); + const gamOnlySlot = { + getSlotElementId: () => 'example-targeted-interstitial', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const refreshSlots = [...coveredSlots, gamOnlySlot]; + const { originalRefresh, pubads } = installGpt(refreshSlots); + const setTargetingForGPTAsync = vi.fn(); + (mockPbjs as any).setTargetingForGPTAsync = setTargetingForGPTAsync; + let refreshAfterCallback: (() => void) | undefined; + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + const pendingRefresh = refreshAfterCallback; + refreshAfterCallback = undefined; + if (pendingRefresh) setTimeout(pendingRefresh, 750); + }); + const pbjs = installPrebidNpm(); + const coveredCodes = coveredSlots.map((slot) => slot.getSlotElementId()); + + pbjs.requestBids({ + adUnits: coveredCodes.map((code, index) => ({ + code, + bids: [{ bidder: 'exampleServer', params: { placement: index } }], + })), + bidsBackHandler: () => { + (pbjs as any).setTargetingForGPTAsync([gamOnlySlot.getSlotElementId(), ...coveredCodes]); + refreshAfterCallback = () => pubads.refresh(refreshSlots); + }, + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(1); + expect(setTargetingForGPTAsync).toHaveBeenCalledWith([ + gamOnlySlot.getSlotElementId(), + ...coveredCodes, + ]); + expect((mockPbjs as any).setTargetingForGPTAsync).toBe(setTargetingForGPTAsync); + + vi.advanceTimersByTime(750); + + refreshSlots.forEach((slot) => expect(slot.clearTargeting).not.toHaveBeenCalled()); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith(refreshSlots, undefined); + + vi.runOnlyPendingTimers(); + pubads.refresh([coveredSlots[0]]); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(coveredSlots[0].clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(originalRefresh).toHaveBeenCalledTimes(2); + } finally { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + delete (mockPbjs as any).setTargetingForGPTAsync; + } + }); + + it('expires a targeted delivery context before a later event-loop task', () => { + vi.useFakeTimers(); + try { + const slot = { + getSlotElementId: () => 'example-expiring-delivery', + getTargeting: () => [], + getSizes: () => [[300, 250]], + clearTargeting: vi.fn(), + }; + const { originalRefresh, pubads } = installGpt([slot]); + (mockPbjs as any).setTargetingForGPTAsync = vi.fn(); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [ + { code: 'example-expiring-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => (pbjs as any).setTargetingForGPTAsync(['example-expiring-delivery']), + } as any); + vi.runOnlyPendingTimers(); + pubads.refresh([slot]); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + expect(slot.clearTargeting).toHaveBeenCalledWith('hb_pb'); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith([slot], undefined); + } finally { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + delete (mockPbjs as any).setTargetingForGPTAsync; + } + }); + + it('bypasses a mixed explicit delivery list spanning nested contexts', () => { + const outerSlot = { + getSlotElementId: () => 'example-outer-delivery', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const innerSlot = { + getSlotElementId: () => 'example-inner-delivery', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const gamOnlySlot = { + getSlotElementId: () => 'example-gam-only-interstitial', + getTargeting: () => [], + clearTargeting: vi.fn(), + }; + const refreshSlots = [innerSlot, outerSlot, gamOnlySlot]; + const { originalRefresh, pubads } = installGpt(refreshSlots); + mockRequestBids.mockImplementation((opts?: { bidsBackHandler?: () => void }) => { + opts?.bidsBackHandler?.(); + }); + const pbjs = installPrebidNpm(); + + pbjs.requestBids({ + adUnits: [ + { code: 'example-outer-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => { + pbjs.requestBids({ + adUnits: [ + { code: 'example-inner-delivery', bids: [{ bidder: 'exampleServer', params: {} }] }, + ], + bidsBackHandler: () => pubads.refresh(refreshSlots), + } as any); + }, + } as any); + + expect(mockRequestBids).toHaveBeenCalledTimes(2); + refreshSlots.forEach((slot) => expect(slot.clearTargeting).not.toHaveBeenCalled()); + expect(originalRefresh).toHaveBeenCalledTimes(1); + expect(originalRefresh).toHaveBeenCalledWith(refreshSlots, undefined); + }); + + it('treats a microtask refresh without a targeting signal as an independent auction', async () => { const slot = { getSlotElementId: () => 'example-deferred-refresh', getTargeting: () => [], From 310e62f307a8320639cee58938142de1afe3a78e Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 14 Jul 2026 16:57:56 -0500 Subject: [PATCH 25/44] Use HTTP status error type for Prebid failures --- crates/trusted-server-core/src/integrations/prebid.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 2e765538..9366512e 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -18,6 +18,7 @@ use serde_json::Value as Json; use url::{Url, Url as ParsedUrl}; use validator::{Validate, ValidationError}; +use crate::auction::orchestrator::ERROR_TYPE_HTTP_STATUS; use crate::auction::provider::AuctionProvider; use crate::auction::types::{ AuctionContext, AuctionRequest, AuctionResponse, Bid as AuctionBid, MediaType, @@ -62,7 +63,6 @@ const ZONE_KEY: &str = "zone"; /// Default currency for `OpenRTB` bid floors and responses. const DEFAULT_CURRENCY: &str = "USD"; -const PREBID_ERROR_TYPE_UPSTREAM_HTTP: &str = "upstream_http"; const PREBID_PUBLIC_ERROR_MESSAGE_CHARS: usize = 500; const PREBID_ERROR_BODY_PREVIEW_CHARS: usize = 1000; const PREBID_ERROR_BODY_PREVIEW_BYTES: usize = PREBID_ERROR_BODY_PREVIEW_CHARS * 4; @@ -2010,10 +2010,7 @@ impl PrebidAuctionProvider { let status_code = status.as_u16(); let mut auction_response = AuctionResponse::error(PREBID_INTEGRATION_ID, response_time_ms) - .with_metadata( - "error_type", - serde_json::json!(PREBID_ERROR_TYPE_UPSTREAM_HTTP), - ) + .with_metadata("error_type", serde_json::json!(ERROR_TYPE_HTTP_STATUS)) .with_metadata("http_status", serde_json::json!(status_code)) .with_metadata( "message", @@ -5143,7 +5140,7 @@ external_bundle_sri = "sha384-AAAA" ); assert_eq!( auction_response.metadata["error_type"], - json!(PREBID_ERROR_TYPE_UPSTREAM_HTTP) + json!(ERROR_TYPE_HTTP_STATUS) ); assert_eq!(auction_response.metadata["http_status"], json!(400)); assert_eq!( From 6b4e82ed594d77fdc686107298823d83b2e3c19f Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 15 Jul 2026 12:04:27 -0500 Subject: [PATCH 26/44] Make auction creative rewriting optional Allow operators to retain sanitizer-accepted external URLs in POST /auction adm while preserving mandatory server-side sanitization and the existing default behavior. --- CHANGELOG.md | 1 + .../src/auction/endpoints.rs | 7 +- .../src/auction/formats.rs | 141 +++++++++++++++++- .../src/auction/orchestrator.rs | 1 + .../src/auction_config_types.rs | 22 +++ .../trusted-server-core/src/config_payload.rs | 24 +++ crates/trusted-server-core/src/proxy.rs | 45 ++++++ crates/trusted-server-core/src/settings.rs | 35 +++++ docs/guide/auction-orchestration.md | 69 ++++++--- docs/guide/configuration.md | 26 +++- docs/guide/creative-processing.md | 54 +++++-- trusted-server.example.toml | 4 + 12 files changed, 382 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bc49c80..fddc8009 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added the default-true `[auction].rewrite_creatives` option. Setting it to `false` preserves mandatory `/auction` creative sanitization while skipping first-party resource/click URL rewriting and creative TSJS injection. - Added Osano consent mirror integration docs and public enablement guidance. - Implemented basic authentication for configurable endpoint paths (#73) - Added integrations guide with example `testlight` integration diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 825129b1..e7d4f0de 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -76,9 +76,10 @@ const MAX_AUCTION_BODY_SIZE: usize = 256 * 1024; /// ## Response /// /// Returns an `OpenRTB 2.x` response. Creative HTML is inlined in each bid's -/// `adm` field after sanitisation and first-party URL rewriting. Response -/// headers include `X-TS-EC` (the caller's Edge Cookie ID) and -/// `X-TS-EC-Fresh` (a freshly generated ID for cookie renewal). +/// `adm` field after mandatory server-side sanitization. First-party resource +/// and click URL rewriting plus creative TSJS injection are enabled by default; +/// setting [`auction.rewrite_creatives`][`crate::auction_config_types::AuctionConfig::rewrite_creatives`] +/// to `false` skips only that rewrite pass. /// /// ## Scroll, refresh, and SPA navigation /// diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index 441828a1..71f9a290 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -217,7 +217,8 @@ pub fn convert_tsjs_to_auction_request( /// Convert `OrchestrationResult` to `OpenRTB` response format. /// -/// Returns rewritten creative HTML directly in the `adm` field for inline delivery. +/// Always sanitizes creative HTML in the `adm` field and optionally rewrites it +/// according to the auction configuration. /// /// # Errors /// @@ -250,21 +251,34 @@ pub fn convert_to_openrtb_response( let width = to_openrtb_i32(bid.width, "width", &bid_context); let height = to_openrtb_i32(bid.height, "height", &bid_context); - // Process creative HTML if present - — sanitize dangerous markup first, then rewrite URLs. + // Process creative HTML if present — always sanitize dangerous markup first. let creative_html = if let Some(ref raw_creative) = bid.creative { let sanitized = creative::sanitize_creative_html(raw_creative); - let rewritten = creative::rewrite_creative_html(settings, &sanitized); + let sanitized_len = sanitized.len(); + let rewrite_creatives = settings.auction.rewrite_creatives; + let processed = if rewrite_creatives { + creative::rewrite_creative_html(settings, &sanitized) + } else { + sanitized + }; + let rewrite_mode = if rewrite_creatives { + "enabled" + } else { + "disabled" + }; log::debug!( - "Processed creative for auction {} slot {} ({} → {} → {} bytes)", + "Processed creative for auction {} slot {} bidder {} (rewrite {}, raw {} bytes, sanitized {} bytes, output {} bytes)", auction_request.id, slot_id, + bid.bidder, + rewrite_mode, raw_creative.len(), - sanitized.len(), - rewritten.len() + sanitized_len, + processed.len() ); - rewritten + processed } else { // No creative provided (e.g., from mediation layer that returns iframe URLs) log::warn!( @@ -445,6 +459,15 @@ mod tests { } } + fn make_complete_creative_bid() -> Bid { + let mut bid = make_bid("div-gpt-top", "appnexus", Some(2.75)); + bid.creative = Some( + r#""# + .to_string(), + ); + bid + } + fn make_result(bid: Bid) -> OrchestrationResult { OrchestrationResult { provider_responses: vec![AuctionResponse { @@ -466,6 +489,13 @@ mod tests { .expect("should parse JSON response") } + fn response_adm(response: Response) -> String { + response_json(response)["seatbid"][0]["bid"][0]["adm"] + .as_str() + .expect("should serialize adm as a string") + .to_string() + } + fn make_banner_body(config: Option) -> AdRequest { AdRequest { ad_units: vec![AdUnit { @@ -932,6 +962,103 @@ mod tests { ); } + #[test] + fn convert_to_openrtb_response_rewrites_sanitized_creative_by_default() { + let settings = make_settings(); + let auction_request = make_auction_request(); + let result = make_result(make_complete_creative_bid()); + + let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) + .expect("should convert creative with rewriting enabled"); + let adm = response_adm(response); + + assert!( + adm.matches("/first-party/proxy?tsurl=").count() >= 2, + "should rewrite image and inline CSS URLs through the proxy: {adm}" + ); + assert!( + adm.contains("/first-party/click?tsurl="), + "should rewrite click URLs: {adm}" + ); + assert!( + adm.contains("data-tsclick"), + "should add the click guard attribute: {adm}" + ); + assert!( + adm.contains("tsjs-unified.min.js"), + "should inject the unified creative runtime: {adm}" + ); + assert!( + !adm.contains(r#"src="https://cdn.example.com/ad.png""#), + "should not retain the image URL as a direct attribute: {adm}" + ); + assert!( + !adm.contains(r#"href="https://advertiser.example.com/landing""#), + "should not retain the click URL as a direct attribute: {adm}" + ); + assert!( + !adm.contains("url(https://styles.example.com/bg.png)"), + "should not retain the CSS URL as a direct value: {adm}" + ); + assert!( + !adm.contains("auction-script-marker"), + "should remove malicious script content before rewriting: {adm}" + ); + assert!( + !adm.contains("auction-handler-marker") && !adm.contains("onerror"), + "should remove event handlers before rewriting: {adm}" + ); + } + + #[test] + fn convert_to_openrtb_response_can_skip_rewriting_but_not_sanitization() { + let mut settings = make_settings(); + settings.auction.rewrite_creatives = false; + let auction_request = make_auction_request(); + let result = make_result(make_complete_creative_bid()); + + let response = convert_to_openrtb_response(&result, &settings, &auction_request, false) + .expect("should convert creative with rewriting disabled"); + let adm = response_adm(response); + + assert!( + adm.contains(r#"src="https://cdn.example.com/ad.png""#), + "should retain the sanitizer-accepted image URL: {adm}" + ); + assert!( + adm.contains(r#"href="https://advertiser.example.com/landing""#), + "should retain the sanitizer-accepted click URL: {adm}" + ); + assert!( + adm.contains("url(https://styles.example.com/bg.png)"), + "should retain the sanitizer-accepted CSS URL: {adm}" + ); + assert!( + !adm.contains("/first-party/proxy"), + "should not rewrite resource URLs: {adm}" + ); + assert!( + !adm.contains("/first-party/click"), + "should not rewrite click URLs: {adm}" + ); + assert!( + !adm.contains("data-tsclick"), + "should not add the click guard attribute: {adm}" + ); + assert!( + !adm.contains("tsjs-unified.min.js"), + "should not inject the unified creative runtime: {adm}" + ); + assert!( + !adm.contains("auction-script-marker"), + "should still remove malicious script content: {adm}" + ); + assert!( + !adm.contains("auction-handler-marker") && !adm.contains("onerror"), + "should still remove event handlers: {adm}" + ); + } + #[test] fn convert_to_openrtb_response_serializes_missing_creative_as_empty_adm() { let settings = make_settings(); diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 4ef73e58..fb925582 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -1818,6 +1818,7 @@ mod tests { futures::executor::block_on(async { let config = AuctionConfig { enabled: true, + rewrite_creatives: true, providers: vec![], mediator: None, timeout_ms: 2000, diff --git a/crates/trusted-server-core/src/auction_config_types.rs b/crates/trusted-server-core/src/auction_config_types.rs index 3bd747f6..f1d1a5cf 100644 --- a/crates/trusted-server-core/src/auction_config_types.rs +++ b/crates/trusted-server-core/src/auction_config_types.rs @@ -11,6 +11,10 @@ pub struct AuctionConfig { #[serde(default)] pub enabled: bool, + /// Rewrite sanitized winning-bid creative HTML to first-party endpoints. + #[serde(default = "default_rewrite_creatives")] + pub rewrite_creatives: bool, + /// Provider names that participate in bidding /// Simply list the provider names (e.g., ["prebid", "aps"]) #[serde(default, deserialize_with = "crate::settings::vec_from_seq_or_map")] @@ -41,6 +45,7 @@ impl Default for AuctionConfig { fn default() -> Self { Self { enabled: false, + rewrite_creatives: default_rewrite_creatives(), providers: Vec::new(), mediator: None, timeout_ms: default_timeout(), @@ -54,6 +59,10 @@ fn default_timeout() -> u32 { 2000 } +fn default_rewrite_creatives() -> bool { + true +} + fn default_creative_store() -> String { "creative_store".to_owned() } @@ -79,3 +88,16 @@ impl AuctionConfig { self.mediator.is_some() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rewrite_creatives_defaults_to_true() { + assert!( + AuctionConfig::default().rewrite_creatives, + "should enable creative rewriting by default" + ); + } +} diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index dd0b3533..58c18538 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -78,6 +78,30 @@ mod tests { ); } + #[test] + fn legacy_blob_without_rewrite_creatives_preserves_rewriting() { + let mut data = + serde_json::to_value(test_settings()).expect("should serialize settings to JSON"); + let auction = data + .get_mut("auction") + .and_then(serde_json::Value::as_object_mut) + .expect("should serialize auction settings as an object"); + assert!( + auction.remove("rewrite_creatives").is_some(), + "should remove the newly serialized setting" + ); + let envelope = BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_string()); + let envelope_json = serde_json::to_string(&envelope).expect("should serialize envelope"); + + let reconstructed = + settings_from_config_blob(&envelope_json).expect("should reconstruct legacy settings"); + + assert!( + reconstructed.auction.rewrite_creatives, + "should enable creative rewriting for legacy blobs" + ); + } + #[test] fn strings_that_look_like_json_scalars_round_trip_as_strings() { let mut original = test_settings(); diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 9e03f4db..9bc71fb2 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -2882,6 +2882,51 @@ mod tests { assert_eq!(ct, "text/css; charset=utf-8"); } + #[test] + fn auction_rewrite_setting_does_not_change_proxied_html_or_css_rewriting() { + let mut settings = create_test_settings(); + settings.auction.rewrite_creatives = false; + let req = build_http_request(Method::GET, "https://edge.example/first-party/proxy"); + + let html = r#""#; + let mut html_response = build_http_response(StatusCode::OK, EdgeBody::from(html)); + html_response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); + let html_output = finalize( + &settings, + &req, + "https://cdn.example/creative.html", + html_response, + ) + .expect("should finalize proxied HTML"); + let html_body = response_body_string(html_output); + + let css = "body{background:url(https://cdn.example/bg.png)}"; + let mut css_response = build_http_response(StatusCode::OK, EdgeBody::from(css)); + css_response + .headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static("text/css")); + let css_output = finalize( + &settings, + &req, + "https://cdn.example/creative.css", + css_response, + ) + .expect("should finalize proxied CSS"); + let css_body = response_body_string(css_output); + + assert!( + html_body.contains("/first-party/proxy?tsurl="), + "should keep rewriting proxied HTML when auction rewriting is disabled: {html_body}" + ); + assert!( + css_body.contains("/first-party/proxy?tsurl="), + "should keep rewriting proxied CSS when auction rewriting is disabled: {css_body}" + ); + } + #[test] fn html_response_rewrite_preserves_non_standard_port() { // Verify that HTML rewriting preserves non-standard ports in sub-resource URLs. diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 03cc535c..b177a23e 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -4334,6 +4334,41 @@ origin_host_header_overide = "www.example.com""#, assert!(!rewrite.is_excluded("")); } + #[test] + fn test_auction_rewrite_creatives_defaults_to_true_when_omitted() { + let toml_str = crate_test_settings_str() + + r#" + [auction] + enabled = true + providers = [] + "#; + + let settings = Settings::from_toml(&toml_str).expect("should parse valid TOML"); + + assert!( + settings.auction.rewrite_creatives, + "should preserve creative rewriting when the setting is omitted" + ); + } + + #[test] + fn test_auction_rewrite_creatives_accepts_explicit_false() { + let toml_str = crate_test_settings_str() + + r#" + [auction] + enabled = true + providers = [] + rewrite_creatives = false + "#; + + let settings = Settings::from_toml(&toml_str).expect("should parse valid TOML"); + + assert!( + !settings.auction.rewrite_creatives, + "should disable creative rewriting when explicitly configured" + ); + } + #[test] fn test_auction_allowed_context_keys_defaults_to_empty() { let settings = create_test_settings(); diff --git a/docs/guide/auction-orchestration.md b/docs/guide/auction-orchestration.md index d7595881..c6c82dac 100644 --- a/docs/guide/auction-orchestration.md +++ b/docs/guide/auction-orchestration.md @@ -12,7 +12,7 @@ Key capabilities: - **Strategy-based winner selection** — Automatic strategy detection based on configuration - **Mediator support** — Optional external mediator for decoding encoded prices (e.g., APS) and applying unified floor pricing - **Provider abstraction** — Pluggable provider interface for adding new demand sources -- **Creative rewriting** — Winning creatives automatically rewritten with first-party proxy URLs +- **Creative rewriting** — Winning creatives are sanitized and rewritten with first-party proxy URLs by default ## System Flow (Prebid + APS) @@ -147,7 +147,7 @@ sequenceDiagram Note over Client,Mock: Response Assembly activate TS activate Client - Orch->>Orch: Transform to OpenRTB response
Generate iframe creatives
Rewrite creative URLs
Add orchestrator metadata + Orch->>Orch: Transform to OpenRTB response
Sanitize creative HTML
Optionally rewrite creative URLs
Add orchestrator metadata Orch-->>TS: OpenRTB BidResponse Note right of Orch: { "id": "auction-response",
"seatbid": [{ "seat": "amazon-aps",
"bid": [{ "price": 2.50,
"adm": "