From 486bf169aa45ebeb8d2b695ec234acf543ab5201 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 8 Jul 2026 11:25:17 -0500 Subject: [PATCH 1/2] Implement configurable cache header policies --- crates/trusted-server-adapter-axum/src/app.rs | 4 +- .../tests/routes.rs | 36 ++ .../src/app.rs | 8 +- .../tests/routes.rs | 37 ++ .../trusted-server-adapter-fastly/src/app.rs | 4 +- .../trusted-server-adapter-fastly/src/main.rs | 3 +- crates/trusted-server-adapter-spin/src/app.rs | 4 +- .../tests/routes.rs | 30 + .../trusted-server-core/src/cache_policy.rs | 565 ++++++++++++++++++ crates/trusted-server-core/src/http_util.rs | 30 +- .../src/integrations/prebid.rs | 18 +- .../src/integrations/testlight.rs | 5 +- crates/trusted-server-core/src/lib.rs | 1 + crates/trusted-server-core/src/proxy.rs | 168 +++++- crates/trusted-server-core/src/publisher.rs | 357 ++++++++++- .../src/response_privacy.rs | 132 +++- crates/trusted-server-core/src/settings.rs | 473 +++++++++++++++ crates/trusted-server-core/src/tsjs.rs | 47 +- crates/trusted-server-js/Cargo.toml | 3 +- crates/trusted-server-js/build.rs | 47 +- crates/trusted-server-js/src/bundle.rs | 163 ++++- docs/guide/configuration.md | 73 +++ ...ache-control-header-implementation-plan.md | 446 ++++++++++++++ .../2026-07-06-cache-control-header-design.md | 145 +++++ trusted-server.example.toml | 21 + 25 files changed, 2654 insertions(+), 166 deletions(-) create mode 100644 crates/trusted-server-core/src/cache_policy.rs create mode 100644 docs/superpowers/plans/2026-07-06-cache-control-header-implementation-plan.md create mode 100644 docs/superpowers/specs/2026-07-06-cache-control-header-design.md diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 2f4329574..170654e2a 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -11,6 +11,7 @@ use edgezero_core::router::RouterService; use error_stack::Report; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; +use trusted_server_core::cache_policy::EdgeCacheHeader; use trusted_server_core::ec::EcContext; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; @@ -176,7 +177,7 @@ async fn dispatch_fallback( let method = req.method().clone(); if method == Method::GET && path.starts_with("/static/tsjs=") { - return handle_tsjs_dynamic(&req, &state.registry); + return handle_tsjs_dynamic(&req, &state.registry, EdgeCacheHeader::SMaxageFallback); } if state.registry.has_route(&method, &path) { @@ -215,6 +216,7 @@ async fn dispatch_fallback( &mut ec_context, auction, req, + EdgeCacheHeader::SMaxageFallback, ) .await?; // Async finalize so the dispatched auction is collected and its bids are diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index c4bf7d990..629c2b0b8 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -197,6 +197,42 @@ async fn tsjs_route_prefix_is_handled_not_5xx() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tsjs_route_matching_hash_uses_s_maxage_fallback() { + let mut svc = make_service(); + let src = trusted_server_core::tsjs::tsjs_script_src(&["creative"]); + let req = Request::builder() + .method("GET") + .uri(src) + .body(AxumBody::empty()) + .expect("should build request"); + + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + + assert_eq!( + resp.status().as_u16(), + 200, + "matching TSJS hash should serve OK" + ); + assert_eq!( + resp.headers() + .get("cache-control") + .and_then(|value| value.to_str().ok()), + Some("public, max-age=31536000, s-maxage=31536000, immutable"), + "Axum adapter should render the portable s-maxage fallback" + ); + assert!( + resp.headers().get("surrogate-control").is_none(), + "s-maxage fallback must not emit Fastly Surrogate-Control" + ); +} + // --------------------------------------------------------------------------- // Middleware tests // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index c931360f6..015f1e7f7 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -10,6 +10,7 @@ use edgezero_core::router::RouterService; use error_stack::Report; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; +use trusted_server_core::cache_policy::EdgeCacheHeader; #[cfg(target_arch = "wasm32")] use trusted_server_core::config_payload::settings_from_config_blob; use trusted_server_core::ec::EcContext; @@ -367,7 +368,11 @@ fn build_router(state: &Arc) -> RouterService { let allow_tsjs = method == Method::GET; let result = if allow_tsjs && path.starts_with("/static/tsjs=") { - handle_tsjs_dynamic(&req, &state.registry) + handle_tsjs_dynamic( + &req, + &state.registry, + EdgeCacheHeader::CloudflareCdnCacheControl, + ) } else if state.registry.has_route(&method, &path) { let mut ec_context = EcContext::default(); state @@ -401,6 +406,7 @@ fn build_router(state: &Arc) -> RouterService { &mut ec_context, auction, req, + EdgeCacheHeader::CloudflareCdnCacheControl, ) .await { diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index df2781945..6aee76b8b 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -203,6 +203,43 @@ async fn tsjs_route_is_routed_not_5xx() { assert!(status < 500, "tsjs route must not 5xx: got {status}"); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tsjs_route_emits_cloudflare_cache_header_for_matching_hash() { + let router = test_router(); + let src = trusted_server_core::tsjs::tsjs_script_src(&["creative"]); + let req = request_builder() + .method("GET") + .uri(src) + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + + let resp = route(router, req).await; + + assert_eq!( + resp.status().as_u16(), + 200, + "matching TSJS hash should serve OK" + ); + assert_eq!( + resp.headers() + .get("cache-control") + .and_then(|value| value.to_str().ok()), + Some("public, max-age=31536000, immutable"), + "browser cache policy should be immutable for matching TSJS hash" + ); + assert_eq!( + resp.headers() + .get("cloudflare-cdn-cache-control") + .and_then(|value| value.to_str().ok()), + Some("max-age=31536000"), + "Cloudflare adapter should emit the Cloudflare-specific edge header" + ); + assert!( + resp.headers().get("surrogate-control").is_none(), + "Cloudflare adapter must not emit Fastly Surrogate-Control" + ); +} + /// Verify that every expected explicit route is registered in the route table. /// /// Uses [`RouterService::routes()`] for introspection rather than checking diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index e56498b10..9abbe7d63 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -96,6 +96,7 @@ use error_stack::Report; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::AuctionTelemetrySink; use trusted_server_core::auction::{build_orchestrator, AuctionOrchestrator}; +use trusted_server_core::cache_policy::EdgeCacheHeader; use trusted_server_core::constants::{COOKIE_SHAREDID, COOKIE_TS_EIDS}; use trusted_server_core::ec::batch_sync::handle_batch_sync; use trusted_server_core::ec::consent::ec_consent_withdrawn; @@ -719,7 +720,7 @@ async fn dispatch_fallback( }; let result = if uses_dynamic_tsjs_fallback(&method, &path) { - handle_tsjs_dynamic(&req, &state.registry) + handle_tsjs_dynamic(&req, &state.registry, EdgeCacheHeader::SurrogateControl) } 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 @@ -794,6 +795,7 @@ async fn dispatch_fallback( &mut ec.ec_context, auction, req, + EdgeCacheHeader::SurrogateControl, ) .await { diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index d20de533d..8b4f80a77 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -11,6 +11,7 @@ use error_stack::Report; use fastly::http::Method as FastlyMethod; use fastly::{Request as FastlyRequest, Response as FastlyResponse}; +use trusted_server_core::cache_policy::EdgeCacheHeader; use trusted_server_core::ec::device::DeviceSignals; use trusted_server_core::ec::finalize::ec_finalize_response; use trusted_server_core::ec::kv::KvIdentityGraph; @@ -202,7 +203,7 @@ fn edgezero_main(mut req: FastlyRequest) { } if let Some(policy) = asset_cache_policy { - policy.apply_after_route_finalization(&mut response); + policy.apply_after_route_finalization(&mut response, EdgeCacheHeader::SurrogateControl); } if let Some(ec_state) = ec_state { diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 2291fce74..63cbe6bc2 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -10,6 +10,7 @@ use edgezero_core::router::RouterService; use error_stack::Report; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; +use trusted_server_core::cache_policy::EdgeCacheHeader; use trusted_server_core::ec::EcContext; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::sanitize_forwarded_headers; @@ -637,7 +638,7 @@ fn build_router(state: &Arc) -> RouterService { // Dynamic tsjs serving is GET-only; other methods fall through to the // integration/publisher fallback. let result = if method == Method::GET && path.starts_with("/static/tsjs=") { - handle_tsjs_dynamic(&req, &state.registry) + handle_tsjs_dynamic(&req, &state.registry, EdgeCacheHeader::SMaxageFallback) } else if state.registry.has_route(&method, &path) { let mut ec_context = EcContext::default(); state @@ -671,6 +672,7 @@ fn build_router(state: &Arc) -> RouterService { &mut ec_context, auction, req, + EdgeCacheHeader::SMaxageFallback, ) .await { diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 9b96dbd70..f2e7c7ac0 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -209,6 +209,36 @@ async fn tsjs_route_is_routed_not_5xx() { assert!(status < 500, "tsjs route must not 5xx: got {status}"); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tsjs_route_matching_hash_uses_s_maxage_fallback() { + let router = test_router(); + let src = trusted_server_core::tsjs::tsjs_script_src(&["creative"]); + let req = request_builder() + .method("GET") + .uri(src) + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + + let resp = route(router, req).await; + + assert_eq!( + resp.status().as_u16(), + 200, + "matching TSJS hash should serve OK" + ); + assert_eq!( + resp.headers() + .get("cache-control") + .and_then(|value| value.to_str().ok()), + Some("public, max-age=31536000, s-maxage=31536000, immutable"), + "Spin adapter should render the portable s-maxage fallback" + ); + assert!( + resp.headers().get("surrogate-control").is_none(), + "s-maxage fallback must not emit Fastly Surrogate-Control" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn verify_signature_is_routed() { let router = test_router(); diff --git a/crates/trusted-server-core/src/cache_policy.rs b/crates/trusted-server-core/src/cache_policy.rs new file mode 100644 index 000000000..5b2b09a3e --- /dev/null +++ b/crates/trusted-server-core/src/cache_policy.rs @@ -0,0 +1,565 @@ +//! Structured cache-policy rendering helpers. +//! +//! Cache policy is expressed once as typed data and then rendered into the +//! runtime-specific headers used by each edge platform. The helpers in this +//! module only write cache-control headers; response privacy hardening still +//! runs later so personalized or cookie-bearing responses cannot be made +//! shared-cacheable by accident. + +use std::time::Duration; + +use http::header::{self, HeaderName}; +use http::{HeaderMap, HeaderValue}; + +/// String name Fastly uses for shared-cache control. +pub const HEADER_SURROGATE_CONTROL_NAME: &str = "surrogate-control"; +/// String name Fastly may use for shared-cache control in some configurations. +pub const HEADER_FASTLY_SURROGATE_CONTROL_NAME: &str = "fastly-surrogate-control"; +/// String name for the standards-track CDN-only shared-cache control header. +pub const HEADER_CDN_CACHE_CONTROL_NAME: &str = "cdn-cache-control"; +/// String name for Cloudflare-specific CDN-only shared-cache control. +pub const HEADER_CLOUDFLARE_CDN_CACHE_CONTROL_NAME: &str = "cloudflare-cdn-cache-control"; + +/// Runtime edge-cache header names owned by this crate. +pub const EDGE_CACHE_HEADER_NAMES: &[&str] = &[ + HEADER_SURROGATE_CONTROL_NAME, + HEADER_FASTLY_SURROGATE_CONTROL_NAME, + HEADER_CDN_CACHE_CONTROL_NAME, + HEADER_CLOUDFLARE_CDN_CACHE_CONTROL_NAME, +]; + +/// Header name Fastly uses for shared-cache control. +pub const HEADER_SURROGATE_CONTROL: HeaderName = + HeaderName::from_static(HEADER_SURROGATE_CONTROL_NAME); +/// Header name Fastly may use for shared-cache control in some configurations. +pub const HEADER_FASTLY_SURROGATE_CONTROL: HeaderName = + HeaderName::from_static(HEADER_FASTLY_SURROGATE_CONTROL_NAME); +/// Standards-track header name for CDN-only shared-cache control. +pub const HEADER_CDN_CACHE_CONTROL: HeaderName = + HeaderName::from_static(HEADER_CDN_CACHE_CONTROL_NAME); +/// Cloudflare-specific header name for CDN-only shared-cache control. +pub const HEADER_CLOUDFLARE_CDN_CACHE_CONTROL: HeaderName = + HeaderName::from_static(HEADER_CLOUDFLARE_CDN_CACHE_CONTROL_NAME); + +/// Cache-control value used when a response must not be stored. +pub const NO_STORE_PRIVATE_CACHE_CONTROL: &str = "no-store, private"; + +/// Shared-cache header family emitted for the current runtime. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum EdgeCacheHeader { + /// Emit Fastly's `Surrogate-Control` header. + SurrogateControl, + /// Emit the standards-track `CDN-Cache-Control` header. + CdnCacheControl, + /// Emit Cloudflare's `Cloudflare-CDN-Cache-Control` header. + CloudflareCdnCacheControl, + /// Put `s-maxage` into `Cache-Control` instead of emitting a separate edge header. + SMaxageFallback, + /// Do not emit edge-cache directives. + None, +} + +/// Cache visibility for the browser-facing `Cache-Control` header. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum CacheVisibility { + /// Response may be stored by shared caches when edge directives allow it. + Public, + /// Response is private to the requesting browser. + Private, +} + +impl CacheVisibility { + fn directive(self) -> &'static str { + match self { + Self::Public => "public", + Self::Private => "private", + } + } +} + +impl EdgeCacheHeader { + fn header_name(self) -> Option { + match self { + Self::SurrogateControl => Some(HEADER_SURROGATE_CONTROL), + Self::CdnCacheControl => Some(HEADER_CDN_CACHE_CONTROL), + Self::CloudflareCdnCacheControl => Some(HEADER_CLOUDFLARE_CDN_CACHE_CONTROL), + Self::SMaxageFallback | Self::None => None, + } + } +} + +/// Structured browser/edge cache policy. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct CachePolicy { + /// Whether the browser-facing response is public or private. + pub visibility: CacheVisibility, + /// Browser cache TTL rendered as `max-age`. + pub browser_ttl: Option, + /// Shared edge cache TTL rendered as an edge header or `s-maxage` fallback. + pub edge_ttl: Option, + /// Optional `stale-while-revalidate` duration. + pub stale_while_revalidate: Option, + /// Optional `stale-if-error` duration. + pub stale_if_error: Option, + /// Whether to render `immutable` for browser caches. + pub immutable: bool, +} + +impl CachePolicy { + /// Create a public immutable policy for content-addressed static assets. + #[must_use] + pub const fn public_immutable(ttl: Duration) -> Self { + Self { + visibility: CacheVisibility::Public, + browser_ttl: Some(ttl), + edge_ttl: Some(ttl), + stale_while_revalidate: None, + stale_if_error: None, + immutable: true, + } + } + + /// Create the current short TSJS fallback policy for unversioned/mismatched requests. + #[must_use] + pub const fn public_short_with_stale( + ttl: Duration, + stale_while_revalidate: Duration, + stale_if_error: Duration, + ) -> Self { + Self { + visibility: CacheVisibility::Public, + browser_ttl: Some(ttl), + edge_ttl: Some(ttl), + stale_while_revalidate: Some(stale_while_revalidate), + stale_if_error: Some(stale_if_error), + immutable: false, + } + } + + /// Create a private revalidation policy for personalized browser responses. + #[must_use] + pub const fn private_revalidate() -> Self { + Self { + visibility: CacheVisibility::Private, + browser_ttl: Some(Duration::from_secs(0)), + edge_ttl: None, + stale_while_revalidate: None, + stale_if_error: None, + immutable: false, + } + } + + /// Render the browser-facing `Cache-Control` value. + #[must_use] + pub fn cache_control_value(self, edge_header: EdgeCacheHeader) -> String { + let mut directives = Vec::new(); + directives.push(self.visibility.directive().to_string()); + + if let Some(ttl) = self.browser_ttl { + directives.push(format!("max-age={}", ttl.as_secs())); + } + + if edge_header == EdgeCacheHeader::SMaxageFallback { + if let Some(ttl) = self + .edge_ttl + .filter(|_| self.visibility == CacheVisibility::Public) + { + directives.push(format!("s-maxage={}", ttl.as_secs())); + } + } + + if let Some(ttl) = self.stale_while_revalidate { + directives.push(format!("stale-while-revalidate={}", ttl.as_secs())); + } + + if let Some(ttl) = self.stale_if_error { + directives.push(format!("stale-if-error={}", ttl.as_secs())); + } + + if self.immutable && self.browser_ttl.is_some_and(|ttl| ttl.as_secs() > 0) { + directives.push("immutable".to_string()); + } + + directives.join(", ") + } + + /// Render the separate edge-cache header value, if this policy should emit one. + #[must_use] + pub fn edge_header_value(self, edge_header: EdgeCacheHeader) -> Option { + if self.visibility != CacheVisibility::Public { + return None; + } + if matches!( + edge_header, + EdgeCacheHeader::None | EdgeCacheHeader::SMaxageFallback + ) { + return None; + } + + let edge_ttl = self.edge_ttl?; + let mut directives = vec![format!("max-age={}", edge_ttl.as_secs())]; + + if let Some(ttl) = self.stale_while_revalidate { + directives.push(format!("stale-while-revalidate={}", ttl.as_secs())); + } + + if let Some(ttl) = self.stale_if_error { + directives.push(format!("stale-if-error={}", ttl.as_secs())); + } + + Some(directives.join(", ")) + } + + /// Apply the policy to response headers for the selected runtime edge header. + /// + /// # Panics + /// + /// Panics if the internally-rendered cache header values are not valid HTTP + /// header values. This should not happen because values are generated from + /// fixed directive names and numeric durations. + pub fn apply_to_headers(self, headers: &mut HeaderMap, edge_header: EdgeCacheHeader) { + let cache_control = self.cache_control_value(edge_header); + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_str(&cache_control) + .expect("should render a valid cache-control header"), + ); + + remove_edge_cache_headers(headers); + if let Some(header_name) = edge_header.header_name() { + if let Some(value) = self.edge_header_value(edge_header) { + headers.insert( + header_name, + HeaderValue::from_str(&value) + .expect("should render a valid edge cache-control header"), + ); + } + } + } +} + +/// Cache-control mode, including explicitly uncacheable responses. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum CacheControlPolicy { + /// Apply a regular TTL-based cache policy. + Store(CachePolicy), + /// Apply `Cache-Control: no-store, private` and strip shared-cache headers. + NoStorePrivate, +} + +impl CacheControlPolicy { + /// Apply this cache-control mode to response headers. + /// + /// # Panics + /// + /// Panics if an internally-rendered cache header value is not valid. This + /// should not happen because values are generated from fixed directive names + /// and numeric durations. + pub fn apply_to_headers(self, headers: &mut HeaderMap, edge_header: EdgeCacheHeader) { + match self { + Self::Store(policy) => policy.apply_to_headers(headers, edge_header), + Self::NoStorePrivate => apply_no_store_private_to_headers(headers), + } + } +} + +impl From for CacheControlPolicy { + fn from(policy: CachePolicy) -> Self { + Self::Store(policy) + } +} + +/// Remove every runtime-specific shared-cache header owned by this crate. +pub fn remove_edge_cache_headers(headers: &mut HeaderMap) { + for name in EDGE_CACHE_HEADER_NAMES { + headers.remove(*name); + } +} + +/// Return true when `name` is an edge-cache header owned by this crate. +#[must_use] +pub fn is_edge_cache_header_name(name: &str) -> bool { + EDGE_CACHE_HEADER_NAMES + .iter() + .any(|candidate| name.eq_ignore_ascii_case(candidate)) +} + +/// Return true when a `Cache-Control` field value contains `directive`. +/// +/// Matching is directive-name exact and case-insensitive. Pseudo-directives such +/// as `not-private` or `no-storey` do not match `private` / `no-store`. +#[must_use] +pub fn cache_control_value_has_directive(value: &str, directive: &str) -> bool { + value.split(',').any(|part| { + let part = part.trim(); + let directive_name = part + .find(['=', ';']) + .map_or(part, |end| &part[..end]) + .trim(); + directive_name.eq_ignore_ascii_case(directive) + }) +} + +/// Return true when any `Cache-Control` header value contains `directive`. +#[must_use] +pub fn cache_control_headers_have_directive(headers: &HeaderMap, directive: &str) -> bool { + headers + .get_all(header::CACHE_CONTROL) + .iter() + .filter_map(|value| value.to_str().ok()) + .any(|value| cache_control_value_has_directive(value, directive)) +} + +/// Return true when response cache-control contains exact `private` or `no-store`. +#[must_use] +pub fn cache_control_headers_are_private_or_no_store(headers: &HeaderMap) -> bool { + cache_control_headers_have_directive(headers, "private") + || cache_control_headers_have_directive(headers, "no-store") +} + +/// Apply `Cache-Control: no-store, private` and strip all shared-cache headers. +/// +/// # Panics +/// +/// Panics if the fixed no-store cache-control value is not a valid HTTP header +/// value. This should not happen for a static ASCII value. +pub fn apply_no_store_private_to_headers(headers: &mut HeaderMap) { + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_static(NO_STORE_PRIVATE_CACHE_CONTROL), + ); + remove_edge_cache_headers(headers); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn public_immutable_renders_browser_and_fastly_headers() { + let policy = CachePolicy::public_immutable(Duration::from_secs(31_536_000)); + let mut headers = HeaderMap::new(); + + policy.apply_to_headers(&mut headers, EdgeCacheHeader::SurrogateControl); + + assert_eq!( + headers + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("public, max-age=31536000, immutable"), + "should render immutable browser policy" + ); + assert_eq!( + headers + .get(HEADER_SURROGATE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("max-age=31536000"), + "should render Fastly edge TTL" + ); + } + + #[test] + fn s_maxage_fallback_renders_edge_ttl_inside_cache_control() { + let policy = CachePolicy::public_short_with_stale( + Duration::from_secs(300), + Duration::from_secs(60), + Duration::from_secs(86_400), + ); + + assert_eq!( + policy.cache_control_value(EdgeCacheHeader::SMaxageFallback), + "public, max-age=300, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400", + "should render portable two-tier fallback" + ); + } + + #[test] + fn generic_cdn_header_renders_cdn_only_policy() { + let policy = CachePolicy::public_short_with_stale( + Duration::from_secs(300), + Duration::from_secs(60), + Duration::from_secs(86_400), + ); + let mut headers = HeaderMap::new(); + + policy.apply_to_headers(&mut headers, EdgeCacheHeader::CdnCacheControl); + + assert_eq!( + headers + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("public, max-age=300, stale-while-revalidate=60, stale-if-error=86400"), + "should keep CDN TTL out of browser cache-control when using targeted CDN header" + ); + assert_eq!( + headers + .get(HEADER_CDN_CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("max-age=300, stale-while-revalidate=60, stale-if-error=86400"), + "should render generic CDN cache policy" + ); + } + + #[test] + fn cloudflare_specific_header_renders_cdn_only_policy() { + let policy = CachePolicy::public_short_with_stale( + Duration::from_secs(300), + Duration::from_secs(60), + Duration::from_secs(86_400), + ); + let mut headers = HeaderMap::new(); + + policy.apply_to_headers(&mut headers, EdgeCacheHeader::CloudflareCdnCacheControl); + + assert_eq!( + headers + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("public, max-age=300, stale-while-revalidate=60, stale-if-error=86400"), + "should keep CDN TTL out of browser cache-control when using targeted CDN header" + ); + assert_eq!( + headers + .get(HEADER_CLOUDFLARE_CDN_CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("max-age=300, stale-while-revalidate=60, stale-if-error=86400"), + "should render Cloudflare-specific CDN cache policy" + ); + assert!( + headers.get(HEADER_CDN_CACHE_CONTROL).is_none(), + "should not also emit the generic CDN cache header" + ); + } + + #[test] + fn private_policy_removes_stale_edge_headers() { + let policy = CachePolicy::private_revalidate(); + let mut headers = HeaderMap::new(); + headers.insert( + HEADER_SURROGATE_CONTROL, + HeaderValue::from_static("max-age=60"), + ); + headers.insert( + HEADER_CDN_CACHE_CONTROL, + HeaderValue::from_static("max-age=60"), + ); + headers.insert( + HEADER_CLOUDFLARE_CDN_CACHE_CONTROL, + HeaderValue::from_static("max-age=60"), + ); + + policy.apply_to_headers(&mut headers, EdgeCacheHeader::SurrogateControl); + + assert_eq!( + headers + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("private, max-age=0"), + "should render private browser policy" + ); + assert!( + headers.get(HEADER_SURROGATE_CONTROL).is_none(), + "should remove Fastly shared-cache headers for private responses" + ); + assert!( + headers.get(HEADER_CDN_CACHE_CONTROL).is_none(), + "should remove generic CDN cache headers for private responses" + ); + assert!( + headers.get(HEADER_CLOUDFLARE_CDN_CACHE_CONTROL).is_none(), + "should remove Cloudflare cache headers for private responses" + ); + } + + #[test] + fn no_store_policy_removes_stale_edge_headers() { + let mut headers = HeaderMap::new(); + headers.insert( + HEADER_SURROGATE_CONTROL, + HeaderValue::from_static("max-age=60"), + ); + headers.insert( + HEADER_FASTLY_SURROGATE_CONTROL, + HeaderValue::from_static("max-age=60"), + ); + headers.insert( + HEADER_CDN_CACHE_CONTROL, + HeaderValue::from_static("max-age=60"), + ); + headers.insert( + HEADER_CLOUDFLARE_CDN_CACHE_CONTROL, + HeaderValue::from_static("max-age=60"), + ); + + CacheControlPolicy::NoStorePrivate.apply_to_headers(&mut headers, EdgeCacheHeader::None); + + assert_eq!( + headers + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some(NO_STORE_PRIVATE_CACHE_CONTROL), + "should render no-store cache policy" + ); + assert!( + headers.get(HEADER_SURROGATE_CONTROL).is_none() + && headers.get(HEADER_FASTLY_SURROGATE_CONTROL).is_none() + && headers.get(HEADER_CDN_CACHE_CONTROL).is_none() + && headers.get(HEADER_CLOUDFLARE_CDN_CACHE_CONTROL).is_none(), + "should remove all shared-cache headers" + ); + } + + #[test] + fn immutable_is_omitted_without_positive_browser_ttl() { + let policy = CachePolicy { + visibility: CacheVisibility::Public, + browser_ttl: Some(Duration::from_secs(0)), + edge_ttl: Some(Duration::from_secs(60)), + stale_while_revalidate: None, + stale_if_error: None, + immutable: true, + }; + + assert_eq!( + policy.cache_control_value(EdgeCacheHeader::None), + "public, max-age=0", + "should not render immutable without a positive browser TTL" + ); + } + + #[test] + fn cache_control_directive_matching_is_exact() { + assert!( + cache_control_value_has_directive("public, max-age=60, No-Store", "no-store"), + "should match real no-store directives case-insensitively" + ); + assert!( + cache_control_value_has_directive("private=\"set-cookie\", max-age=0", "private"), + "should match directives with arguments" + ); + assert!( + !cache_control_value_has_directive("public, no-storey, not-private", "no-store"), + "should not match pseudo-directives by substring" + ); + assert!( + !cache_control_value_has_directive("public, no-storey, not-private", "private"), + "should not match pseudo-private directives by substring" + ); + } + + #[test] + fn cache_control_header_matching_checks_all_values() { + let mut headers = HeaderMap::new(); + headers.append(header::CACHE_CONTROL, HeaderValue::from_static("public")); + headers.append( + header::CACHE_CONTROL, + HeaderValue::from_static("max-age=60"), + ); + headers.append(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + + assert!( + cache_control_headers_are_private_or_no_store(&headers), + "should inspect every Cache-Control field value" + ); + } +} diff --git a/crates/trusted-server-core/src/http_util.rs b/crates/trusted-server-core/src/http_util.rs index 74830ff9b..96d7a6cbd 100644 --- a/crates/trusted-server-core/src/http_util.rs +++ b/crates/trusted-server-core/src/http_util.rs @@ -4,8 +4,10 @@ use edgezero_core::body::Body as EdgeBody; use error_stack::Report; use http::{header, Request, Response, StatusCode}; use sha2::{Digest as _, Sha256}; +use std::time::Duration; use subtle::ConstantTimeEq as _; +use crate::cache_policy::{CachePolicy, EdgeCacheHeader}; use crate::constants::INTERNAL_HEADERS; use crate::error::TrustedServerError; use crate::platform::ClientInfo; @@ -279,44 +281,42 @@ pub fn serve_static_with_etag( body: &str, req: &Request, content_type: &str, + edge_header: EdgeCacheHeader, ) -> Response { - // Compute ETag for conditional caching let hash = Sha256::digest(body.as_bytes()); let etag = format!("\"sha256-{}\"", hex::encode(hash)); + let short_policy = CachePolicy::public_short_with_stale( + Duration::from_secs(300), + Duration::from_secs(60), + Duration::from_secs(86_400), + ); - // If-None-Match handling for 304 responses if let Some(if_none_match) = req .headers() .get(header::IF_NONE_MATCH) .and_then(|h| h.to_str().ok()) { if if_none_match == etag { - return Response::builder() + let mut response = Response::builder() .status(StatusCode::NOT_MODIFIED) .header(header::ETAG, &etag) - .header( - header::CACHE_CONTROL, - "public, max-age=300, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400", - ) - .header("surrogate-control", "max-age=300") .header(header::VARY, "Accept-Encoding") .body(EdgeBody::empty()) .expect("should build 304 static response"); + short_policy.apply_to_headers(response.headers_mut(), edge_header); + return response; } } - Response::builder() + let mut response = Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, content_type) - .header( - header::CACHE_CONTROL, - "public, max-age=300, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400", - ) - .header("surrogate-control", "max-age=300") .header(header::ETAG, &etag) .header(header::VARY, "Accept-Encoding") .body(EdgeBody::from(body.as_bytes())) - .expect("should build static response") + .expect("should build static response"); + short_policy.apply_to_headers(response.headers_mut(), edge_header); + response } /// Encrypts a URL using XChaCha20-Poly1305 with a key derived from the publisher `proxy_secret`. diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 32a1c2a85..fc0e6847a 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -22,6 +22,7 @@ use crate::auction::provider::AuctionProvider; use crate::auction::types::{ AuctionContext, AuctionRequest, AuctionResponse, Bid as AuctionBid, MediaType, }; +use crate::cache_policy::{CacheControlPolicy, EdgeCacheHeader}; use crate::consent_config::ConsentForwardingMode; use crate::cookies::{strip_cookies, CONSENT_COOKIE_NAMES}; use crate::error::TrustedServerError; @@ -568,14 +569,16 @@ impl PrebidIntegration { ) -> Result, Report> { let body = "// Script overridden by Trusted Server\n"; - http::Response::builder() + let mut response = http::Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, PREBID_BUNDLE_CONTENT_TYPE) - .header(header::CACHE_CONTROL, "public, max-age=31536000") .body(EdgeBody::from(body)) .change_context(TrustedServerError::Prebid { message: "Failed to build Prebid script handler response".to_string(), - }) + })?; + CacheControlPolicy::NoStorePrivate + .apply_to_headers(response.headers_mut(), EdgeCacheHeader::None); + Ok(response) } fn external_bundle_script_src(&self) -> String { @@ -2925,7 +2928,14 @@ external_bundle_sri = "sha384-AAAA" .get(header::CACHE_CONTROL) .and_then(|value| value.to_str().ok()) .expect("should have cache-control"); - assert!(cache_control.contains("max-age=31536000")); + assert_eq!( + cache_control, "no-store, private", + "neutralized stable shim must not be cached for a year" + ); + assert!( + response.headers().get("surrogate-control").is_none(), + "neutralized shim must not emit edge-cache headers" + ); let body = String::from_utf8( response diff --git a/crates/trusted-server-core/src/integrations/testlight.rs b/crates/trusted-server-core/src/integrations/testlight.rs index 5e32cc89c..3feda5b62 100644 --- a/crates/trusted-server-core/src/integrations/testlight.rs +++ b/crates/trusted-server-core/src/integrations/testlight.rs @@ -265,8 +265,9 @@ fn default_timeout_ms() -> u32 { } fn default_shim_src() -> String { - // Testlight is included in the unified bundle, so we return the unified script source. - // Uses conservative all-module hash since the registry is unavailable at config time. + // Testlight is included in the unified bundle, so return the registry-free + // unified script source. It intentionally omits `?v=` because the exact + // enabled module set is unavailable at config-default time. tsjs::tsjs_unified_script_src() } diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 70a4d6cfd..48e92faed 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -35,6 +35,7 @@ pub(crate) mod asset_image_optimizer; pub mod auction; pub mod auction_config_types; pub mod auth; +pub mod cache_policy; pub mod config; pub mod config_payload; pub mod consent; diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 19c10a80d..8737e88f1 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -11,6 +11,9 @@ use std::sync::{Arc, LazyLock, Mutex}; use std::time::Duration; use web_time::{SystemTime, UNIX_EPOCH}; +use crate::cache_policy::{ + apply_no_store_private_to_headers, CachePolicy, EdgeCacheHeader, NO_STORE_PRIVATE_CACHE_CONTROL, +}; use crate::constants::{ HEADER_ACCEPT, HEADER_ACCEPT_ENCODING, HEADER_ACCEPT_LANGUAGE, HEADER_REFERER, HEADER_USER_AGENT, HEADER_X_FORWARDED_FOR, @@ -94,7 +97,7 @@ const ASSET_PROXY_STRIP_RESPONSE_HEADERS: [&str; 3] = ["set-cookie", "strict-transport-security", "clear-site-data"]; /// Cache-control value used when asset proxy responses must not be stored. -pub const ASSET_NO_STORE_PRIVATE_CACHE_CONTROL: &str = "no-store, private"; +pub const ASSET_NO_STORE_PRIVATE_CACHE_CONTROL: &str = NO_STORE_PRIVATE_CACHE_CONTROL; /// Cache policy metadata emitted by the asset proxy handler. /// @@ -107,13 +110,23 @@ pub enum AssetProxyCachePolicy { OriginControlled, /// Reapply `Cache-Control: no-store, private` after standard finalization. NoStorePrivate, + /// Reapply an operator-selected normalized cache policy after finalization. + Normalized(CachePolicy), } impl AssetProxyCachePolicy { /// Apply protected cache headers after route-level response finalization. - pub fn apply_after_route_finalization(self, response: &mut Response) { - if self == Self::NoStorePrivate { - apply_no_store_cache_control(response); + pub fn apply_after_route_finalization( + self, + response: &mut Response, + edge_header: EdgeCacheHeader, + ) { + match self { + Self::OriginControlled => {} + Self::NoStorePrivate => apply_no_store_cache_control(response), + Self::Normalized(policy) => { + policy.apply_to_headers(response.headers_mut(), edge_header) + } } } } @@ -167,6 +180,11 @@ impl AssetProxyResponse { apply_no_store_cache_control(&mut self.response); } + fn apply_normalized_cache_policy(&mut self, policy: CachePolicy) { + self.cache_policy = AssetProxyCachePolicy::Normalized(policy); + policy.apply_to_headers(self.response.headers_mut(), EdgeCacheHeader::None); + } + /// Return cache policy metadata for router finalization. #[must_use] pub fn cache_policy(&self) -> AssetProxyCachePolicy { @@ -969,10 +987,7 @@ fn strip_asset_proxy_response_headers(response: &mut Response) { } fn apply_no_store_cache_control(response: &mut Response) { - response.headers_mut().insert( - header::CACHE_CONTROL, - HeaderValue::from_static(ASSET_NO_STORE_PRIVATE_CACHE_CONTROL), - ); + apply_no_store_private_to_headers(response.headers_mut()); } fn should_preflight_s3( @@ -1155,6 +1170,13 @@ pub async fn handle_asset_proxy_request( let mut response = platform_response_to_fastly_asset(platform_resp); strip_asset_proxy_response_headers(response.response_mut()); + let status = response.response().status(); + if status.is_success() || status == StatusCode::NOT_MODIFIED { + if let Some(policy) = settings.asset_cache_policy_for_path(incoming_path)? { + response.apply_normalized_cache_policy(policy); + } + } + Ok(response) } @@ -2027,6 +2049,7 @@ mod tests { use std::collections::{HashMap, VecDeque}; use std::io; use std::sync::{Arc, Mutex}; + use std::time::Duration; use super::{ asset_origin_host_header, asset_path_skips_image_optimizer, build_asset_proxy_target_url, @@ -2037,6 +2060,7 @@ mod tests { AssetProxyCachePolicy, ProxyRequestConfig, IMAGE_FALLBACK_CONTENT_TYPE, SUPPORTED_ENCODINGS, }; + use crate::cache_policy::{CachePolicy, EdgeCacheHeader}; use crate::constants::{HEADER_ACCEPT, HEADER_X_FORWARDED_FOR}; use crate::creative; use crate::error::{IntoHttpResponse, TrustedServerError}; @@ -2051,9 +2075,9 @@ mod tests { use crate::settings::{ AssetImageOptimizerConfig, AssetOriginAuth, ImageOptimizerAspectRatioConfig, ImageOptimizerCropOffsetsConfig, ImageOptimizerProfileSet, ImageOptimizerSettings, - OriginQueryPolicy, ProxyAssetRoute, S3SigV4AuthConfig, UnknownProfilePolicy, + OriginQueryPolicy, ProxyAssetRoute, S3SigV4AuthConfig, Settings, UnknownProfilePolicy, }; - use crate::test_support::tests::create_test_settings; + use crate::test_support::tests::{crate_test_settings_str, create_test_settings}; use bytes::Bytes; use edgezero_core::body::Body as EdgeBody; use edgezero_core::http::response_builder as edge_response_builder; @@ -3728,6 +3752,130 @@ mod tests { }); } + #[test] + fn handle_asset_proxy_request_applies_configured_normalized_cache_policy() { + futures::executor::block_on(async { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 200, + b"asset".to_vec(), + vec![(header::CACHE_CONTROL.as_str(), "no-store")], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let settings = Settings::from_toml(&format!( + r#"{} + + [[cache.asset_rules]] + id = "fingerprinted-assets" + enabled = true + path_globs = ["/assets/**/*.js"] + requires_hash_in_filename = true + visibility = "public" + browser_ttl_seconds = 31536000 + edge_ttl_seconds = 31536000 + immutable = true + "#, + crate_test_settings_str() + )) + .expect("should parse settings with cache asset rule"); + let req = build_http_request( + Method::GET, + "https://www.example.com/assets/app.0123abcd.js", + ); + let route = ProxyAssetRoute::new("/assets/", "https://assets.example.com"); + + let asset_response = handle_asset_proxy_request(&settings, &services, req, &route) + .await + .expect("should proxy asset request"); + assert_eq!( + asset_response.cache_policy(), + AssetProxyCachePolicy::Normalized(CachePolicy::public_immutable( + Duration::from_secs(31_536_000) + )), + "should carry normalized cache policy metadata" + ); + + let mut response = asset_response + .into_response() + .expect("should return buffered asset response"); + assert_eq!( + response_header(&response, header::CACHE_CONTROL), + Some("public, max-age=31536000, immutable"), + "core response should apply browser cache policy immediately" + ); + assert!( + response.headers().get("surrogate-control").is_none(), + "runtime-specific edge header should wait for adapter finalization" + ); + + AssetProxyCachePolicy::Normalized(CachePolicy::public_immutable(Duration::from_secs( + 31_536_000, + ))) + .apply_after_route_finalization(&mut response, EdgeCacheHeader::SurrogateControl); + assert_eq!( + response + .headers() + .get("surrogate-control") + .and_then(|value| value.to_str().ok()), + Some("max-age=31536000"), + "Fastly finalization should render Surrogate-Control" + ); + }); + } + + #[test] + fn handle_asset_proxy_request_leaves_non_matching_assets_origin_controlled() { + futures::executor::block_on(async { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 200, + b"asset".to_vec(), + vec![(header::CACHE_CONTROL.as_str(), "public, max-age=60")], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let settings = Settings::from_toml(&format!( + r#"{} + + [[cache.asset_rules]] + id = "fingerprinted-assets" + enabled = true + path_globs = ["/assets/**/*.js"] + requires_hash_in_filename = true + visibility = "public" + browser_ttl_seconds = 31536000 + edge_ttl_seconds = 31536000 + immutable = true + "#, + crate_test_settings_str() + )) + .expect("should parse settings with cache asset rule"); + let req = build_http_request(Method::GET, "https://www.example.com/assets/app.js"); + let route = ProxyAssetRoute::new("/assets/", "https://assets.example.com"); + + let asset_response = handle_asset_proxy_request(&settings, &services, req, &route) + .await + .expect("should proxy asset request"); + + assert_eq!( + asset_response.cache_policy(), + AssetProxyCachePolicy::OriginControlled, + "non-fingerprinted file should not receive normalized immutable policy" + ); + let response = asset_response + .into_response() + .expect("should return buffered asset response"); + assert_eq!( + response_header(&response, header::CACHE_CONTROL), + Some("public, max-age=60"), + "origin-controlled response should preserve origin cache header" + ); + }); + } + fn test_profile_set() -> ImageOptimizerProfileSet { let mut profiles = HashMap::new(); profiles.insert("default".to_string(), "width=1920".to_string()); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 6b0ea3a5d..acf92959c 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -40,6 +40,9 @@ use crate::auction::telemetry::{ use crate::auction::types::{ AuctionContext, AuctionRequest, Bid, DeviceInfo, PublisherInfo, SiteInfo, UserInfo, }; +use crate::cache_policy::{ + cache_control_headers_are_private_or_no_store, CachePolicy, EdgeCacheHeader, +}; use crate::consent::{consent_allows_server_side_auction, gate_eids_by_consent}; use crate::constants::{COOKIE_TS_EIDS, HEADER_X_COMPRESS_HINT}; use crate::cookies::handle_request_cookies; @@ -165,6 +168,7 @@ fn accept_encoding_qvalue(header_value: &str, target: &str) -> Option { pub fn handle_tsjs_dynamic( req: &Request, integration_registry: &IntegrationRegistry, + edge_header: EdgeCacheHeader, ) -> Result, Report> { const PREFIX: &str = "/static/tsjs="; const UNIFIED_FILENAMES: &[&str] = &["tsjs-unified.js", "tsjs-unified.min.js"]; @@ -179,10 +183,8 @@ pub fn handle_tsjs_dynamic( // Serve core + immediate modules (excludes deferred like prebid) let module_ids = integration_registry.js_module_ids_immediate(); let body = trusted_server_js::concatenate_modules(&module_ids); - let mut resp = serve_static_with_etag(&body, req, "application/javascript; charset=utf-8"); - resp.headers_mut() - .insert(HEADER_X_COMPRESS_HINT, HeaderValue::from_static("on")); - return Ok(resp); + let hash = trusted_server_js::concatenated_hash(&module_ids); + return Ok(serve_tsjs_static(req, &body, &hash, edge_header)); } if let Some(module_id) = parse_deferred_module_filename(filename) { @@ -191,19 +193,46 @@ pub fn handle_tsjs_dynamic( if !deferred_ids.contains(&module_id) { return Ok(not_found_response()); } - if let Some(content) = trusted_server_js::module_bundle(module_id) { - let mut resp = - serve_static_with_etag(content, req, "application/javascript; charset=utf-8"); - resp.headers_mut() - .insert(HEADER_X_COMPRESS_HINT, HeaderValue::from_static("on")); - return Ok(resp); + if let (Some(content), Some(hash)) = ( + trusted_server_js::module_bundle(module_id), + trusted_server_js::single_module_hash(module_id), + ) { + return Ok(serve_tsjs_static(req, content, hash, edge_header)); } } Ok(not_found_response()) } -/// Extract a module ID from a deferred-module filename like `tsjs-sourcepoint.min.js`. +fn serve_tsjs_static( + req: &Request, + body: &str, + expected_hash: &str, + edge_header: EdgeCacheHeader, +) -> Response { + let mut resp = serve_static_with_etag( + body, + req, + "application/javascript; charset=utf-8", + edge_header, + ); + if request_version_hash(req).is_some_and(|hash| hash == expected_hash) { + CachePolicy::public_immutable(Duration::from_secs(31_536_000)) + .apply_to_headers(resp.headers_mut(), edge_header); + } + resp.headers_mut() + .insert(HEADER_X_COMPRESS_HINT, HeaderValue::from_static("on")); + resp +} + +fn request_version_hash(req: &Request) -> Option<&str> { + req.uri().query()?.split('&').find_map(|pair| { + let (name, value) = pair.split_once('=')?; + (name == "v").then_some(value) + }) +} + +/// Extract a module ID from a deferred-module filename like `tsjs-prebid.min.js`. /// /// Returns `Some(&'static str)` if the filename matches a known JS module ID, /// `None` otherwise. The caller must additionally verify that the module is @@ -427,6 +456,33 @@ pub(crate) fn classify_response_route( ResponseRoute::Stream } +fn response_cache_control_is_private_or_no_store(response: &Response) -> bool { + cache_control_headers_are_private_or_no_store(response.headers()) +} + +fn apply_publisher_asset_cache_policy( + settings: &Settings, + path: &str, + cache_rule_method: bool, + edge_header: EdgeCacheHeader, + response: &mut Response, +) -> Result<(), Report> { + if !cache_rule_method || response_cache_control_is_private_or_no_store(response) { + return Ok(()); + } + + let status = response.status(); + if !(status.is_success() || status == StatusCode::NOT_MODIFIED) { + return Ok(()); + } + + if let Some(policy) = settings.asset_cache_policy_for_path(path)? { + policy.apply_to_headers(response.headers_mut(), edge_header); + } + + Ok(()) +} + /// Owned version of [`ProcessResponseParams`] for returning from /// [`handle_publisher_request`] without lifetime issues. pub struct OwnedProcessResponseParams { @@ -1306,6 +1362,7 @@ pub async fn handle_publisher_request( ec_context: &mut EcContext, auction: AuctionDispatch<'_>, mut req: Request, + edge_header: EdgeCacheHeader, ) -> Result> { log::debug!("Proxying request to publisher_origin"); @@ -1389,6 +1446,7 @@ pub async fn handle_publisher_request( let request_path = req.uri().path().to_string(); let is_get = req.method() == http::Method::GET; + let cache_rule_method = req.method() == Method::GET || req.method() == Method::HEAD; let is_prefetch = is_prefetch_request(&req); let is_bot = is_bot_user_agent(&req); @@ -1640,7 +1698,7 @@ pub async fn handle_publisher_request( // §4.7: HTML carrying inline per-user bid data must never be shared-cached. // `private, max-age=0` is deliberate (not `no-store`): it keeps the page // BFCache-eligible while restricting reuse to the same user's browser with - // revalidation; `Surrogate-Control` removal handles the Fastly shared cache. + // revalidation; edge-cache header removal handles shared CDN caches. // // Gate on `should_run_ad_stack` rather than content-type alone: when no slot // matched, the feature is disabled, or this is not an ad-eligible navigation, @@ -1655,14 +1713,17 @@ pub async fn handle_publisher_request( .and_then(|h| h.to_str().ok()) .unwrap_or_default(); if should_run_ad_stack && is_html_content_type(origin_content_type) { - response.headers_mut().insert( - header::CACHE_CONTROL, - HeaderValue::from_static("private, max-age=0"), - ); - response.headers_mut().remove("surrogate-control"); - response.headers_mut().remove("fastly-surrogate-control"); + CachePolicy::private_revalidate().apply_to_headers(response.headers_mut(), edge_header); } + apply_publisher_asset_cache_policy( + settings, + &request_path, + cache_rule_method, + edge_header, + &mut response, + )?; + let content_type = response .headers() .get(header::CONTENT_TYPE) @@ -2442,7 +2503,7 @@ mod tests { use crate::platform::test_support::{ build_services_with_http_client, noop_services, StubHttpClient, }; - use crate::test_support::tests::create_test_settings; + use crate::test_support::tests::{crate_test_settings_str, create_test_settings}; use edgezero_core::body::Body as EdgeBody; use http::{header, Method, Request as HttpRequest, StatusCode}; use std::sync::Arc; @@ -2695,6 +2756,7 @@ mod tests { registry: None, }, req, + EdgeCacheHeader::SurrogateControl, ) .await .expect("should proxy publisher request") @@ -2733,6 +2795,130 @@ mod tests { ); } + #[tokio::test] + async fn publisher_request_applies_configured_asset_cache_policy() { + let settings = Settings::from_toml(&format!( + r#"{} + + [[cache.asset_rules]] + id = "publisher-fingerprinted-assets" + enabled = true + path_globs = ["/assets/**/*.png"] + requires_hash_in_filename = true + visibility = "public" + browser_ttl_seconds = 31536000 + edge_ttl_seconds = 31536000 + immutable = true + "#, + crate_test_settings_str() + )) + .expect("should parse settings with cache rule"); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 200, + b"png".to_vec(), + vec![ + (header::CONTENT_TYPE.as_str(), "image/png"), + (header::CACHE_CONTROL.as_str(), "public, max-age=60"), + ], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let req = HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/assets/logo.0123abcd.png") + .header(header::HOST, "publisher.example") + .body(EdgeBody::empty()) + .expect("should build request"); + + let response = match run_publisher_proxy(&settings, &services, req).await { + PublisherResponse::PassThrough { response, .. } => response, + PublisherResponse::Buffered(response) | PublisherResponse::Stream { response, .. } => { + response + } + }; + + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("public, max-age=31536000, immutable"), + "matched publisher-origin asset should receive normalized immutable policy" + ); + assert_eq!( + response + .headers() + .get("surrogate-control") + .and_then(|value| value.to_str().ok()), + Some("max-age=31536000"), + "publisher-origin asset should receive selected runtime edge header" + ); + } + + #[tokio::test] + async fn publisher_asset_cache_policy_respects_split_no_store_origin_header() { + let settings = Settings::from_toml(&format!( + r#"{} + + [[cache.asset_rules]] + id = "publisher-fingerprinted-assets" + enabled = true + path_globs = ["/assets/**/*.png"] + requires_hash_in_filename = true + visibility = "public" + browser_ttl_seconds = 31536000 + edge_ttl_seconds = 31536000 + immutable = true + "#, + crate_test_settings_str() + )) + .expect("should parse settings with cache rule"); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 200, + b"png".to_vec(), + vec![ + (header::CONTENT_TYPE.as_str(), "image/png"), + (header::CACHE_CONTROL.as_str(), "public, max-age=60"), + (header::CACHE_CONTROL.as_str(), "no-store"), + ], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let req = HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/assets/logo.0123abcd.png") + .header(header::HOST, "publisher.example") + .body(EdgeBody::empty()) + .expect("should build request"); + + let response = match run_publisher_proxy(&settings, &services, req).await { + PublisherResponse::PassThrough { response, .. } => response, + PublisherResponse::Buffered(response) | PublisherResponse::Stream { response, .. } => { + response + } + }; + + let cache_control_values = response + .headers() + .get_all(header::CACHE_CONTROL) + .iter() + .filter_map(|value| value.to_str().ok()) + .collect::>(); + assert_eq!( + cache_control_values, + vec!["public, max-age=60", "no-store"], + "origin no-store in a later Cache-Control field should prevent normalized upgrade" + ); + assert!( + response.headers().get("surrogate-control").is_none(), + "origin no-store response must not receive edge-cache headers" + ); + } + #[tokio::test] async fn handle_publisher_request_does_not_self_generate_ec() { // EC generation is the adapter's real-browser-gated responsibility. This @@ -2780,6 +2966,7 @@ mod tests { registry: None, }, req, + EdgeCacheHeader::SurrogateControl, ) .await .expect("should proxy publisher request"); @@ -3406,7 +3593,8 @@ mod tests { "https://publisher.example/static/tsjs=unknown.js", ); - let response = handle_tsjs_dynamic(&req, ®istry).expect("should handle tsjs request"); + let response = handle_tsjs_dynamic(&req, ®istry, EdgeCacheHeader::SurrogateControl) + .expect("should handle tsjs request"); assert_eq!(response.status(), StatusCode::NOT_FOUND); } @@ -3420,8 +3608,126 @@ mod tests { "https://publisher.example/static/tsjs=tsjs-unified.min.js", ); - let response = handle_tsjs_dynamic(&req, ®istry).expect("should handle tsjs request"); + let response = handle_tsjs_dynamic(&req, ®istry, EdgeCacheHeader::SurrogateControl) + .expect("should handle tsjs request"); + assert_eq!(response.status(), StatusCode::OK); + } + + #[test] + fn tsjs_dynamic_uses_immutable_cache_for_matching_hash() { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let module_ids = registry.js_module_ids_immediate(); + let hash = trusted_server_js::concatenated_hash(&module_ids); + let req = build_request( + Method::GET, + &format!("https://publisher.example/static/tsjs=tsjs-unified.min.js?v={hash}"), + ); + + let response = handle_tsjs_dynamic(&req, ®istry, EdgeCacheHeader::SurrogateControl) + .expect("should handle tsjs request"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("public, max-age=31536000, immutable"), + "should make matching content-versioned bundle immutable" + ); + assert_eq!( + response + .headers() + .get("surrogate-control") + .and_then(|value| value.to_str().ok()), + Some("max-age=31536000"), + "should give Fastly edge cache the same immutable TTL" + ); + assert_eq!( + response + .headers() + .get(header::VARY) + .and_then(|value| value.to_str().ok()), + Some("Accept-Encoding"), + "should keep encoding in the cache key" + ); + assert_eq!( + response + .headers() + .get(HEADER_X_COMPRESS_HINT) + .and_then(|value| value.to_str().ok()), + Some("on"), + "should keep Fastly delivery compression hint" + ); + } + + #[test] + fn tsjs_dynamic_uses_cloudflare_edge_header_when_selected() { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let module_ids = registry.js_module_ids_immediate(); + let hash = trusted_server_js::concatenated_hash(&module_ids); + let req = build_request( + Method::GET, + &format!("https://publisher.example/static/tsjs=tsjs-unified.min.js?v={hash}"), + ); + + let response = + handle_tsjs_dynamic(&req, ®istry, EdgeCacheHeader::CloudflareCdnCacheControl) + .expect("should handle tsjs request"); + + assert_eq!( + response + .headers() + .get("cloudflare-cdn-cache-control") + .and_then(|value| value.to_str().ok()), + Some("max-age=31536000"), + "should render Cloudflare-specific edge cache header" + ); + assert!( + response.headers().get("surrogate-control").is_none(), + "Cloudflare responses should not emit Fastly Surrogate-Control" + ); + } + + #[test] + fn tsjs_dynamic_keeps_short_cache_for_mismatched_hash() { + let settings = create_test_settings(); + let registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + let req = build_request( + Method::GET, + "https://publisher.example/static/tsjs=tsjs-unified.min.js?v=not-the-hash", + ); + + let response = handle_tsjs_dynamic(&req, ®istry, EdgeCacheHeader::SurrogateControl) + .expect("should handle tsjs request"); + let cache_control = response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()) + .expect("should set cache-control"); + assert_eq!(response.status(), StatusCode::OK); + assert!( + cache_control.contains("max-age=300"), + "should keep short browser TTL for mismatched hash" + ); + assert!( + !cache_control.contains("immutable"), + "should not make mismatched hash requests immutable" + ); + assert_eq!( + response + .headers() + .get("surrogate-control") + .and_then(|value| value.to_str().ok()), + Some("max-age=300, stale-while-revalidate=60, stale-if-error=86400"), + "should keep short edge TTL for mismatched hash" + ); } #[test] @@ -3472,7 +3778,8 @@ mod tests { "https://publisher.example/static/tsjs=tsjs-prebid.min.js", ); - let response = handle_tsjs_dynamic(&req, ®istry).expect("should handle tsjs request"); + let response = handle_tsjs_dynamic(&req, ®istry, EdgeCacheHeader::SurrogateControl) + .expect("should handle tsjs request"); assert_eq!( response.status(), StatusCode::NOT_FOUND, @@ -3501,7 +3808,8 @@ mod tests { "https://publisher.example/static/tsjs=tsjs-prebid.min.js", ); - let response = handle_tsjs_dynamic(&req, ®istry).expect("should handle tsjs request"); + let response = handle_tsjs_dynamic(&req, ®istry, EdgeCacheHeader::SurrogateControl) + .expect("should handle tsjs request"); assert_eq!( response.status(), StatusCode::NOT_FOUND, @@ -3519,7 +3827,8 @@ mod tests { "https://publisher.example/static/tsjs=tsjs-evil.min.js", ); - let response = handle_tsjs_dynamic(&req, ®istry).expect("should handle tsjs request"); + let response = handle_tsjs_dynamic(&req, ®istry, EdgeCacheHeader::SurrogateControl) + .expect("should handle tsjs request"); assert_eq!( response.status(), StatusCode::NOT_FOUND, diff --git a/crates/trusted-server-core/src/response_privacy.rs b/crates/trusted-server-core/src/response_privacy.rs index 141ef3164..f9d564b6e 100644 --- a/crates/trusted-server-core/src/response_privacy.rs +++ b/crates/trusted-server-core/src/response_privacy.rs @@ -11,13 +11,18 @@ use edgezero_core::http::{header, HeaderName, HeaderValue, Response}; +use crate::cache_policy::{ + cache_control_headers_are_private_or_no_store, is_edge_cache_header_name, + remove_edge_cache_headers, +}; use crate::settings::Settings; -/// Surrogate cache headers stripped from every cookie-bearing response. -/// -/// A single source of truth so the adapter copies of the privacy downgrade -/// cannot drift apart. -pub const SURROGATE_CACHE_HEADERS: &[&str] = &["surrogate-control", "fastly-surrogate-control"]; +/// Runtime edge-cache headers stripped from private or cookie-bearing responses. +pub use crate::cache_policy::EDGE_CACHE_HEADER_NAMES as SURROGATE_CACHE_HEADERS; + +fn cache_control_is_private_or_no_store(response: &Response) -> bool { + cache_control_headers_are_private_or_no_store(response.headers()) +} /// Forces cookie-bearing responses to stay private to shared caches. /// @@ -32,21 +37,14 @@ pub fn enforce_set_cookie_cache_privacy(response: &mut Response) { if !response.headers().contains_key(header::SET_COOKIE) { return; } - // Surrogate cache headers must come off every cookie-bearing response, even - // one already carrying a stricter `no-store`/`private` directive — they are + // Edge-cache headers must come off every cookie-bearing response, even one + // already carrying a stricter `no-store`/`private` directive — they are // independent of Cache-Control and would otherwise let a shared cache store // and replay one visitor's Set-Cookie. - for name in SURROGATE_CACHE_HEADERS { - response.headers_mut().remove(*name); - } + remove_edge_cache_headers(response.headers_mut()); // Cache-Control directives are case-insensitive (RFC 9111 §5.2), so match // against a lowercased copy — `No-Store` / `Private` must count. - let already_uncacheable = response - .headers() - .get(header::CACHE_CONTROL) - .and_then(|v| v.to_str().ok()) - .map(str::to_ascii_lowercase) - .is_some_and(|v| v.contains("private") || v.contains("no-store")); + let already_uncacheable = cache_control_is_private_or_no_store(response); if !already_uncacheable { response.headers_mut().insert( header::CACHE_CONTROL, @@ -61,10 +59,10 @@ pub fn enforce_set_cookie_cache_privacy(response: &mut Response) { /// First downgrades cookie-bearing responses via /// [`enforce_set_cookie_cache_privacy`], then applies operator headers — but on /// an uncacheable (`private`/`no-store`) response the cache-controlling headers -/// (`Cache-Control` and the surrogate cache headers) are skipped so operators +/// (`Cache-Control` and runtime edge-cache headers) are skipped so operators /// cannot re-enable shared caching for per-user payloads. After the operator /// headers are applied the cookie-privacy downgrade runs once more, so a -/// configured `Set-Cookie` combined with public/surrogate cache headers cannot +/// configured `Set-Cookie` combined with public edge-cache headers cannot /// produce a shared-cacheable cookie-bearing response. /// /// Invalid header names/values are logged and skipped rather than panicking, so @@ -72,18 +70,15 @@ pub fn enforce_set_cookie_cache_privacy(response: &mut Response) { pub fn apply_response_headers_with_cache_privacy(settings: &Settings, response: &mut Response) { enforce_set_cookie_cache_privacy(response); - let response_is_uncacheable = response - .headers() - .get(header::CACHE_CONTROL) - .and_then(|v| v.to_str().ok()) - .map(str::to_ascii_lowercase) - .is_some_and(|v| v.contains("private") || v.contains("no-store")); + let response_is_uncacheable = cache_control_is_private_or_no_store(response); + if response_is_uncacheable { + remove_edge_cache_headers(response.headers_mut()); + } for (key, value) in &settings.response_headers { if response_is_uncacheable && (key.eq_ignore_ascii_case(header::CACHE_CONTROL.as_str()) - || key.eq_ignore_ascii_case("surrogate-control") - || key.eq_ignore_ascii_case("fastly-surrogate-control")) + || is_edge_cache_header_name(key)) { continue; } @@ -104,10 +99,14 @@ pub fn apply_response_headers_with_cache_privacy(settings: &Settings, response: response.headers_mut().insert(header_name, header_value); } + if cache_control_is_private_or_no_store(response) { + remove_edge_cache_headers(response.headers_mut()); + } + // Operator headers can themselves introduce Set-Cookie (alongside public - // or surrogate cache headers) onto a previously cookieless response, which - // the pre-apply pass could not see. Re-run the downgrade so the final - // response can never pair Set-Cookie with shared cacheability. + // edge-cache headers) onto a previously cookieless response, which the + // pre-apply pass could not see. Re-run the downgrade so the final response + // can never pair Set-Cookie with shared cacheability. enforce_set_cookie_cache_privacy(response); } @@ -149,6 +148,8 @@ mod tests { let mut response = response_builder() .header(header::SET_COOKIE, "id=abc") .header("surrogate-control", "max-age=600") + .header("cdn-cache-control", "max-age=600") + .header("cloudflare-cdn-cache-control", "max-age=600") .body(edgezero_core::body::Body::empty()) .expect("should build response"); @@ -163,8 +164,12 @@ mod tests { "operator public Cache-Control must not override cookie privacy downgrade" ); assert!( - !response.headers().contains_key("surrogate-control"), - "surrogate cache headers must be stripped on cookie responses" + !response.headers().contains_key("surrogate-control") + && !response.headers().contains_key("cdn-cache-control") + && !response + .headers() + .contains_key("cloudflare-cdn-cache-control"), + "edge cache headers must be stripped on cookie responses" ); } @@ -176,6 +181,8 @@ mod tests { ("set-cookie", "operator=abc"), ("cache-control", "public, max-age=600"), ("surrogate-control", "max-age=600"), + ("cdn-cache-control", "max-age=600"), + ("cloudflare-cdn-cache-control", "max-age=600"), ]); let mut response = response_builder() .body(edgezero_core::body::Body::empty()) @@ -192,8 +199,12 @@ mod tests { "operator Set-Cookie plus public Cache-Control must be re-downgraded to private" ); assert!( - !response.headers().contains_key("surrogate-control"), - "surrogate cache headers must be stripped when operator headers add Set-Cookie" + !response.headers().contains_key("surrogate-control") + && !response.headers().contains_key("cdn-cache-control") + && !response + .headers() + .contains_key("cloudflare-cdn-cache-control"), + "edge cache headers must be stripped when operator headers add Set-Cookie" ); assert!( response.headers().contains_key(header::SET_COOKIE), @@ -201,6 +212,61 @@ mod tests { ); } + #[test] + fn cookie_privacy_does_not_treat_pseudo_directives_as_uncacheable() { + let settings = settings_with_response_headers(&[]); + let mut response = response_builder() + .header(header::SET_COOKIE, "id=abc") + .header( + header::CACHE_CONTROL, + "public, max-age=600, no-storey, not-private", + ) + .header("surrogate-control", "max-age=600") + .body(edgezero_core::body::Body::empty()) + .expect("should build response"); + + apply_response_headers_with_cache_privacy(&settings, &mut response); + + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("private, max-age=0"), + "pseudo-directives must not prevent the cookie privacy downgrade" + ); + assert!( + !response.headers().contains_key("surrogate-control"), + "cookie privacy downgrade should still strip edge-cache headers" + ); + } + + #[test] + fn strips_edge_headers_from_uncacheable_cookieless_response() { + let settings = settings_with_response_headers(&[ + ("cdn-cache-control", "max-age=600"), + ("cloudflare-cdn-cache-control", "max-age=600"), + ]); + let mut response = response_builder() + .header(header::CACHE_CONTROL, "private, max-age=0") + .header("surrogate-control", "max-age=600") + .header("cdn-cache-control", "max-age=600") + .header("cloudflare-cdn-cache-control", "max-age=600") + .body(edgezero_core::body::Body::empty()) + .expect("should build response"); + + apply_response_headers_with_cache_privacy(&settings, &mut response); + + assert!( + !response.headers().contains_key("surrogate-control") + && !response.headers().contains_key("cdn-cache-control") + && !response + .headers() + .contains_key("cloudflare-cdn-cache-control"), + "uncacheable responses must not retain or receive edge-cache headers" + ); + } + #[test] fn applies_operator_headers_on_cookieless_response() { let settings = settings_with_response_headers(&[("x-operator", "value")]); diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 9cbb2a546..473482254 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -1,6 +1,7 @@ #[cfg(test)] use config::{Config, Environment, File, FileFormat}; use error_stack::{Report, ResultExt}; +use glob::Pattern; use regex::Regex; use serde::{de::DeserializeOwned, Deserialize, Deserializer, Serialize}; use serde_json::Value as JsonValue; @@ -8,10 +9,12 @@ use std::collections::{HashMap, HashSet}; use std::ops::{Deref, DerefMut}; use std::str::FromStr; use std::sync::OnceLock; +use std::time::Duration; use url::Url; use validator::{Validate, ValidationError}; use crate::auction_config_types::AuctionConfig; +use crate::cache_policy::{CachePolicy, CacheVisibility}; use crate::consent_config::ConsentConfig; use crate::creative_opportunities::CreativeOpportunitiesConfig; use crate::error::TrustedServerError; @@ -1889,6 +1892,322 @@ fn validate_tinybird_secret(value: &str, setting: &str) -> Result<(), Report, +} + +impl CacheSettings { + fn normalize(&mut self) { + for rule in &mut self.asset_rules { + rule.normalize(); + } + } + + /// Eagerly validate runtime-only cache settings artifacts. + /// + /// # Errors + /// + /// Returns a configuration error if any rule ID is duplicate, matcher shape + /// is invalid, or a configured regex/glob cannot compile. + pub fn prepare_runtime(&self) -> Result<(), Report> { + let mut seen_ids = HashSet::new(); + for rule in &self.asset_rules { + if rule.id.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: "cache.asset_rules id must not be empty".to_string(), + })); + } + if !seen_ids.insert(rule.id.clone()) { + return Err(Report::new(TrustedServerError::Configuration { + message: format!("cache.asset_rules contains duplicate id `{}`", rule.id), + })); + } + rule.prepare_runtime()?; + } + Ok(()) + } + + /// Resolve the first enabled asset cache rule that matches `path`. + /// + /// # Errors + /// + /// Returns a configuration error if a lazily prepared matcher unexpectedly + /// fails to compile. + pub fn asset_policy_for_path( + &self, + path: &str, + ) -> Result, Report> { + for rule in &self.asset_rules { + if rule.matches_path(path)? { + return Ok(Some(rule.cache_policy())); + } + } + Ok(None) + } +} + +/// A configurable cache rule for publisher-origin or rehosted static assets. +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CacheAssetRule { + /// Stable operator-facing identifier for logs/tests/config errors. + pub id: String, + /// Whether this rule participates in matching. + #[serde(default)] + pub enabled: bool, + /// Built-in framework/static preset matcher. + #[serde(default)] + pub preset: Option, + /// Raw path prefix matcher. + #[serde(default)] + pub path_prefix: Option, + /// Single glob matcher retained for concise configs. + #[serde(default)] + pub path_glob: Option, + /// Multiple glob matchers. + #[serde(default)] + pub path_globs: Vec, + /// Regex matcher applied to the request path. + #[serde(default)] + pub path_regex: Option, + /// File extensions matched against the request path, case-insensitively. + #[serde(default)] + pub extensions: Vec, + /// Require a hash-like token in the final path segment before the rule matches. + #[serde(default)] + pub requires_hash_in_filename: bool, + /// Browser-facing cache visibility. + #[serde(default)] + pub visibility: CachePolicyVisibility, + /// Browser cache TTL rendered as `max-age`. + #[serde(default)] + pub browser_ttl_seconds: Option, + /// Shared edge cache TTL rendered as runtime-specific edge control. + #[serde(default)] + pub edge_ttl_seconds: Option, + /// Optional stale-while-revalidate duration. + #[serde(default)] + pub stale_while_revalidate_seconds: Option, + /// Optional stale-if-error duration. + #[serde(default)] + pub stale_if_error_seconds: Option, + /// Whether browser caches may treat the response as immutable. + #[serde(default)] + pub immutable: bool, + #[serde(skip)] + compiled_regex: OnceLock>, + #[serde(skip)] + compiled_globs: OnceLock, String>>, +} + +impl CacheAssetRule { + fn normalize(&mut self) { + self.id = self.id.trim().to_string(); + self.path_prefix = self + .path_prefix + .take() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + self.path_glob = self + .path_glob + .take() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + self.path_globs = self + .path_globs + .iter() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .collect(); + self.path_regex = self + .path_regex + .take() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + self.extensions = self + .extensions + .iter() + .map(|value| value.trim().trim_start_matches('.').to_ascii_lowercase()) + .filter(|value| !value.is_empty()) + .collect(); + } + + fn prepare_runtime(&self) -> Result<(), Report> { + self.validate_matcher_shape()?; + self.compiled_regex().map(|_| ())?; + self.compiled_globs().map(|_| ())?; + Ok(()) + } + + fn validate_matcher_shape(&self) -> Result<(), Report> { + if self.path_glob.is_some() && !self.path_globs.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "cache.asset_rules `{}` must use path_glob or path_globs, not both", + self.id + ), + })); + } + + let matcher_count = usize::from(self.preset.is_some()) + + usize::from(self.path_prefix.is_some()) + + usize::from(self.path_glob.is_some() || !self.path_globs.is_empty()) + + usize::from(self.path_regex.is_some()) + + usize::from(!self.extensions.is_empty()); + + if matcher_count != 1 { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "cache.asset_rules `{}` must configure exactly one matcher", + self.id + ), + })); + } + Ok(()) + } + + fn compiled_regex(&self) -> Result, Report> { + let Some(pattern) = self.path_regex.as_deref() else { + return Ok(None); + }; + match self + .compiled_regex + .get_or_init(|| Regex::new(pattern).map_err(|err| err.to_string())) + { + Ok(regex) => Ok(Some(regex)), + Err(message) => Err(Report::new(TrustedServerError::Configuration { + message: format!( + "cache.asset_rules `{}` path_regex `{pattern}` failed to compile: {message}", + self.id + ), + })), + } + } + + fn compiled_globs(&self) -> Result, Report> { + if self.path_glob.is_none() && self.path_globs.is_empty() { + return Ok(None); + } + + match self.compiled_globs.get_or_init(|| { + if let Some(glob) = self.path_glob.as_deref() { + Pattern::new(glob) + .map(|pattern| vec![pattern]) + .map_err(|err| err.to_string()) + } else { + self.path_globs + .iter() + .map(|pattern| Pattern::new(pattern).map_err(|err| err.to_string())) + .collect() + } + }) { + Ok(patterns) => Ok(Some(patterns.as_slice())), + Err(message) => Err(Report::new(TrustedServerError::Configuration { + message: format!( + "cache.asset_rules `{}` glob matcher failed to compile: {message}", + self.id + ), + })), + } + } + + fn matches_path(&self, path: &str) -> Result> { + if !self.enabled { + return Ok(false); + } + if self.requires_hash_in_filename && !filename_contains_hash(path) { + return Ok(false); + } + + if let Some(preset) = self.preset { + return Ok(preset.matches_path(path)); + } + if let Some(prefix) = self.path_prefix.as_deref() { + return Ok(path.starts_with(prefix)); + } + if let Some(patterns) = self.compiled_globs()? { + return Ok(patterns.iter().any(|pattern| pattern.matches(path))); + } + if let Some(regex) = self.compiled_regex()? { + return Ok(regex.is_match(path)); + } + if !self.extensions.is_empty() { + return Ok(path_extension(path).is_some_and(|extension| { + self.extensions + .iter() + .any(|candidate| candidate == &extension) + })); + } + Ok(false) + } + + fn cache_policy(&self) -> CachePolicy { + CachePolicy { + visibility: self.visibility.into(), + browser_ttl: self.browser_ttl_seconds.map(Duration::from_secs), + edge_ttl: self.edge_ttl_seconds.map(Duration::from_secs), + stale_while_revalidate: self.stale_while_revalidate_seconds.map(Duration::from_secs), + stale_if_error: self.stale_if_error_seconds.map(Duration::from_secs), + immutable: self.immutable, + } + } +} + +/// Built-in cache-rule presets that operators can enable explicitly. +#[derive(Debug, Clone, Copy, Deserialize, Serialize, Eq, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum CacheAssetPreset { + /// Next.js build output under `/_next/static/`. + #[serde(rename = "nextjs-static")] + NextJsStatic, +} + +impl CacheAssetPreset { + fn matches_path(self, path: &str) -> bool { + match self { + Self::NextJsStatic => path.starts_with("/_next/static/"), + } + } +} + +/// Cache visibility parsed from operator configuration. +#[derive(Debug, Default, Clone, Copy, Deserialize, Serialize, Eq, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum CachePolicyVisibility { + /// Public browser/cache visibility. + #[default] + Public, + /// Private browser visibility. + Private, +} + +impl From for CacheVisibility { + fn from(value: CachePolicyVisibility) -> Self { + match value { + CachePolicyVisibility::Public => Self::Public, + CachePolicyVisibility::Private => Self::Private, + } + } +} + +fn path_extension(path: &str) -> Option { + let filename = path.rsplit('/').next()?; + let (_, extension) = filename.rsplit_once('.')?; + (!extension.is_empty()).then(|| extension.to_ascii_lowercase()) +} + +fn filename_contains_hash(path: &str) -> bool { + let filename = path.rsplit('/').next().unwrap_or(path); + filename + .split(['.', '-', '_', '~']) + .any(|segment| segment.len() >= 8 && segment.chars().all(|ch| ch.is_ascii_hexdigit())) +} + /// Debug-only features. All flags default to `false` (off in production). #[derive(Debug, Default, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] @@ -1951,6 +2270,9 @@ pub struct Settings { #[serde(default)] pub consent: ConsentConfig, #[serde(default)] + #[validate(nested)] + pub cache: CacheSettings, + #[serde(default)] pub proxy: Proxy, #[serde(default)] pub creative_opportunities: Option, @@ -2034,6 +2356,7 @@ impl Settings { validation_label: &str, ) -> Result> { settings.integrations.normalize(); + settings.cache.normalize(); settings.proxy.normalize(); settings.image_optimizer.normalize(); settings.consent.validate(); @@ -2061,6 +2384,7 @@ impl Settings { /// opportunity slot is invalid. pub fn prepare_runtime(&mut self) -> Result<(), Report> { self.image_optimizer.prepare_runtime()?; + self.cache.prepare_runtime()?; self.proxy.prepare_runtime()?; self.tinybird.prepare_runtime()?; self.validate_asset_image_optimizer_profile_sets()?; @@ -2169,6 +2493,18 @@ impl Settings { Ok(()) } + /// Resolve the first matching configured asset cache policy for the request path. + /// + /// # Errors + /// + /// Returns a configuration error if matcher preparation unexpectedly fails. + pub fn asset_cache_policy_for_path( + &self, + path: &str, + ) -> Result, Report> { + self.cache.asset_policy_for_path(path) + } + /// Resolve the longest matching asset route for the request path. #[must_use] pub fn asset_route_for_path(&self, path: &str) -> Option<&ProxyAssetRoute> { @@ -2735,6 +3071,143 @@ mod tests { ); } + #[test] + fn cache_asset_rule_nextjs_preset_is_operator_controlled() { + let toml_str = format!( + r#"{} + + [[cache.asset_rules]] + id = "nextjs-static" + enabled = true + preset = "nextjs-static" + visibility = "public" + browser_ttl_seconds = 31536000 + edge_ttl_seconds = 31536000 + immutable = true + "#, + crate_test_settings_str() + ); + let settings = Settings::from_toml(&toml_str).expect("should parse cache asset rule"); + + let policy = settings + .asset_cache_policy_for_path("/_next/static/chunks/app.js") + .expect("should evaluate cache rules") + .expect("should match enabled Next.js preset"); + assert_eq!( + policy, + CachePolicy::public_immutable(Duration::from_secs(31_536_000)), + "enabled preset should produce immutable static policy" + ); + + let disabled_toml = toml_str.replace("enabled = true", "enabled = false"); + let disabled_settings = + Settings::from_toml(&disabled_toml).expect("should parse disabled cache asset rule"); + assert!( + disabled_settings + .asset_cache_policy_for_path("/_next/static/chunks/app.js") + .expect("should evaluate disabled cache rules") + .is_none(), + "disabled preset must not mark framework paths immutable" + ); + } + + #[test] + fn cache_asset_rule_requires_hash_in_filename_when_configured() { + let toml_str = format!( + r#"{} + + [[cache.asset_rules]] + id = "publisher-assets" + enabled = true + path_globs = ["/assets/**/*.js"] + requires_hash_in_filename = true + visibility = "public" + browser_ttl_seconds = 31536000 + edge_ttl_seconds = 31536000 + immutable = true + "#, + crate_test_settings_str() + ); + let settings = Settings::from_toml(&toml_str).expect("should parse cache asset rule"); + + assert!( + settings + .asset_cache_policy_for_path("/assets/app.js") + .expect("should evaluate cache rules") + .is_none(), + "broad allowlist should not match non-fingerprinted files when hash is required" + ); + assert_eq!( + settings + .asset_cache_policy_for_path("/assets/app.0123abcd.js") + .expect("should evaluate cache rules"), + Some(CachePolicy::public_immutable(Duration::from_secs( + 31_536_000 + ))), + "fingerprinted asset should match the allowlist" + ); + } + + #[test] + fn cache_asset_rule_validation_rejects_invalid_config() { + let duplicate_ids = format!( + r#"{} + + [[cache.asset_rules]] + id = "duplicate" + enabled = true + path_prefix = "/assets/" + + [[cache.asset_rules]] + id = "duplicate" + enabled = true + path_prefix = "/static/" + "#, + crate_test_settings_str() + ); + let duplicate_err = + Settings::from_toml(&duplicate_ids).expect_err("should reject duplicate rule ids"); + assert!( + format!("{duplicate_err:?}").contains("duplicate id"), + "should explain duplicate rule id: {duplicate_err:?}" + ); + + let invalid_regex = format!( + r#"{} + + [[cache.asset_rules]] + id = "bad-regex" + enabled = true + path_regex = "[" + "#, + crate_test_settings_str() + ); + let regex_err = + Settings::from_toml(&invalid_regex).expect_err("should reject invalid regex"); + assert!( + format!("{regex_err:?}").contains("path_regex"), + "should explain invalid regex: {regex_err:?}" + ); + + let invalid_shape = format!( + r#"{} + + [[cache.asset_rules]] + id = "too-many-matchers" + enabled = true + path_prefix = "/assets/" + extensions = ["js"] + "#, + crate_test_settings_str() + ); + let shape_err = + Settings::from_toml(&invalid_shape).expect_err("should reject invalid matcher shape"); + assert!( + format!("{shape_err:?}").contains("exactly one matcher"), + "should explain invalid matcher shape: {shape_err:?}" + ); + } + #[test] fn validate_rejects_trailing_slash_in_origin_url() { let toml_str = crate_test_settings_str().replace( diff --git a/crates/trusted-server-core/src/tsjs.rs b/crates/trusted-server-core/src/tsjs.rs index 45aee02eb..e8d678df2 100644 --- a/crates/trusted-server-core/src/tsjs.rs +++ b/crates/trusted-server-core/src/tsjs.rs @@ -1,4 +1,4 @@ -use trusted_server_js::{all_module_ids, concatenated_hash, single_module_hash}; +use trusted_server_js::{concatenated_hash, single_module_hash}; /// `/static` URL for the tsjs bundle with cache-busting hash based on /// the concatenated content of the given module set. @@ -17,25 +17,28 @@ pub fn tsjs_script_tag(module_ids: &[&str]) -> String { ) } -/// `/static` URL for the unified bundle with a conservative cache-busting hash. +/// `/static` URL for the unified bundle when exact module IDs are unavailable. /// -/// Hashes all compiled module IDs so the cache invalidates whenever any module -/// changes. Over-invalidates slightly (includes deferred modules in the hash) -/// but never serves stale content. Use [`tsjs_script_src`] with exact module -/// IDs when `IntegrationRegistry` is available. +/// This intentionally omits `?v=` because the serving path can only mark a URL +/// immutable when the hash matches the exact enabled module set. Use +/// [`tsjs_script_src`] with exact module IDs when [`IntegrationRegistry`] is +/// available. +/// +/// [`IntegrationRegistry`]: crate::integrations::IntegrationRegistry #[must_use] pub fn tsjs_unified_script_src() -> String { - let ids = all_module_ids(); - tsjs_script_src(&ids) + "/static/tsjs=tsjs-unified.min.js".to_string() } -/// `", + tsjs_unified_script_src() + ) } /// `/static` URL for a single deferred module with its own cache-busting hash. @@ -165,18 +168,17 @@ mod tests { } #[test] - fn tsjs_unified_helpers_use_all_module_ids() { - let ids = all_module_ids(); + fn tsjs_unified_helpers_use_unversioned_fallback_without_registry() { + let src = tsjs_unified_script_src(); assert_eq!( - tsjs_unified_script_src(), - tsjs_script_src(&ids), - "should hash all module IDs for the unified script source" + src, "/static/tsjs=tsjs-unified.min.js", + "registry-free unified helper should not emit an unverifiable hash" ); assert_eq!( tsjs_unified_script_tag(), - tsjs_script_tag(&ids), - "should wrap the all-module unified script source" + format!(r#""#), + "should wrap the registry-free unified source" ); } @@ -239,14 +241,13 @@ mod tests { } #[test] - fn tsjs_unified_script_src_and_tag_include_cache_busting_hash() { + fn tsjs_unified_script_src_and_tag_omit_unverifiable_cache_busting_hash() { let src = tsjs_unified_script_src(); - assert!( - src.starts_with("/static/tsjs=tsjs-unified.min.js?v="), - "should include unified script URL prefix" + assert_eq!( + src, "/static/tsjs=tsjs-unified.min.js", + "should use the unified script URL without an unverifiable hash" ); - assert_sha256_hex_hash(hash_query_value(&src)); assert_eq!( tsjs_unified_script_tag(), format!(r#""#), diff --git a/crates/trusted-server-js/Cargo.toml b/crates/trusted-server-js/Cargo.toml index c1e832a58..b1c5ebf71 100644 --- a/crates/trusted-server-js/Cargo.toml +++ b/crates/trusted-server-js/Cargo.toml @@ -13,10 +13,11 @@ workspace = true doctest = false name = "trusted_server_js" path = "src/lib.rs" -test = false [build-dependencies] build-print = { workspace = true } +hex = { workspace = true } +sha2 = { workspace = true } which = { workspace = true } [dependencies] diff --git a/crates/trusted-server-js/build.rs b/crates/trusted-server-js/build.rs index 5055e9840..faa1e5791 100644 --- a/crates/trusted-server-js/build.rs +++ b/crates/trusted-server-js/build.rs @@ -12,6 +12,7 @@ use std::path::{Path, PathBuf}; use std::process::{Command, ExitStatus}; use build_print::{info, warn}; +use sha2::{Digest as _, Sha256}; fn main() { // Rebuild if TS sources change (belt-and-suspenders): enumerate every file under lib/ @@ -127,7 +128,7 @@ fn main() { // Copy each module file to OUT_DIR for (_, filename) in &modules { - copy_bundle(filename, true, &crate_dir, &dist_dir, &out_dir); + copy_bundle(filename, true, &dist_dir, &out_dir); } // Generate tsjs_modules.rs with include_str!() for each module @@ -141,9 +142,10 @@ fn main() { ) .expect("should write generated module header"); for (id, filename) in &modules { + let sha256 = bundle_sha256(&out_dir.join(filename)); writeln!( codegen, - " TsjsModuleMeta {{\n bundle: include_str!(concat!(env!(\"OUT_DIR\"), \"/{filename}\")),\n id: \"{id}\",\n }},\n" + " TsjsModuleMeta {{\n bundle: include_str!(concat!(env!(\"OUT_DIR\"), \"/{filename}\")),\n id: \"{id}\",\n sha256: \"{sha256}\",\n }},\n" ) .expect("should write generated module entry"); } @@ -151,6 +153,7 @@ fn main() { codegen.push_str("\npub(crate) struct TsjsModuleMeta {\n"); codegen.push_str(" pub bundle: &'static str,\n"); codegen.push_str(" pub id: &'static str,\n"); + codegen.push_str(" pub sha256: &'static str,\n"); codegen.push_str("}\n"); let generated_path = out_dir.join("tsjs_modules.rs"); @@ -162,30 +165,36 @@ fn main() { }); } -fn copy_bundle(filename: &str, required: bool, crate_dir: &Path, dist_dir: &Path, out_dir: &Path) { - let primary = dist_dir.join(filename); - let fallback = crate_dir.join("dist").join(filename); +fn bundle_sha256(path: &Path) -> String { + let content = fs::read(path).unwrap_or_else(|err| { + panic!( + "tsjs: failed to read copied bundle {} for hashing: {err}", + path.display() + ); + }); + hex::encode(Sha256::digest(&content)) +} + +fn copy_bundle(filename: &str, required: bool, dist_dir: &Path, out_dir: &Path) { + let source = dist_dir.join(filename); let target = out_dir.join(filename); - for source in [&primary, &fallback] { - if source.exists() { - if let Err(err) = fs::copy(source, &target) { - assert!( - !required, - "tsjs: failed to copy {} to {}: {err}", - source.display(), - target.display() - ); - } - return; + if source.exists() { + if let Err(err) = fs::copy(&source, &target) { + assert!( + !required, + "tsjs: failed to copy {} to {}: {err}", + source.display(), + target.display() + ); } + return; } assert!( !required, - "tsjs: bundle {filename} not found: {} (and fallback {}). Ensure Node is installed and `npm run build` succeeds, or commit dist/{filename}.", - primary.display(), - fallback.display() + "tsjs: bundle {filename} not found: {}. Ensure Node is installed and `npm run build` succeeds, or commit dist/{filename}.", + source.display() ); fs::write(&target, "").expect("should write optional empty bundle placeholder"); diff --git a/crates/trusted-server-js/src/bundle.rs b/crates/trusted-server-js/src/bundle.rs index 7ddc060ab..83815654a 100644 --- a/crates/trusted-server-js/src/bundle.rs +++ b/crates/trusted-server-js/src/bundle.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use std::sync::OnceLock; +use std::sync::{Mutex, MutexGuard, OnceLock}; use hex::encode; use sha2::{Digest as _, Sha256}; @@ -10,7 +10,7 @@ include!(concat!(env!("OUT_DIR"), "/tsjs_modules.rs")); #[must_use] #[inline] pub fn module_bundle(id: &str) -> Option<&'static str> { - module_map().get(id).copied() + module_meta_map().get(id).map(|module| module.bundle) } /// Return all available module IDs, in discovery order (core first). @@ -27,56 +27,159 @@ pub fn all_module_ids() -> Vec<&'static str> { #[must_use] #[inline] pub fn concatenate_modules(ids: &[&str]) -> String { - let map = module_map(); - let mut parts: Vec<&str> = Vec::new(); + let ordered_ids = concatenated_module_ids(ids); + let mut body = String::new(); + visit_concatenated_module_parts(&ordered_ids, |part| body.push_str(part)); + body +} + +/// SHA-256 hash of the concatenated modules, for cache-busting URLs. +/// +/// The hash is computed over the same byte sequence as [`concatenate_modules`] +/// without allocating that concatenated body. Results are cached by ordered +/// module ID list so HTML injection does not re-hash the full JS payload on +/// every page view. +#[must_use] +#[inline] +pub fn concatenated_hash(ids: &[&str]) -> String { + let key = concatenated_module_ids(ids); + if let Some(hash) = lock_concatenated_hash_cache().get(&key).cloned() { + return hash; + } + + let hash = hash_concatenated_modules(&key); + lock_concatenated_hash_cache().insert(key, hash.clone()); + hash +} + +/// SHA-256 hash of a single module's content (without prepending core). +/// +/// Used for cache-busting URLs of deferred modules served individually. +#[must_use] +#[inline] +pub fn single_module_hash(id: &str) -> Option<&'static str> { + module_meta_map().get(id).map(|module| module.sha256) +} + +fn concatenated_module_ids(ids: &[&str]) -> Vec<&'static str> { + let map = module_meta_map(); + let mut ordered = Vec::new(); - // Core always first if let Some(core) = map.get("core") { - parts.push(core); + ordered.push(core.id); } - // Then requested modules (excluding core, already included) for id in ids { if *id == "core" { continue; } - if let Some(bundle) = map.get(id) { - parts.push(bundle); + if let Some(module) = map.get(*id) { + ordered.push(module.id); } } - parts.join(";\n") + ordered } -/// SHA-256 hash of the concatenated modules, for cache-busting URLs. -#[must_use] -#[inline] -pub fn concatenated_hash(ids: &[&str]) -> String { - let body = concatenate_modules(ids); +fn hash_concatenated_modules(ids: &[&'static str]) -> String { let mut hasher = Sha256::new(); - hasher.update(body.as_bytes()); + visit_concatenated_module_parts(ids, |part| hasher.update(part.as_bytes())); encode(hasher.finalize()) } -/// SHA-256 hash of a single module's content (without prepending core). -/// -/// Used for cache-busting URLs of deferred modules served individually. -#[must_use] -#[inline] -pub fn single_module_hash(id: &str) -> Option { - module_bundle(id).map(|content| { - let mut hasher = Sha256::new(); - hasher.update(content.as_bytes()); - encode(hasher.finalize()) - }) +fn visit_concatenated_module_parts(ids: &[&'static str], mut visit: F) +where + F: FnMut(&'static str), +{ + let map = module_meta_map(); + let mut first = true; + + for id in ids { + let Some(module) = map.get(*id) else { + continue; + }; + if first { + first = false; + } else { + visit(";\n"); + } + visit(module.bundle); + } } -fn module_map() -> &'static HashMap<&'static str, &'static str> { - static MAP: OnceLock> = OnceLock::new(); +fn module_meta_map() -> &'static HashMap<&'static str, &'static TsjsModuleMeta> { + static MAP: OnceLock> = OnceLock::new(); MAP.get_or_init(|| { TSJS_MODULES .iter() - .map(|module| (module.id, module.bundle)) + .map(|module| (module.id, module)) .collect() }) } + +fn lock_concatenated_hash_cache() -> MutexGuard<'static, HashMap, String>> { + match concatenated_hash_cache().lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } +} + +fn concatenated_hash_cache() -> &'static Mutex, String>> { + static CACHE: OnceLock, String>>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sha256_hex(bytes: &[u8]) -> String { + encode(Sha256::digest(bytes)) + } + + #[test] + fn generated_single_module_hashes_match_bundle_contents() { + for id in all_module_ids() { + let bundle = module_bundle(id).expect("should have module bundle"); + let generated_hash = single_module_hash(id).expect("should have generated hash"); + + assert_eq!( + generated_hash, + sha256_hex(bundle.as_bytes()), + "generated hash for module {id} should match included bundle bytes" + ); + } + } + + #[test] + fn concatenated_hash_matches_concatenated_bundle_contents() { + let available_ids = all_module_ids(); + let non_core_ids = available_ids + .iter() + .copied() + .filter(|id| *id != "core") + .take(3) + .collect::>(); + + let mut cases: Vec> = vec![Vec::new()]; + if let Some(first) = non_core_ids.first().copied() { + cases.push(vec![first]); + } + if non_core_ids.len() >= 2 { + cases.push(non_core_ids[..2].to_vec()); + cases.push(non_core_ids[..2].iter().rev().copied().collect()); + } + if non_core_ids.len() >= 3 { + cases.push(non_core_ids[..3].to_vec()); + } + + for ids in cases { + let concatenated = concatenate_modules(&ids); + assert_eq!( + concatenated_hash(&ids), + sha256_hex(concatenated.as_bytes()), + "concatenated hash should match concatenated bundle bytes for {ids:?}" + ); + } + } +} diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index b975590c6..938766929 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -69,6 +69,7 @@ fail and the service will return its startup-error response. | `[ec]` | Edge Cookie (EC) ID generation | | `[tester_cookie]` | Optional tester-cookie endpoint | | `[proxy]` | Proxy SSRF allowlist and asset routes | +| `[cache]` | Static/rehosted asset cache policy rules | | `[image_optimizer]` | Reusable Image Optimizer profile sets | | `[request_signing]` | Ed25519 request signing | | `[auction]` | Auction orchestration | @@ -984,6 +985,78 @@ when_missing = "smart" See [Asset Routes](/guide/asset-routes) for request flow, S3 auth details, and Image Optimizer behavior. +## Cache Configuration + +Static and rehosted asset cache upgrades are operator-controlled. By default, +Trusted Server leaves arbitrary publisher-origin assets under origin cache +control. Add `[[cache.asset_rules]]` entries only for paths that are known to be +content-addressed or otherwise safe for the configured TTL. + +### `[[cache.asset_rules]]` + +Rules are evaluated in file order; the first enabled matching rule wins. +Disabled rules are ignored, which lets you keep framework presets documented in +config without enabling them for every publisher. + +| Field | Type | Required | Description | +| ---------------------------------- | ------------- | -------- | ----------------------------------------------------------------- | +| `id` | String | Yes | Unique operator-facing rule identifier | +| `enabled` | Boolean | No | Whether the rule participates in matching (default `false`) | +| `preset` | String | Matcher | Built-in preset such as `nextjs-static` | +| `path_prefix` | String | Matcher | Request path prefix | +| `path_glob` | String | Matcher | Single glob matched against the request path | +| `path_globs` | Array[String] | Matcher | Multiple globs matched against the request path | +| `path_regex` | String | Matcher | Regex matched against the request path | +| `extensions` | Array[String] | Matcher | Case-insensitive file extensions | +| `requires_hash_in_filename` | Boolean | No | Require an 8+ hex token in the final path segment before matching | +| `visibility` | String | No | `public` or `private` (default `public`) | +| `browser_ttl_seconds` | Integer | No | Browser `max-age` | +| `edge_ttl_seconds` | Integer | No | Edge/shared-cache TTL | +| `stale_while_revalidate_seconds` | Integer | No | Optional `stale-while-revalidate` | +| `stale_if_error_seconds` | Integer | No | Optional `stale-if-error` | +| `immutable` | Boolean | No | Add `immutable` when browser TTL is positive | + +Exactly one matcher must be configured per rule. `path_glob` and `path_globs` +are mutually exclusive. + +**Next.js preset example** (disabled until the publisher confirms +`/_next/static/` is content-addressed): + +```toml +[[cache.asset_rules]] +id = "nextjs-static" +enabled = false +preset = "nextjs-static" +visibility = "public" +browser_ttl_seconds = 31536000 +edge_ttl_seconds = 31536000 +immutable = true +``` + +**Publisher allowlist example**: + +```toml +[[cache.asset_rules]] +id = "publisher-fingerprinted-assets" +enabled = true +path_globs = [ + "/assets/**/*.js", + "/assets/**/*.css", + "/assets/**/*.png", + "/assets/**/*.webp", +] +requires_hash_in_filename = true +visibility = "public" +browser_ttl_seconds = 31536000 +edge_ttl_seconds = 31536000 +immutable = true +``` + +If `[cache]` is omitted or no enabled rule matches, Trusted Server preserves the +origin cache policy for publisher-origin assets. TS-owned validated hash URLs, +such as `/static/tsjs=...js?v=`, use their built-in cache policy and do +not require an asset rule. + ## Integration Configurations Settings for built-in integrations (Prebid, Next.js, Osano, Permutive, Testlight). For other diff --git a/docs/superpowers/plans/2026-07-06-cache-control-header-implementation-plan.md b/docs/superpowers/plans/2026-07-06-cache-control-header-implementation-plan.md new file mode 100644 index 000000000..f156aa768 --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-cache-control-header-implementation-plan.md @@ -0,0 +1,446 @@ +# Cache-Control Header Strategy Implementation Plan + +**Date:** 2026-07-06 +**Status:** Initial cache-header slice implemented in the current branch +**Spec:** `docs/superpowers/specs/2026-07-06-cache-control-header-design.md` + +## Scope + +Implement the **initial cache-header slice** from the current spec. The latest +spec resolves the initial-slice open questions and defers the larger dynamic +caching, template caching, streaming, and compression-offload work. + +Initial slice goals: + +1. Make TS-owned, hash-versioned TSJS responses cache correctly. +2. Make neutralized publisher Prebid compatibility responses safe to cache. +3. Add a structured, runtime-portable cache-policy model. +4. Add a configurable static/rehosted asset cache-rule engine so framework + assumptions are operator-controlled, not hard-coded. +5. Keep arbitrary publisher-origin assets origin-controlled unless an enabled + rule proves they are immutable-safe. + +Deferred follow-up features are listed separately below and should not be folded +into the initial cache-header PRs. + +## Decisions locked for the initial slice + +- SSAT-assembled HTML remains `Cache-Control: private, max-age=0` and strips + runtime edge-cache headers (`Surrogate-Control`, `Fastly-Surrogate-Control`, + `CDN-Cache-Control`, and `Cloudflare-CDN-Cache-Control`) whenever the ad stack + can inject per-user slot/bid state. +- TSJS keeps the current `/static/tsjs=...js?v=` canonical URL shape. + Matching hash/version requests receive immutable cache headers; missing or + mismatched hash/version requests keep short TTLs rather than redirecting. +- Runtime cache-key configuration must preserve the `v` query parameter for + `/static/tsjs=`. Fastly and Cloudflare include query strings in default cache + keys, but project-specific query normalization must not drop `v`. +- Framework-specific immutable paths, including Next.js `/_next/static/*`, must + be represented as configurable cache-rule presets. Do not add adapter- or + proxy-level hard-coded framework path checks. +- Operators decide which framework presets and publisher allowlists are enabled. + Arbitrary publisher CSS/JS/images remain origin-controlled unless an enabled + cache rule proves they are immutable-safe. +- TS-owned Prebid delivery is covered by deferred TSJS module URLs. Publisher + Prebid script URLs neutralized by TS are compatibility shims at stable URLs and + must use `no-store` or a very short TTL, not a year-long immutable policy. +- Rehosted assets are TS-owned copies once TS rewrites/hosts them. They should + use explicit normalized policies, with immutable only for TS-fingerprinted + rehosted URLs. +- Fastly and Cloudflare are the MVP runtime targets. Akamai mapping is deferred + until Akamai is on the roadmap. +- Dynamic HTML/RSC/API caching, dynamic `Vary`/cache-key normalization, + origin-template caching, transformed-template caching, true publisher-origin + streaming, parser-context bid splice, EdgeZero streaming parity, and SSAT HTML + compression offload are deferred follow-up features. +- All personalized/cookie-bearing response hardening in `response_privacy.rs` and + adapter middleware stays in place and runs after any new policy application. + +## Original baseline before this implementation + +| Area | Current file(s) | Baseline | +| ---------------------------- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| TSJS URL injection | `crates/trusted-server-core/src/tsjs.rs` | Injects `/static/tsjs=...js?v=`; current branch is moving hash work out of the hot path. | +| TSJS serving | `publisher.rs`, `http_util.rs` | Historically served through `serve_static_with_etag` with 5-minute browser/edge TTLs. | +| Cache policy primitives | `cache_policy.rs` | Current branch adds typed policy rendering; still needs final alignment with no-store and config rules. | +| Neutralized Prebid shim | `crates/trusted-server-core/src/integrations/prebid.rs` | `handle_script_handler` currently returns an empty JS shim with `public, max-age=31536000`; this must be changed. | +| Cache privacy | `publisher.rs`, `response_privacy.rs`, adapter middleware | Ad-stack HTML and cookie-bearing responses are downgraded to private/shared-uncacheable. | +| Rehosted asset cache policy | `proxy.rs` | Policy is effectively origin-controlled or `no-store, private`; no normalized immutable/SWR policy. | +| Dynamic HTML/RSC/API caching | none | Deferred. No initial-slice `Vary` rewriting or Next router-header special casing. | +| Origin-template cache | none | Deferred. No cache API/override/template key/surrogate-key implementation exists. | + +## Definition of done for the initial slice + +- TSJS hash-version-matching requests emit one-year immutable browser cache and + one-year edge cache headers. +- TSJS missing/mismatched hash requests keep short TTL behavior. +- TSJS injected hash generation no longer concatenates and hashes the full + bundle on every page view. +- Neutralized publisher Prebid shim responses use `no-store` or a very short TTL. +- Cache policy is represented as structured data and can emit Fastly + `Surrogate-Control`, generic `CDN-Cache-Control`, Cloudflare-specific + `Cloudflare-CDN-Cache-Control`, and `s-maxage` fallback headers. +- Cache policy can represent `no-store`/uncacheable responses as well as public + and private TTL policies. +- TS config expresses static/rehosted cache policy through structured rules with + match criteria, policy fields, and `enabled` flags. +- Built-in framework presets, including Next.js `/_next/static/*`, are + implemented through the shared rule engine and can be disabled/overridden. +- Arbitrary publisher-origin assets remain origin-controlled unless matched by an + enabled preset or publisher allowlist. +- TS-owned rehosted assets have explicit normalized policies instead of blindly + passing through third-party defaults. +- MVP adapters emit the correct edge-cache header from shared policy: Fastly + `Surrogate-Control`, Cloudflare `CDN-Cache-Control` / + `Cloudflare-CDN-Cache-Control`, or portable `s-maxage` fallback. +- Deferred features are documented as deferred and are not accidentally + implemented as hard-coded Next.js/dynamic-cache behavior. +- Tests and target-matched checks pass for touched crates/adapters. + +## Proposed PR sequence + +### PR 1 — Structured cache policy primitives + +Status: implemented in the current branch. + +#### Code changes + +- Keep/add a core module such as `crates/trusted-server-core/src/cache_policy.rs`. +- Define structured policy types: + - `CacheVisibility::{Public, Private}` + - `CachePolicy { visibility, browser_ttl, edge_ttl, stale_while_revalidate, +stale_if_error, immutable }` + - a `no-store` / uncacheable representation, either as a policy mode or a + dedicated helper, so neutralized shims and error responses do not need + ad-hoc strings; + - `EdgeCacheHeader::{SurrogateControl, CdnCacheControl, +CloudflareCdnCacheControl, SMaxageFallback, None}`. +- Add helpers that render policy into headers: + - browser `Cache-Control` + - Fastly `Surrogate-Control` + - generic `CDN-Cache-Control` + - Cloudflare-specific `Cloudflare-CDN-Cache-Control` + - portable `s-maxage` fallback. +- Keep helpers side-effect-limited: they should only mutate cache headers they + own and should not bypass `response_privacy` hardening. When applying private + or no-store policies, remove any existing edge-cache headers owned by the + helper so stale `Surrogate-Control`/CDN cache headers cannot survive. +- Add default policy constructors/constants for: + - immutable static; + - short TSJS fallback; + - neutralized Prebid shim (`no-store` or very short TTL); + - uncacheable private. + +#### Tests + +- Unit-test exact header rendering for immutable, short edge/browser split, + private, no-store, SWR/SIE, generic CDN, Cloudflare-specific CDN, and fallback + `s-maxage` policies. +- Test that `immutable` is omitted when browser TTL is absent or zero. +- Test that edge-header output is disabled for private/no-store responses, and + that applying private/no-store removes any pre-existing edge-cache header the + helper owns. + +### PR 2 — TSJS immutable hash-version serving + +Status: implemented in the current branch with runtime-specific edge-header +selection. + +#### Code changes + +- Extend `crates/trusted-server-js/build.rs` generated metadata with per-module + SHA-256 hashes. +- Update `trusted-server-js/src/bundle.rs`: + - `single_module_hash(id)` returns generated hash instead of hashing content; + - `concatenated_hash(ids)` hashes incrementally without concatenating a full + `String`, or caches the result per normalized module-id set; + - `concatenate_modules(ids)` can remain for serving the response body. +- Update `handle_tsjs_dynamic` in `publisher.rs`: + - parse `?v=` from the request URI; + - compare it with the canonical hash for the requested bundle; + - if it matches, apply immutable static policy plus `Vary: Accept-Encoding`, + ETag, and `X-Compress-Hint: on`; + - if missing/mismatched, keep short TTL policy plus ETag and + `X-Compress-Hint: on`. +- Keep the current canonical path shape (`/static/tsjs=...js?v=`). +- Document/verify that runtime cache-key configuration preserves the `v` query + parameter for `/static/tsjs=`. + +#### Tests + +- `tsjs_script_src` and deferred script tests still produce `?v=`. +- Matching `?v=` returns: + - `Cache-Control: public, max-age=31536000, immutable` + - runtime edge header via policy helper; + - `Vary: Accept-Encoding`; + - ETag. +- Missing/mismatched `?v=` returns short TTL and no `immutable`. +- Deferred disabled module still 404s. +- Hash helpers do not allocate the concatenated body just to hash it. + +### PR 3 — Neutralized publisher Prebid shim cache safety + +Fix the stable publisher Prebid compatibility route separately from TS-owned +Prebid delivery. + +#### Code changes + +- Update `PrebidIntegration::handle_script_handler` in + `crates/trusted-server-core/src/integrations/prebid.rs`. +- Replace the current year-long `public, max-age=31536000` response with either: + - `Cache-Control: no-store`, preferred for compatibility when integration + enablement/config can change; or + - a very short TTL if no-store is too conservative. +- Ensure no `Surrogate-Control`/CDN edge header is emitted for the neutralized + stable URL. +- Keep TS-owned Prebid bundle delivery on the deferred TSJS module path, where + matching `?v=` remains immutable. + +#### Tests + +- Neutralized Prebid script handler returns the empty compatibility script with + `no-store` or the chosen short TTL. +- Neutralized Prebid shim does not emit immutable or year-long cache headers. +- TSJS deferred Prebid still receives immutable headers when `?v=` matches. + +### PR 4 — Configurable static asset cache-rule engine + +Introduce operator-configurable static asset rules before applying immutable +upgrades to publisher-origin assets. + +#### Code changes + +- Add cache-rule settings rather than hard-coded path checks. Suggested shape: + - `CacheAssetRule { id, enabled, matcher, policy }` + - `CacheAssetMatcher::{PathPrefix, Glob, Regex, Extension, Preset}` + - `CacheAssetPreset::NextJsStatic` expands to `/_next/static/*` when enabled. +- Add cache settings under `Settings` (and `trusted-server.example.toml`) with + `#[serde(deny_unknown_fields)]` validation consistent with the rest of the + config model. +- Add a shared rule evaluator with deterministic precedence. Prefer an ordered + rule list where the first enabled match wins; reject duplicate rule IDs and + invalid matcher combinations during settings validation. +- Ship framework presets as data/config defaults or documented examples, not as + special cases in proxy/adapters. +- The Next.js preset may be present in example config, but operators must be able + to disable/override it. Do not silently apply it through a hard-coded branch. +- Support publisher-defined allowlist rules for other frameworks or + publisher-specific fingerprinted paths. +- Apply immutable policy only when an enabled rule/preset says the URL is + content-addressed, or for TS-owned validated hash URLs such as TSJS. + +#### Tests + +- With the Next.js preset enabled, `/_next/static/*` gets immutable policy. +- With the Next.js preset disabled, the same `/_next/static/*` remains + origin-controlled. +- Publisher-defined allowlist rule can mark a non-Next fingerprinted path + immutable. +- Non-matching publisher asset remains origin-controlled. +- Rule precedence is deterministic. +- Invalid regex/glob/config fails validation clearly. + +### PR 5 — MVP runtime edge-header mapping and docs + +Make the shared policy output explicit per runtime before wiring the rule engine +into more routes. This prevents new code from copying the current core +Fastly-specific `Surrogate-Control` behavior. + +#### Code changes + +- Stop requiring core helpers such as `handle_tsjs_dynamic` or + `serve_static_with_etag` to hard-code Fastly's `Surrogate-Control`. +- Choose one adapter boundary pattern and use it consistently: + - pass the runtime `EdgeCacheHeader`/policy emitter into core handlers; or + - return cache-policy metadata in response extensions and let adapters render + runtime-specific headers after route handling. +- Fastly adapter emits `Surrogate-Control` for edge TTLs. +- Cloudflare adapter emits `CDN-Cache-Control` or + `Cloudflare-CDN-Cache-Control`, depending on the chosen adapter convention. +- Portable/local fallback can use `s-maxage` inside `Cache-Control` when no + runtime-specific edge header is available. +- Akamai mapping remains absent/deferred; do not add untested Akamai behavior. +- Update `trusted-server.example.toml` and docs with disabled framework preset + examples and operator-owned allowlist examples. + +#### Tests + +- Fastly TSJS/static policy application emits `Surrogate-Control`. +- Cloudflare TSJS/static policy application emits the selected Cloudflare CDN + cache header and does not emit Fastly-only `Surrogate-Control`. +- Fallback policy emits `s-maxage` only for public/shared-cacheable responses. +- Private/no-store responses remove or avoid all edge-cache headers. + +### PR 6 — Apply static/rehosted policies to proxy responses + +Wire the rule engine into the routes that emit publisher-origin or rehosted +assets, using the runtime edge-header mapping from PR 5. + +#### Code changes + +- Extend `AssetProxyCachePolicy` in `proxy.rs` beyond + `OriginControlled`/`NoStorePrivate`, for example: + - `OriginControlled` + - `NoStorePrivate` + - `Normalized(CachePolicy)` from a matched enabled rule. +- Apply normalized policy after route finalization but before final response + privacy hardening. +- Preserve existing no-store/private handling for errors, signed failures, or + responses that set cookies/security headers. +- Ensure operator `response_headers` cannot weaken protected private/no-store + decisions. +- For TS-owned rehosted copies: + - use immutable only for fingerprinted TS-owned URLs; + - use conservative edge/browser TTLs for stable rehosted URLs; + - keep dynamic/personalized endpoints uncached. + +#### Tests + +- Rehosted/fingerprinted route matched by an enabled rule gets immutable policy. +- Stable rehosted route gets the configured conservative policy, not a borrowed + third-party `no-store` unless configured. +- Rehosted error responses keep `no-store, private`. +- `Set-Cookie` response remains private/no-store and loses surrogate headers. +- Operator response headers cannot re-enable shared caching for protected + responses. + +## Initial config sketch + +Exact names can change during implementation, but keep the shape structured and +operator-controlled. + +```toml +[[cache.asset_rules]] +id = "nextjs-static" +enabled = false # operators may enable for Next.js publishers +preset = "nextjs-static" +visibility = "public" +browser_ttl_seconds = 31536000 +edge_ttl_seconds = 31536000 +immutable = true + +[[cache.asset_rules]] +id = "publisher-fingerprinted-assets-example" +enabled = false +path_globs = [ + "/assets/**/*.js", + "/assets/**/*.css", + "/assets/**/*.png", + "/assets/**/*.jpg", + "/assets/**/*.webp", + "/assets/**/*.avif", +] +requires_hash_in_filename = true +visibility = "public" +browser_ttl_seconds = 31536000 +edge_ttl_seconds = 31536000 +immutable = true + +[cache.tsjs.versioned] +visibility = "public" +browser_ttl_seconds = 31536000 +edge_ttl_seconds = 31536000 +immutable = true + +[cache.tsjs.fallback] +visibility = "public" +browser_ttl_seconds = 300 +edge_ttl_seconds = 300 +stale_while_revalidate_seconds = 60 +stale_if_error_seconds = 86400 + +[cache.prebid_neutralized] +mode = "no-store" +``` + +Defaults should preserve current behavior unless a rule is explicitly enabled or +unless the response is TS-owned and hash-validated, such as TSJS. + +## Deferred follow-up backlog + +These remain valuable, but are intentionally outside the initial cache-header +slice. + +| Follow-up | Why deferred | Notes | +| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| True publisher-origin streaming | Requires platform/body boundary changes and adapter streaming semantics | Includes avoiding full `take_body_bytes()` materialization on Fastly and documenting/implementing non-Fastly streaming parity. | +| Parser-context bid splice | Requires HTML pipeline redesign | Replace raw `` | `Cache-Control: public, max-age=31536000, immutable` plus runtime edge header | The serving path must validate that `v` matches the bytes served. | +| TSJS missing/mismatched `?v=` | Short TTL or redirect to canonical hashed URL | Do not mark immutable. | +| TSJS deferred modules, including Prebid | Same as TSJS hash-matching policy | Example: `/static/tsjs=tsjs-prebid.min.js?v=`. | +| Publisher Prebid URL neutralized by TS | `no-store` or very short TTL | The empty compatibility shim is config-dependent and served at a stable publisher URL. Do not cache it for a year. | +| Enabled framework preset static, e.g. Next.js `/_next/static/*` | `Cache-Control: public, max-age=31536000, immutable` | Applied through configurable preset/allowlist rules. | +| TS-fingerprinted rehosted asset | `Cache-Control: public, max-age=31536000, immutable` | Safe only when TS owns the fingerprinted URL. | +| Stable TS-owned/rehosted URL | Conservative short browser TTL, optional longer edge TTL | Do not use `immutable`. | +| Arbitrary publisher-origin CSS/JS/images | Origin-controlled by default | TS may upgrade only via enabled framework preset or publisher allowlist. | +| SSAT-assembled ad-stack HTML | `Cache-Control: private, max-age=0`; strip runtime edge-cache headers | Must never enter shared cache because it can contain per-user slot/bid data. | +| Dynamic HTML/RSC/API | Origin-controlled in this slice | Future dynamic caching belongs to #859. | + +## TSJS-specific requirements + +Current TSJS URLs already include a content hash query string, for example: + +```text +/static/tsjs=tsjs-unified.min.js?v= +/static/tsjs=tsjs-prebid.min.js?v= +``` + +The current serving path still emits a short cache policy. Update it so that: + +- hash-matching requests emit one-year immutable browser caching; +- hash-matching requests emit the runtime edge header with equivalent long edge TTL; +- missing or mismatched hash requests do not receive immutable caching; +- cache-key configuration preserves the `v` query parameter; +- TSJS hashes used in injected URLs are generated at build time or cached so HTML injection does not re-concatenate and re-hash large bundles per pageview; +- `Vary: Accept-Encoding` remains on compressed/static responses; +- ETags may remain as a fallback for clients or intermediaries that revalidate anyway. + +Fastly and Cloudflare include query strings in default cache keys, but TS must still avoid any project-specific query normalization that drops `v` for `/static/tsjs=`. + +## SSAT HTML privacy requirement + +SSAT-assembled ad-stack HTML can contain per-user data such as slot state or bid data. It must remain: + +```http +Cache-Control: private, max-age=0 +``` + +and must strip runtime edge-cache headers, including: + +```http +Surrogate-Control +Fastly-Surrogate-Control +CDN-Cache-Control +Cloudflare-CDN-Cache-Control +``` + +This requirement applies to the browser-facing assembled response. Origin-template caching is separate follow-up work in #859. + +## Runtime header mapping for MVP + +Adapters should render the shared policy as follows: + +| Runtime | Edge/shared-cache header | +| ----------------- | ---------------------------------------------------- | +| Fastly | `Surrogate-Control` | +| Cloudflare | `CDN-Cache-Control` / `Cloudflare-CDN-Cache-Control` | +| Portable fallback | `s-maxage` in `Cache-Control` | + +Akamai mapping is deferred until Akamai is on the roadmap. + +## Acceptance criteria + +- [ ] Cache policy is represented as structured fields, not hard-coded header strings. +- [ ] Built-in framework presets, including a disableable/overrideable Next.js `/_next/static/*` rule, are implemented through the shared cache-policy rule engine. +- [ ] TSJS hash-matching requests for unified and deferred modules emit `public, max-age=31536000, immutable` plus the runtime edge header. +- [ ] TSJS missing/mismatched hash requests do not get immutable caching. +- [ ] TSJS hash generation is build-time or cached enough that HTML injection does not re-concatenate/re-hash the bundle per pageview. +- [ ] Runtime cache-key configuration preserves the `v` query parameter for `/static/tsjs=`. +- [ ] Neutralized publisher Prebid shim responses use `no-store` or a short TTL, not a year-long policy. +- [ ] Arbitrary publisher-origin assets remain origin-controlled unless covered by an enabled framework preset or publisher allowlist. +- [ ] TS-owned rehosted assets have explicit normalized cache policy; immutable is used only for TS-fingerprinted rehosted URLs. +- [ ] SSAT-assembled ad-stack HTML continues to emit `private, max-age=0` and strips all runtime edge-cache headers. +- [ ] Fastly and Cloudflare adapters emit the correct edge-cache header from the shared policy, with portable `s-maxage` fallback where needed. +- [ ] Dynamic HTML/RSC/API Vary/cache-key normalization is not hard-coded in this PR and is deferred to #859. +- [ ] SSAT streaming fixes and compression offload are not included in this PR and remain tracked by #857 and #858. diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 0951b781e..fa967ef9f 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -109,6 +109,27 @@ rewrite_script = true # Required for integrations.prebid.external_bundle_url and first-party proxy redirects. # allowed_domains = ["ads.example.com", "assets.example.com", "*.cdn.example.com"] +# Static/rehosted asset cache policies are operator-controlled. Keep framework +# presets disabled unless the matched publisher paths are known content-addressed. +# [[cache.asset_rules]] +# id = "nextjs-static" +# enabled = false +# preset = "nextjs-static" +# visibility = "public" +# browser_ttl_seconds = 31536000 +# edge_ttl_seconds = 31536000 +# immutable = true +# +# [[cache.asset_rules]] +# id = "publisher-fingerprinted-assets" +# enabled = false +# path_globs = ["/assets/**/*.js", "/assets/**/*.css", "/assets/**/*.png", "/assets/**/*.webp"] +# requires_hash_in_filename = true +# visibility = "public" +# browser_ttl_seconds = 31536000 +# edge_ttl_seconds = 31536000 +# immutable = true + [auction] enabled = false providers = [] From 518fce765b965b1a32aaf0a42757fad723b0fb49 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 8 Jul 2026 17:01:17 -0500 Subject: [PATCH 2/2] Implement parser-safe SSAT late binding --- .../benches/html_processor_bench.rs | 8 +- .../trusted-server-core/src/html_processor.rs | 303 +++- crates/trusted-server-core/src/lib.rs | 1 + crates/trusted-server-core/src/publisher.rs | 1354 ++++++++++++----- .../src/publisher_late_binding.rs | 317 ++++ ...reaming-parser-safe-implementation-plan.md | 740 +++++++++ ...-publisher-streaming-parser-safe-design.md | 823 ++++++++++ 7 files changed, 3083 insertions(+), 463 deletions(-) create mode 100644 crates/trusted-server-core/src/publisher_late_binding.rs create mode 100644 docs/superpowers/plans/2026-07-08-ssat-publisher-streaming-parser-safe-implementation-plan.md create mode 100644 docs/superpowers/specs/2026-07-08-ssat-publisher-streaming-parser-safe-design.md diff --git a/crates/trusted-server-core/benches/html_processor_bench.rs b/crates/trusted-server-core/benches/html_processor_bench.rs index 9e81d67d9..c5488c443 100644 --- a/crates/trusted-server-core/benches/html_processor_bench.rs +++ b/crates/trusted-server-core/benches/html_processor_bench.rs @@ -1,5 +1,8 @@ use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion}; -use trusted_server_core::html_processor::{create_html_processor, HtmlProcessorConfig}; +use trusted_server_core::html_processor::{ + create_html_processor, BidInjectionMode, HtmlPostProcessingMode, HtmlProcessorConfig, +}; +use trusted_server_core::integrations::IntegrationDocumentState; use trusted_server_core::integrations::IntegrationRegistry; use trusted_server_core::streaming_processor::StreamProcessor as _; @@ -12,6 +15,9 @@ fn make_config() -> HtmlProcessorConfig { ad_slots_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, + bid_injection_mode: BidInjectionMode::DirectState, + post_processing_mode: HtmlPostProcessingMode::Enabled, + document_state: IntegrationDocumentState::default(), } } diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index a3170084a..884214aa6 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -19,6 +19,7 @@ use crate::integrations::{ IntegrationScriptContext, ScriptRewriteAction, }; use crate::publisher::build_empty_bids_script; +use crate::publisher_late_binding::HtmlInjectionTracker; use crate::settings::Settings; use crate::streaming_processor::{HtmlRewriterAdapter, StreamProcessor}; use crate::tsjs; @@ -92,66 +93,103 @@ impl StreamProcessor for HtmlWithPostProcessing { return Ok(Vec::new()); } - // Final chunk: run post-processors on the full accumulated output. - let full_output = std::mem::take(&mut self.accumulated_output); - if full_output.is_empty() { - return Ok(full_output); - } + run_html_post_processors( + std::mem::take(&mut self.accumulated_output), + &self.post_processors, + &self.origin_host, + &self.request_host, + &self.request_scheme, + &self.document_state, + self.max_buffered_body_bytes, + ) + } - let Ok(output_str) = std::str::from_utf8(&full_output) else { - return Ok(full_output); - }; + /// No-op. `HtmlWithPostProcessing` wraps a single-use + /// [`HtmlRewriterAdapter`] that cannot be reset. Clearing auxiliary + /// state without resetting the rewriter would leave the processor + /// in an inconsistent state, so this method intentionally does nothing. + fn reset(&mut self) {} +} - let ctx = IntegrationHtmlContext { - request_host: &self.request_host, - request_scheme: &self.request_scheme, - origin_host: &self.origin_host, - document_state: &self.document_state, - }; +/// Run registered full-document post-processors on already rewritten HTML. +pub(crate) fn run_html_post_processors( + full_output: Vec, + post_processors: &[Arc], + origin_host: &str, + request_host: &str, + request_scheme: &str, + document_state: &IntegrationDocumentState, + max_buffered_body_bytes: usize, +) -> Result, io::Error> { + if full_output.is_empty() || post_processors.is_empty() { + return Ok(full_output); + } - // Preflight to avoid allocating a `String` unless at least one post-processor wants to run. - if !self - .post_processors - .iter() - .any(|p| p.should_process(output_str, &ctx)) - { - return Ok(full_output); - } + let Ok(output_str) = std::str::from_utf8(&full_output) else { + return Ok(full_output); + }; - let mut html = String::from_utf8(full_output).map_err(|e| { - io::Error::other(format!( - "HTML post-processing expected valid UTF-8 output: {e}" - )) - })?; + let ctx = IntegrationHtmlContext { + request_host, + request_scheme, + origin_host, + document_state, + }; - let mut changed = false; - for processor in &self.post_processors { - if processor.should_process(&html, &ctx) { - changed |= processor.post_process(&mut html, &ctx); - } - } + if !post_processors + .iter() + .any(|processor| processor.should_process(output_str, &ctx)) + { + return Ok(full_output); + } - if changed { - log::debug!("HTML post-processing complete: output_len={}", html.len()); - } + let mut html = String::from_utf8(full_output).map_err(|e| { + io::Error::other(format!( + "HTML post-processing expected valid UTF-8 output: {e}" + )) + })?; - // Post-processors may append content (e.g. injected scripts); enforce the - // same cap on the final document so growth during post-processing cannot - // push the buffer past the limit either. - if html.len() > self.max_buffered_body_bytes { - return Err(io::Error::other( - "publisher body exceeded maximum buffered size", - )); + let mut changed = false; + for processor in post_processors { + if processor.should_process(&html, &ctx) { + changed |= processor.post_process(&mut html, &ctx); } + } - Ok(html.into_bytes()) + if changed { + log::debug!("HTML post-processing complete: output_len={}", html.len()); } - /// No-op. `HtmlWithPostProcessing` wraps a single-use - /// [`HtmlRewriterAdapter`] that cannot be reset. Clearing auxiliary - /// state without resetting the rewriter would leave the processor - /// in an inconsistent state, so this method intentionally does nothing. - fn reset(&mut self) {} + if html.len() > max_buffered_body_bytes { + return Err(io::Error::other( + "publisher body exceeded maximum buffered size", + )); + } + + Ok(html.into_bytes()) +} + +/// How SSAT bids are inserted into parser-confirmed body end tags. +#[derive(Clone)] +pub enum BidInjectionMode { + /// Read the current bids script from shared state at the body end tag. + DirectState, + /// Insert an opaque placeholder for a later parser-safe replacement pass. + Placeholder { + /// Placeholder HTML inserted before the body end tag. + html: String, + /// Shared tracker used for EOF fallback decisions. + tracker: Arc, + }, +} + +/// Whether full-document HTML post-processors run inside this processor. +#[derive(Clone, Copy)] +pub enum HtmlPostProcessingMode { + /// Run registered full-document post-processors at EOF. + Enabled, + /// Skip post-processors so callers can run them after bid late binding. + Disabled, } /// Configuration for HTML processing @@ -172,6 +210,13 @@ pub struct HtmlProcessorConfig { /// processor aborts. Mirrors `publisher.max_buffered_body_bytes` so the /// full-document buffering done for post-processors is bounded. pub max_buffered_body_bytes: usize, + /// Bid insertion strategy for parser-confirmed body end tags. + pub bid_injection_mode: BidInjectionMode, + /// Controls whether full-document post-processors run inside this processor. + pub post_processing_mode: HtmlPostProcessingMode, + /// Per-document integration state shared by script rewriters and optional + /// post-processors. + pub document_state: IntegrationDocumentState, } impl HtmlProcessorConfig { @@ -192,6 +237,9 @@ impl HtmlProcessorConfig { ad_slots_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: settings.publisher.max_buffered_body_bytes, + bid_injection_mode: BidInjectionMode::DirectState, + post_processing_mode: HtmlPostProcessingMode::Enabled, + document_state: IntegrationDocumentState::default(), } } @@ -212,6 +260,65 @@ impl HtmlProcessorConfig { self.ad_bids_state = ad_bids_state; self } + + /// Insert `placeholder_html` at the parser-confirmed body end tag instead + /// of reading bids directly from shared state. + #[must_use] + pub fn with_bid_placeholder( + mut self, + placeholder_html: String, + tracker: Arc, + ) -> Self { + self.bid_injection_mode = BidInjectionMode::Placeholder { + html: placeholder_html, + tracker, + }; + self + } + + /// Disable full-document post-processors for a caller-managed buffered pass. + #[must_use] + pub fn without_post_processing(mut self) -> Self { + self.post_processing_mode = HtmlPostProcessingMode::Disabled; + self + } + + /// Use caller-provided document state for multi-phase processing. + #[must_use] + pub fn with_document_state(mut self, document_state: IntegrationDocumentState) -> Self { + self.document_state = document_state; + self + } +} + +/// Build the executable snippet normally inserted at the start of ``. +#[must_use] +pub(crate) fn build_head_bootstrap_snippet( + integrations: &IntegrationRegistry, + origin_host: &str, + request_host: &str, + request_scheme: &str, + document_state: &IntegrationDocumentState, + ad_slots_script: Option<&str>, +) -> String { + let mut snippet = String::new(); + if let Some(slots_script) = ad_slots_script { + snippet.push_str(slots_script); + } + let ctx = IntegrationHtmlContext { + request_host, + request_scheme, + origin_host, + document_state, + }; + for insert in integrations.head_inserts(&ctx) { + snippet.push_str(&insert); + } + let immediate_ids = integrations.js_module_ids_immediate(); + snippet.push_str(&tsjs::tsjs_script_tag(&immediate_ids)); + let deferred_ids = integrations.js_module_ids_deferred(); + snippet.push_str(&tsjs::tsjs_deferred_script_tags(&deferred_ids)); + snippet } /// Create an HTML processor with URL replacement and integration hooks. @@ -222,8 +329,11 @@ impl HtmlProcessorConfig { /// normal operation since no code holds the lock across a panic boundary. #[must_use] pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcessor { - let post_processors = config.integrations.html_post_processors(); - let document_state = IntegrationDocumentState::default(); + let post_processors = match config.post_processing_mode { + HtmlPostProcessingMode::Enabled => config.integrations.html_post_processors(), + HtmlPostProcessingMode::Disabled => Vec::new(), + }; + let document_state = config.document_state.clone(); // Simplified URL patterns structure - stores only core data and generates variants on-demand struct UrlPatterns { @@ -294,6 +404,8 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let script_rewriters = integration_registry.script_rewriters(); let ad_slots_script = config.ad_slots_script.clone(); let ad_bids_state = config.ad_bids_state.clone(); + let bid_injection_mode = config.bid_injection_mode.clone(); + let head_bid_injection_mode = bid_injection_mode.clone(); let mut element_content_handlers = vec![ // Inject unified tsjs bundle once at the start of @@ -304,33 +416,29 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let document_state = document_state.clone(); let ad_slots_script = ad_slots_script.clone(); move |el| { - if !injected_tsjs.get() { - let mut snippet = String::new(); - // Inject ad slots script first so it appears before tsjs bundle. - if let Some(ref slots_script) = ad_slots_script { - snippet.push_str(slots_script); - } - let ctx = IntegrationHtmlContext { - request_host: &patterns.request_host, - request_scheme: &patterns.request_scheme, - origin_host: &patterns.origin_host, - document_state: &document_state, - }; - // First inject integration-specific config (e.g., window.__tsjs_prebid) - // so it's available when the bundle's auto-init code reads it. - for insert in integrations.head_inserts(&ctx) { - snippet.push_str(&insert); + if injected_tsjs.get() { + return Ok(()); + } + if let BidInjectionMode::Placeholder { tracker, .. } = &head_bid_injection_mode { + if tracker.head_injected() || tracker.bid_placeholder_inserted() { + injected_tsjs.set(true); + return Ok(()); } - // Main bundle: core + non-deferred integrations (synchronous). - let immediate_ids = integrations.js_module_ids_immediate(); - snippet.push_str(&tsjs::tsjs_script_tag(&immediate_ids)); - // Deferred bundles: large modules like prebid loaded after - // HTML parsing completes. Empty when none are enabled. - let deferred_ids = integrations.js_module_ids_deferred(); - snippet.push_str(&tsjs::tsjs_deferred_script_tags(&deferred_ids)); - el.prepend(&snippet, ContentType::Html); - injected_tsjs.set(true); } + + let snippet = build_head_bootstrap_snippet( + &integrations, + &patterns.origin_host, + &patterns.request_host, + &patterns.request_scheme, + &document_state, + ad_slots_script.as_deref(), + ); + if let BidInjectionMode::Placeholder { tracker, .. } = &head_bid_injection_mode { + tracker.mark_head_injected(); + } + el.prepend(&snippet, ContentType::Html); + injected_tsjs.set(true); Ok(()) } }), @@ -345,24 +453,34 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let state = ad_bids_state.clone(); let injected_bids = injected_bids.clone(); let has_slots = ad_slots_script.is_some(); + let bid_injection_mode = bid_injection_mode.clone(); move |el| { if !has_slots { return Ok(()); } let state = state.clone(); let injected_bids = injected_bids.clone(); + let bid_injection_mode = bid_injection_mode.clone(); if let Some(handlers) = el.end_tag_handlers() { let handler: EndTagHandler<'static> = Box::new(move |end_tag: &mut EndTag<'_>| { if injected_bids.swap(true, Ordering::SeqCst) { return Ok(()); } - let script_guard = state.lock().expect("should lock bid state"); - let bids_script = match &*script_guard { - Some(s) => s.clone(), - None => build_empty_bids_script(), - }; - end_tag.before(&bids_script, ContentType::Html); + match &bid_injection_mode { + BidInjectionMode::DirectState => { + let script_guard = state.lock().expect("should lock bid state"); + let bids_script = match &*script_guard { + Some(s) => s.clone(), + None => build_empty_bids_script(), + }; + end_tag.before(&bids_script, ContentType::Html); + } + BidInjectionMode::Placeholder { html, tracker } => { + tracker.mark_bid_placeholder_inserted(); + end_tag.before(html, ContentType::Html); + } + } Ok(()) }); handlers.push(handler); @@ -664,6 +782,9 @@ mod tests { ad_slots_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, + bid_injection_mode: BidInjectionMode::DirectState, + post_processing_mode: HtmlPostProcessingMode::Enabled, + document_state: IntegrationDocumentState::default(), } } @@ -1436,6 +1557,9 @@ mod tests { ), ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, + bid_injection_mode: BidInjectionMode::DirectState, + post_processing_mode: HtmlPostProcessingMode::Enabled, + document_state: IntegrationDocumentState::default(), }; let mut processor = create_html_processor(config); let output = processor @@ -1509,6 +1633,9 @@ mod tests { ), ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, + bid_injection_mode: BidInjectionMode::DirectState, + post_processing_mode: HtmlPostProcessingMode::Enabled, + document_state: IntegrationDocumentState::default(), }; let mut processor = create_html_processor(config); let output = processor @@ -1544,6 +1671,9 @@ mod tests { ), ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, + bid_injection_mode: BidInjectionMode::DirectState, + post_processing_mode: HtmlPostProcessingMode::Enabled, + document_state: IntegrationDocumentState::default(), }; let mut processor = create_html_processor(config); // Malformed HTML with two elements (common in CMS template pages) @@ -1578,6 +1708,9 @@ mod tests { ad_slots_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, + bid_injection_mode: BidInjectionMode::DirectState, + post_processing_mode: HtmlPostProcessingMode::Enabled, + document_state: IntegrationDocumentState::default(), }; let mut processor = create_html_processor(config); let output = processor @@ -1630,6 +1763,9 @@ mod tests { ), ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, + bid_injection_mode: BidInjectionMode::DirectState, + post_processing_mode: HtmlPostProcessingMode::Enabled, + document_state: IntegrationDocumentState::default(), }; let mut processor = create_html_processor(config); let output = processor @@ -1656,6 +1792,9 @@ mod tests { ad_slots_script: None, ad_bids_state: state, max_buffered_body_bytes: 16 * 1024 * 1024, + bid_injection_mode: BidInjectionMode::DirectState, + post_processing_mode: HtmlPostProcessingMode::Enabled, + document_state: IntegrationDocumentState::default(), }; let mut processor = create_html_processor(config); let output = processor diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 48e92faed..a47a63bd7 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -59,6 +59,7 @@ pub mod platform; pub mod price_bucket; pub mod proxy; pub mod publisher; +pub mod publisher_late_binding; pub mod redacted; pub mod request_signing; pub mod response_privacy; diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index acf92959c..1262676da 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -18,7 +18,7 @@ //! into any [`Write`] (a `Vec` for buffered routes, a streaming writer for //! the streaming route). It is not a content-rewriting concern. -use std::io::Write; +use std::io::{Read, Write}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -54,6 +54,10 @@ use crate::http_util::{is_navigation_request, serve_static_with_etag, RequestInf use crate::integrations::IntegrationRegistry; use crate::platform::{GeoInfo, PlatformBackendSpec, PlatformHttpRequest, RuntimeServices}; use crate::price_bucket::{price_bucket, PriceGranularity}; +use crate::publisher_late_binding::{ + BidPlaceholder, HtmlInjectionTracker, PlaceholderLateBinder, PlaceholderScan, + SSAT_HELD_TAIL_CAP_BYTES, +}; use crate::rsc_flight::RscFlightUrlRewriter; use crate::settings::Settings; use crate::streaming_processor::{Compression, PipelineConfig, StreamProcessor, StreamingPipeline}; @@ -345,16 +349,56 @@ fn create_html_stream_processor( ad_slots_script: Option, ad_bids_state: Arc>>, ) -> Result> { - use crate::html_processor::{create_html_processor, HtmlProcessorConfig}; - - let config = HtmlProcessorConfig::from_settings( - settings, - integration_registry, + create_html_stream_processor_with_options(HtmlStreamProcessorOptions { origin_host, request_host, request_scheme, + settings, + integration_registry, + ad_slots_script, + ad_bids_state, + late_binding: None, + }) +} + +struct HtmlLateBindingOptions<'a> { + placeholder: &'a BidPlaceholder, + tracker: Arc, +} + +struct HtmlStreamProcessorOptions<'a> { + origin_host: &'a str, + request_host: &'a str, + request_scheme: &'a str, + settings: &'a Settings, + integration_registry: &'a IntegrationRegistry, + ad_slots_script: Option, + ad_bids_state: Arc>>, + late_binding: Option>, +} + +fn create_html_stream_processor_with_options( + options: HtmlStreamProcessorOptions<'_>, +) -> Result> { + use crate::html_processor::{create_html_processor, HtmlProcessorConfig}; + + let mut config = HtmlProcessorConfig::from_settings( + options.settings, + options.integration_registry, + options.origin_host, + options.request_host, + options.request_scheme, ) - .with_ad_state(ad_slots_script, ad_bids_state); + .with_ad_state(options.ad_slots_script, options.ad_bids_state); + + if let Some(late_binding) = options.late_binding { + config = config + .with_bid_placeholder( + late_binding.placeholder.as_html().to_owned(), + late_binding.tracker, + ) + .without_post_processing(); + } Ok(create_html_processor(config)) } @@ -677,27 +721,24 @@ pub fn stream_publisher_body( process_response_streaming(body, output, &borrowed) } -/// Stream publisher body with a `` syntax. /// -/// 1. [`collect_dispatched_auction`](AuctionOrchestrator::collect_dispatched_auction) -/// is awaited with the remaining deadline. -/// 2. Winning bids are written to `ad_bids_state`. -/// 3. The held tail is fed through the pipeline so `lol_html` fires its -/// `` handler with bids now in state. -/// -/// For non-HTML content types the auction is collected before any body bytes -/// are written (no `` to inject). If `params.dispatched_auction` is -/// `None` the function falls back to the synchronous -/// [`stream_publisher_body`] path. +/// For non-HTML content types, any dispatched auction is collected before body +/// bytes are written because there is no parser-confirmed body close for bid +/// insertion. If no SSAT slots matched this HTML response, the function falls +/// back to the synchronous pipeline unchanged. /// /// # Errors /// -/// Returns an error if processing fails mid-stream. Headers are already -/// committed at that point; the caller logs and drops the `StreamingBody`. +/// Returns an error if processing fails mid-stream. Headers may already be +/// committed at that point; streaming callers should log the error and drop the +/// client body. pub async fn stream_publisher_body_async( body: EdgeBody, output: &mut W, @@ -707,76 +748,100 @@ pub async fn stream_publisher_body_async( orchestrator: &AuctionOrchestrator, services: &RuntimeServices, ) -> Result<(), Report> { - let Some(dispatched) = params.dispatched_auction.take() else { - // No auction — use the existing sync pipeline unchanged. - return stream_publisher_body(body, output, params, settings, integration_registry); - }; + let dispatched = params.dispatched_auction.take(); let telemetry = AuctionTelemetryCarry { observation: params.auction_observation.take(), auction_request: params.auction_request.take(), }; let is_html = is_html_content_type(¶ms.content_type); - - 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), - ) - .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, - }, + let needs_ssat_late_binding = is_html && params.ad_slots_script.is_some(); + + if !needs_ssat_late_binding { + if let Some(dispatched) = dispatched { + // Non-HTML or no-slot responses have no SSAT body-close injection + // point. Collect before writing bytes so telemetry completes and the + // existing direct-state pipeline can preserve legacy behavior. + let placeholder = mediator_placeholder_request(); + let result = orchestrator + .collect_dispatched_auction( + dispatched, + services, + &make_collect_context(settings, services, &placeholder), ) - }) - .await; - } + .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, - ); + 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); } - // HTML: build the processor once and drive it chunk by chunk. - // One-behind buffer: stream chunk N-1 immediately; hold chunk N until origin - // EOF, then await auction and process chunk N (which contains ). - let mut processor = match 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), - params.ad_bids_state.clone(), - ) { - Ok(processor) => processor, - Err(err) => { - emit_abandoned_auction( + if integration_registry.has_html_post_processors() { + return buffer_html_late_binding_with_postprocessors( + body, + output, + BufferedLateBindingContext { + params, + settings, + integration_registry, + orchestrator, services, - telemetry.observation, dispatched, - "processor_init_error", - ) - .await; - return Err(err); - } - }; + telemetry, + }, + ) + .await; + } + + let placeholder = BidPlaceholder::new(); + let tracker = Arc::new(HtmlInjectionTracker::default()); + let mut processor = + match create_html_stream_processor_with_options(HtmlStreamProcessorOptions { + origin_host: ¶ms.origin_host, + request_host: ¶ms.request_host, + request_scheme: ¶ms.request_scheme, + settings, + integration_registry, + ad_slots_script: params.ad_slots_script.as_deref().map(str::to_string), + ad_bids_state: params.ad_bids_state.clone(), + late_binding: Some(HtmlLateBindingOptions { + placeholder: &placeholder, + tracker: Arc::clone(&tracker), + }), + }) { + Ok(processor) => processor, + Err(err) => { + if let Some(dispatched) = dispatched { + emit_abandoned_auction( + services, + telemetry.observation, + dispatched, + "processor_init_error", + ) + .await; + } + return Err(err); + } + }; let compression = Compression::from_content_encoding(¶ms.content_encoding); stream_html_with_auction_hold( @@ -793,10 +858,289 @@ pub async fn stream_publisher_body_async( services, settings, }, + LateBindingStreamConfig { + placeholder: &placeholder, + tracker: Arc::clone(&tracker), + fallback: LateBindingFallbackContext { + origin_host: ¶ms.origin_host, + request_host: ¶ms.request_host, + request_scheme: ¶ms.request_scheme, + integration_registry, + ad_slots_script: params.ad_slots_script.as_deref(), + }, + }, ) .await } +struct BufferedLateBindingContext<'a> { + params: &'a OwnedProcessResponseParams, + settings: &'a Settings, + integration_registry: &'a IntegrationRegistry, + orchestrator: &'a AuctionOrchestrator, + services: &'a RuntimeServices, + dispatched: Option, + telemetry: AuctionTelemetryCarry, +} + +async fn buffer_html_late_binding_with_postprocessors( + body: EdgeBody, + output: &mut W, + mut ctx: BufferedLateBindingContext<'_>, +) -> Result<(), Report> { + use crate::html_processor::{ + create_html_processor, run_html_post_processors, HtmlProcessorConfig, + }; + use crate::integrations::IntegrationDocumentState; + + let compression = Compression::from_content_encoding(&ctx.params.content_encoding); + let decoded = match decode_body_to_vec( + body, + compression, + ctx.settings.publisher.max_buffered_body_bytes, + ) { + Ok(decoded) => decoded, + Err(err) => { + if let Some(dispatched) = ctx.dispatched.take() { + emit_abandoned_auction( + ctx.services, + ctx.telemetry.observation.take(), + dispatched, + "buffered_decode_error", + ) + .await; + } + return Err(err); + } + }; + + let placeholder = BidPlaceholder::new(); + let tracker = Arc::new(HtmlInjectionTracker::default()); + let document_state = IntegrationDocumentState::default(); + let config = HtmlProcessorConfig::from_settings( + ctx.settings, + ctx.integration_registry, + &ctx.params.origin_host, + &ctx.params.request_host, + &ctx.params.request_scheme, + ) + .with_ad_state( + ctx.params.ad_slots_script.clone(), + Arc::clone(&ctx.params.ad_bids_state), + ) + .with_bid_placeholder(placeholder.as_html().to_owned(), Arc::clone(&tracker)) + .without_post_processing() + .with_document_state(document_state.clone()); + let mut processor = create_html_processor(config); + let held_tail_cap = + SSAT_HELD_TAIL_CAP_BYTES.min(ctx.settings.publisher.max_buffered_body_bytes); + let mut binder = PlaceholderLateBinder::new(&placeholder, held_tail_cap); + let mut late_bound = Vec::new(); + let fallback = LateBindingFallbackContext { + origin_host: &ctx.params.origin_host, + request_host: &ctx.params.request_host, + request_scheme: &ctx.params.request_scheme, + integration_registry: ctx.integration_registry, + ad_slots_script: ctx.params.ad_slots_script.as_deref(), + }; + let mut state = LateBindingState { + dispatched: ctx.dispatched, + telemetry: ctx.telemetry, + price_granularity: ctx.params.price_granularity, + ad_bids_state: &ctx.params.ad_bids_state, + orchestrator: ctx.orchestrator, + services: ctx.services, + settings: ctx.settings, + tracker: Arc::clone(&tracker), + fallback, + decoded_input_bytes: decoded.len(), + processed_output_bytes: 0, + }; + + for chunk in decoded.chunks(STREAM_CHUNK_SIZE) { + let processed = + processor + .process_chunk(chunk, false) + .change_context(TrustedServerError::Proxy { + message: "Failed to process buffered publisher HTML".to_string(), + }); + let processed = match processed { + Ok(processed) => processed, + Err(err) => { + abandon_pending_late_binding_auction(&mut state, "buffered_process_error").await; + return Err(err); + } + }; + write_late_bound_processed_output(&mut late_bound, &mut binder, &processed, &mut state) + .await?; + } + + let final_out = processor + .process_chunk(&[], true) + .change_context(TrustedServerError::Proxy { + message: "Failed to process buffered publisher HTML".to_string(), + }); + let final_out = match final_out { + Ok(final_out) => final_out, + Err(err) => { + abandon_pending_late_binding_auction(&mut state, "buffered_process_error").await; + return Err(err); + } + }; + write_late_bound_processed_output(&mut late_bound, &mut binder, &final_out, &mut state).await?; + let found_placeholder = binder.replaced(); + let remainder = binder.finish(); + write_checked_processed_output_or_abandon( + &mut late_bound, + &remainder, + &mut state, + "processed_output_error", + ) + .await?; + if !found_placeholder { + if state.tracker.bid_placeholder_inserted() { + log::warn!( + "SSAT bid placeholder was inserted but not observed in buffered output; using EOF fallback" + ); + } + resolve_late_bound_bids(&mut state).await; + let tail = build_eof_fallback_tail( + state.tracker.head_injected(), + &state.fallback, + state.ad_bids_state, + ); + write_checked_processed_output_or_abandon( + &mut late_bound, + tail.as_bytes(), + &mut state, + "processed_output_error", + ) + .await?; + } + + let post_processors = ctx.integration_registry.html_post_processors(); + let post_processed = run_html_post_processors( + late_bound, + &post_processors, + &ctx.params.origin_host, + &ctx.params.request_host, + &ctx.params.request_scheme, + &document_state, + ctx.settings.publisher.max_buffered_body_bytes, + ) + .change_context(TrustedServerError::Proxy { + message: "Failed to post-process buffered publisher HTML".to_string(), + })?; + + encode_processed_html_to_writer(&post_processed, compression, output) +} + +fn decode_body_to_vec( + body: EdgeBody, + compression: Compression, + limit: usize, +) -> Result, Report> { + use brotli::Decompressor; + use flate2::read::{GzDecoder, ZlibDecoder}; + + let body = body_as_reader(body); + match compression { + Compression::None => read_to_vec_bounded(body, limit), + Compression::Gzip => read_to_vec_bounded(GzDecoder::new(body), limit), + Compression::Deflate => read_to_vec_bounded(ZlibDecoder::new(body), limit), + Compression::Brotli => { + read_to_vec_bounded(Decompressor::new(body, STREAM_CHUNK_SIZE), limit) + } + } +} + +fn read_to_vec_bounded( + mut reader: R, + limit: usize, +) -> Result, Report> { + let mut output = Vec::new(); + let mut buffer = vec![0u8; STREAM_CHUNK_SIZE]; + loop { + match reader.read(&mut buffer) { + Ok(0) => return Ok(output), + Ok(n) => { + if output.len() + n > limit { + return Err(Report::new(TrustedServerError::Proxy { + message: "publisher decoded input exceeded maximum buffered size" + .to_string(), + })); + } + output.extend_from_slice(&buffer[..n]); + } + Err(err) => { + return Err(Report::new(TrustedServerError::Proxy { + message: format!("Failed to decode publisher body: {err}"), + })); + } + } + } +} + +fn encode_processed_html_to_writer( + bytes: &[u8], + compression: Compression, + output: &mut W, +) -> Result<(), Report> { + use brotli::enc::writer::CompressorWriter; + use brotli::enc::BrotliEncoderParams; + use flate2::write::{GzEncoder, ZlibEncoder}; + + match compression { + Compression::None => output + .write_all(bytes) + .change_context(TrustedServerError::Proxy { + message: "Failed to write buffered publisher HTML".to_string(), + }), + Compression::Gzip => { + let mut encoder = GzEncoder::new(output, flate2::Compression::default()); + encoder + .write_all(bytes) + .change_context(TrustedServerError::Proxy { + message: "Failed to write gzip publisher HTML".to_string(), + })?; + encoder.finish().change_context(TrustedServerError::Proxy { + message: "Failed to finalize gzip encoder".to_string(), + })?; + Ok(()) + } + Compression::Deflate => { + let mut encoder = ZlibEncoder::new(output, flate2::Compression::default()); + encoder + .write_all(bytes) + .change_context(TrustedServerError::Proxy { + message: "Failed to write deflate publisher HTML".to_string(), + })?; + encoder.finish().change_context(TrustedServerError::Proxy { + message: "Failed to finalize deflate encoder".to_string(), + })?; + Ok(()) + } + Compression::Brotli => { + let params = BrotliEncoderParams { + quality: 4, + lgwin: 22, + ..Default::default() + }; + let mut encoder = CompressorWriter::with_params(output, STREAM_CHUNK_SIZE, ¶ms); + encoder + .write_all(bytes) + .change_context(TrustedServerError::Proxy { + message: "Failed to write brotli publisher HTML".to_string(), + })?; + encoder.flush().change_context(TrustedServerError::Proxy { + message: "Failed to finalize brotli encoder".to_string(), + })?; + let _ = encoder.into_inner(); + Ok(()) + } + } +} + /// Builds the canonical mediator placeholder [`Request`] passed to the collect /// phase via [`make_collect_context`]. /// @@ -964,7 +1308,7 @@ impl AuctionTelemetryCarry { /// Bundles the auction-collection dependencies passed through the streaming helpers. struct AuctionCollectCtx<'a> { - dispatched: DispatchedAuction, + dispatched: Option, telemetry: AuctionTelemetryCarry, price_granularity: PriceGranularity, ad_bids_state: &'a Arc>>, @@ -973,14 +1317,29 @@ struct AuctionCollectCtx<'a> { settings: &'a Settings, } -/// Run the close-body hold loop for HTML bodies, collecting the auction before -/// the raw ` { + origin_host: &'a str, + request_host: &'a str, + request_scheme: &'a str, + integration_registry: &'a IntegrationRegistry, + ad_slots_script: Option<&'a str>, +} + +struct LateBindingStreamConfig<'a> { + placeholder: &'a BidPlaceholder, + tracker: Arc, + fallback: LateBindingFallbackContext<'a>, +} + +/// Run the parser-safe placeholder late-binding loop for HTML bodies. async fn stream_html_with_auction_hold( body: EdgeBody, output: &mut W, processor: &mut P, compression: Compression, ctx: AuctionCollectCtx<'_>, + late_binding: LateBindingStreamConfig<'_>, ) -> Result<(), Report> { use brotli::enc::writer::CompressorWriter; use brotli::enc::BrotliEncoderParams; @@ -988,13 +1347,36 @@ async fn stream_html_with_auction_hold( use flate2::read::{GzDecoder, ZlibDecoder}; use flate2::write::{GzEncoder, ZlibEncoder}; + let placeholder = late_binding.placeholder; + let tracker = late_binding.tracker; + let fallback_ctx = late_binding.fallback; let body = body_as_reader(body); match compression { - Compression::None => body_close_hold_loop(body, output, processor, ctx).await, + Compression::None => { + body_close_hold_loop( + body, + output, + processor, + placeholder, + Arc::clone(&tracker), + ctx, + fallback_ctx, + ) + .await + } Compression::Gzip => { let decoder = GzDecoder::new(body); let mut encoder = GzEncoder::new(&mut *output, flate2::Compression::default()); - body_close_hold_loop(decoder, &mut encoder, processor, ctx).await?; + body_close_hold_loop( + decoder, + &mut encoder, + processor, + placeholder, + Arc::clone(&tracker), + ctx, + fallback_ctx, + ) + .await?; encoder.finish().change_context(TrustedServerError::Proxy { message: "Failed to finalize gzip encoder".to_string(), })?; @@ -1003,7 +1385,16 @@ async fn stream_html_with_auction_hold( Compression::Deflate => { let decoder = ZlibDecoder::new(body); let mut encoder = ZlibEncoder::new(&mut *output, flate2::Compression::default()); - body_close_hold_loop(decoder, &mut encoder, processor, ctx).await?; + body_close_hold_loop( + decoder, + &mut encoder, + processor, + placeholder, + Arc::clone(&tracker), + ctx, + fallback_ctx, + ) + .await?; encoder.finish().change_context(TrustedServerError::Proxy { message: "Failed to finalize deflate encoder".to_string(), })?; @@ -1018,150 +1409,154 @@ async fn stream_html_with_auction_hold( }; let mut encoder = CompressorWriter::with_params(&mut *output, STREAM_CHUNK_SIZE, ¶ms); - body_close_hold_loop(decoder, &mut encoder, processor, ctx).await?; + body_close_hold_loop( + decoder, + &mut encoder, + processor, + placeholder, + Arc::clone(&tracker), + ctx, + fallback_ctx, + ) + .await?; let _ = encoder.into_inner(); Ok(()) } } } -const BODY_CLOSE_PREFIX: &[u8] = b", - found_close: bool, -} - -impl BodyCloseHoldBuffer { - fn new() -> Self { - Self { - buffered: Vec::new(), - found_close: false, - } - } - - fn push(&mut self, chunk: &[u8]) -> Vec { - self.buffered.extend_from_slice(chunk); - - if self.found_close { - return Vec::new(); - } - - if let Some(pos) = find_ascii_case_insensitive(&self.buffered, BODY_CLOSE_PREFIX) { - self.found_close = true; - return self.buffered.drain(..pos).collect(); - } - - let keep_len = BODY_CLOSE_PREFIX.len().saturating_sub(1); - if self.buffered.len() <= keep_len { - return Vec::new(); - } - - let split_at = self.buffered.len() - keep_len; - self.buffered.drain(..split_at).collect() - } - - fn found_close(&self) -> bool { - self.found_close - } - - fn finish(self) -> Vec { - self.buffered - } -} - -fn find_ascii_case_insensitive(haystack: &[u8], needle: &[u8]) -> Option { - haystack.windows(needle.len()).position(|window| { - window - .iter() - .zip(needle) - .all(|(left, right)| left.eq_ignore_ascii_case(right)) - }) +struct LateBindingState<'a> { + dispatched: Option, + telemetry: AuctionTelemetryCarry, + price_granularity: PriceGranularity, + ad_bids_state: &'a Arc>>, + orchestrator: &'a AuctionOrchestrator, + services: &'a RuntimeServices, + settings: &'a Settings, + tracker: Arc, + fallback: LateBindingFallbackContext<'a>, + decoded_input_bytes: usize, + processed_output_bytes: usize, } -/// Core close-body hold loop. -/// -/// Streams processed output until the first case-insensitive `( +/// Core parser-safe placeholder late-binding loop. +async fn body_close_hold_loop( mut reader: R, writer: &mut W, processor: &mut P, + placeholder: &BidPlaceholder, + tracker: Arc, ctx: AuctionCollectCtx<'_>, + fallback_ctx: LateBindingFallbackContext<'_>, ) -> Result<(), Report> { let AuctionCollectCtx { dispatched, - mut telemetry, + telemetry, price_granularity, ad_bids_state, orchestrator, services, settings, } = ctx; + let held_tail_cap = SSAT_HELD_TAIL_CAP_BYTES.min(settings.publisher.max_buffered_body_bytes); + let mut binder = PlaceholderLateBinder::new(placeholder, held_tail_cap); + let mut state = LateBindingState { + dispatched, + telemetry, + price_granularity, + ad_bids_state, + orchestrator, + services, + settings, + tracker, + fallback: fallback_ctx, + decoded_input_bytes: 0, + processed_output_bytes: 0, + }; let mut buffer = vec![0u8; STREAM_CHUNK_SIZE]; - let mut hold = Some(BodyCloseHoldBuffer::new()); - let mut dispatched = Some(dispatched); loop { - match reader.read(&mut buffer) { - Ok(0) => { - if let Some(hold) = hold.take() { - let dispatched = dispatched - .take() - .expect("should have dispatched auction to collect"); - collect_stream_auction( - dispatched, - telemetry.take(), - price_granularity, - ad_bids_state, - orchestrator, - services, - settings, - ) - .await; + match reader.read(&mut buffer) { + Ok(0) => { + let final_out = + processor + .process_chunk(&[], true) + .change_context(TrustedServerError::Proxy { + message: "Failed to finalize processor".to_string(), + }); + let final_out = match final_out { + Ok(final_out) => final_out, + Err(err) => { + abandon_pending_late_binding_auction(&mut state, "stream_finalize_error") + .await; + return Err(err); + } + }; + write_late_bound_processed_output(writer, &mut binder, &final_out, &mut state) + .await?; + + let found_placeholder = binder.replaced(); + let remainder = binder.finish(); + write_checked_processed_output_or_abandon( + writer, + &remainder, + &mut state, + "processed_output_error", + ) + .await?; - let held = hold.finish(); - write_processed_chunk( + if !found_placeholder { + if state.tracker.bid_placeholder_inserted() { + log::warn!( + "SSAT bid placeholder was inserted but not observed in processed output; using EOF fallback" + ); + } + resolve_late_bound_bids(&mut state).await; + let tail = build_eof_fallback_tail( + state.tracker.head_injected(), + &state.fallback, + state.ad_bids_state, + ); + write_checked_processed_output_or_abandon( writer, - processor, - &held, - false, - "Failed to process held body close", - "Failed to write held body close", - )?; - } - // Signal EOF to lol_html (fires end() which flushes remaining state). - let final_out = processor.process_chunk(&[], true).change_context( - TrustedServerError::Proxy { - message: "Failed to finalize processor".to_string(), - }, - )?; - if !final_out.is_empty() { - writer - .write_all(&final_out) - .change_context(TrustedServerError::Proxy { - message: "Failed to write finalized output".to_string(), - })?; + tail.as_bytes(), + &mut state, + "processed_output_error", + ) + .await?; } break; } Ok(n) => { - if let Some(hold_buffer) = hold.as_mut() { - let ready = hold_buffer.push(&buffer[..n]); - if let Err(err) = write_processed_chunk( - writer, - processor, - &ready, - false, - "Failed to process chunk", - "Failed to write chunk", - ) { - if let Some(dispatched) = dispatched.take() { + state.decoded_input_bytes = state.decoded_input_bytes.saturating_add(n); + if state.decoded_input_bytes > state.settings.publisher.max_buffered_body_bytes { + if let Some(dispatched) = state.dispatched.take() { + emit_abandoned_auction( + state.services, + state.telemetry.observation.take(), + dispatched, + "decoded_input_cap_exceeded", + ) + .await; + } + return Err(Report::new(TrustedServerError::Proxy { + message: "publisher decoded input exceeded maximum streaming size" + .to_string(), + })); + } + + let processed = processor.process_chunk(&buffer[..n], false).change_context( + TrustedServerError::Proxy { + message: "Failed to process chunk".to_string(), + }, + ); + let processed = match processed { + Ok(processed) => processed, + Err(err) => { + if let Some(dispatched) = state.dispatched.take() { emit_abandoned_auction( - services, - telemetry.observation.take(), + state.services, + state.telemetry.observation.take(), dispatched, "stream_process_error", ) @@ -1169,51 +1564,15 @@ async fn body_close_hold_loop( } return Err(err); } - - if hold_buffer.found_close() { - let dispatched = dispatched - .take() - .expect("should have dispatched auction to collect"); - collect_stream_auction( - dispatched, - telemetry.take(), - price_granularity, - ad_bids_state, - orchestrator, - services, - settings, - ) - .await; - - let held = hold - .take() - .expect("should have close-body hold buffer") - .finish(); - write_processed_chunk( - writer, - processor, - &held, - false, - "Failed to process held body close", - "Failed to write held body close", - )?; - } - } else { - write_processed_chunk( - writer, - processor, - &buffer[..n], - false, - "Failed to process chunk", - "Failed to write chunk", - )?; - } + }; + write_late_bound_processed_output(writer, &mut binder, &processed, &mut state) + .await?; } Err(e) => { - if let Some(dispatched) = dispatched.take() { + if let Some(dispatched) = state.dispatched.take() { emit_abandoned_auction( - services, - telemetry.observation.take(), + state.services, + state.telemetry.observation.take(), dispatched, "stream_read_error", ) @@ -1232,6 +1591,166 @@ async fn body_close_hold_loop( Ok(()) } +async fn write_late_bound_processed_output( + writer: &mut W, + binder: &mut PlaceholderLateBinder, + processed: &[u8], + state: &mut LateBindingState<'_>, +) -> Result<(), Report> { + let scan = match binder.push(processed) { + Ok(scan) => scan, + Err(err) => { + abandon_pending_late_binding_auction(state, "placeholder_scan_error").await; + return Err(err); + } + }; + + match scan { + PlaceholderScan::Emit(bytes) => { + write_checked_processed_output_or_abandon( + writer, + &bytes, + state, + "processed_output_error", + ) + .await + } + PlaceholderScan::Found { before, after } => { + write_checked_processed_output_or_abandon( + writer, + &before, + state, + "processed_output_error", + ) + .await?; + resolve_late_bound_bids(state).await; + let replacement = build_body_close_replacement_tail(state); + write_checked_processed_output(writer, replacement.as_bytes(), state)?; + write_checked_processed_output(writer, &after, state) + } + } +} + +async fn resolve_late_bound_bids(state: &mut LateBindingState<'_>) { + let Some(dispatched) = state.dispatched.take() else { + return; + }; + collect_stream_auction( + dispatched, + state.telemetry.take(), + state.price_granularity, + state.ad_bids_state, + state.orchestrator, + state.services, + state.settings, + ) + .await; +} + +async fn abandon_pending_late_binding_auction( + state: &mut LateBindingState<'_>, + reason: &'static str, +) { + let Some(dispatched) = state.dispatched.take() else { + return; + }; + emit_abandoned_auction( + state.services, + state.telemetry.observation.take(), + dispatched, + reason, + ) + .await; +} + +async fn write_checked_processed_output_or_abandon( + writer: &mut W, + bytes: &[u8], + state: &mut LateBindingState<'_>, + reason: &'static str, +) -> Result<(), Report> { + match write_checked_processed_output(writer, bytes, state) { + Ok(()) => Ok(()), + Err(err) => { + abandon_pending_late_binding_auction(state, reason).await; + Err(err) + } + } +} + +fn current_bids_script(ad_bids_state: &Arc>>) -> String { + ad_bids_state + .lock() + .expect("should lock bid state") + .clone() + .unwrap_or_else(build_empty_bids_script) +} + +fn build_body_close_replacement_tail(state: &LateBindingState<'_>) -> String { + if state.tracker.head_injected() { + return current_bids_script(state.ad_bids_state); + } + + log::info!("SSAT bid injection used missing-head body-close fallback"); + state.tracker.mark_head_injected(); + build_bootstrap_and_bids_tail(&state.fallback, state.ad_bids_state) +} + +fn build_eof_fallback_tail( + head_bootstrap_observed: bool, + fallback_ctx: &LateBindingFallbackContext<'_>, + ad_bids_state: &Arc>>, +) -> String { + if head_bootstrap_observed { + log::info!("SSAT bid injection used EOF fallback after head bootstrap"); + return current_bids_script(ad_bids_state); + } + + log::info!("SSAT bid injection used missing-head EOF fallback"); + build_bootstrap_and_bids_tail(fallback_ctx, ad_bids_state) +} + +fn build_bootstrap_and_bids_tail( + fallback_ctx: &LateBindingFallbackContext<'_>, + ad_bids_state: &Arc>>, +) -> String { + let document_state = crate::integrations::IntegrationDocumentState::default(); + let mut tail = crate::html_processor::build_head_bootstrap_snippet( + fallback_ctx.integration_registry, + fallback_ctx.origin_host, + fallback_ctx.request_host, + fallback_ctx.request_scheme, + &document_state, + fallback_ctx.ad_slots_script, + ); + tail.push_str(¤t_bids_script(ad_bids_state)); + tail +} + +fn write_checked_processed_output( + writer: &mut W, + bytes: &[u8], + state: &mut LateBindingState<'_>, +) -> Result<(), Report> { + if bytes.is_empty() { + return Ok(()); + } + + state.processed_output_bytes = state.processed_output_bytes.saturating_add(bytes.len()); + if state.processed_output_bytes > state.settings.publisher.max_buffered_body_bytes { + return Err(Report::new(TrustedServerError::Proxy { + message: "publisher processed output exceeded maximum streaming size".to_string(), + })); + } + + writer + .write_all(bytes) + .change_context(TrustedServerError::Proxy { + message: "Failed to write processed publisher output".to_string(), + })?; + Ok(()) +} + async fn emit_abandoned_auction( services: &RuntimeServices, observation: Option, @@ -1302,35 +1821,6 @@ async fn collect_stream_auction( } } -fn write_processed_chunk( - writer: &mut W, - processor: &mut P, - chunk: &[u8], - is_last: bool, - process_error: &str, - write_error: &str, -) -> Result<(), Report> { - if chunk.is_empty() && !is_last { - return Ok(()); - } - - let out = - processor - .process_chunk(chunk, is_last) - .change_context(TrustedServerError::Proxy { - message: process_error.to_string(), - })?; - if !out.is_empty() { - writer - .write_all(&out) - .change_context(TrustedServerError::Proxy { - message: write_error.to_string(), - })?; - } - - Ok(()) -} - /// Auction dispatch context passed to [`handle_publisher_request`]. pub struct AuctionDispatch<'a> { /// Orchestrator that dispatches and collects SSP bid requests. @@ -2489,8 +2979,7 @@ pub async fn handle_page_bids( #[cfg(test)] mod tests { - use std::io::{self, Read as _, Write as _}; - use std::sync::atomic::{AtomicUsize, Ordering}; + use std::io::{Read as _, Write as _}; use brotli::enc::writer::CompressorWriter; use brotli::Decompressor; @@ -2508,47 +2997,6 @@ mod tests { use http::{header, Method, Request as HttpRequest, StatusCode}; use std::sync::Arc; - struct ChunkedReader { - chunks: std::collections::VecDeque>, - read_count: Arc, - } - - impl ChunkedReader { - fn new(chunks: &[&[u8]], read_count: Arc) -> Self { - Self { - chunks: chunks.iter().map(|chunk| chunk.to_vec()).collect(), - read_count, - } - } - } - - impl io::Read for ChunkedReader { - fn read(&mut self, buf: &mut [u8]) -> io::Result { - let Some(chunk) = self.chunks.pop_front() else { - return Ok(0); - }; - self.read_count.fetch_add(1, Ordering::SeqCst); - let len = chunk.len().min(buf.len()); - buf[..len].copy_from_slice(&chunk[..len]); - Ok(len) - } - } - - struct RecordingProcessor { - read_count: Arc, - body_close_processed_at: Arc, - } - - impl StreamProcessor for RecordingProcessor { - fn process_chunk(&mut self, chunk: &[u8], _is_last: bool) -> Result, io::Error> { - if find_ascii_case_insensitive(chunk, BODY_CLOSE_PREFIX).is_some() { - self.body_close_processed_at - .store(self.read_count.load(Ordering::SeqCst), Ordering::SeqCst); - } - Ok(chunk.to_vec()) - } - } - fn gzip_encode(input: &[u8]) -> Vec { let mut encoder = GzEncoder::new(Vec::new(), flate2::Compression::default()); encoder @@ -2603,6 +3051,28 @@ mod tests { } } + fn make_html_ssat_params( + settings: &Settings, + dispatched_auction: Option, + ) -> OwnedProcessResponseParams { + OwnedProcessResponseParams { + content_encoding: String::new(), + origin_host: settings.publisher.origin_host(), + origin_url: settings.publisher.origin_url.clone(), + request_host: settings.publisher.domain.clone(), + request_scheme: "https".to_owned(), + content_type: "text/html; charset=utf-8".to_owned(), + ad_slots_script: Some( + r#""#.to_owned(), + ), + ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), + auction_observation: None, + auction_request: None, + dispatched_auction, + price_granularity: Default::default(), + } + } + fn test_auction_request() -> AuctionRequest { AuctionRequest { id: "test-auction".to_string(), @@ -3109,93 +3579,217 @@ mod tests { } #[tokio::test] - async fn body_close_hold_loop_processes_close_tail_before_reading_post_body_chunks() { + async fn ssat_late_binding_ignores_body_text_inside_script() { let settings = create_test_settings(); let services = noop_services(); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let registry = IntegrationRegistry::empty_for_tests(); let dispatched = DispatchedAuction::empty_for_test(test_auction_request(), 500); - let read_count = Arc::new(AtomicUsize::new(0)); - let body_close_processed_at = Arc::new(AtomicUsize::new(0)); - let reader = ChunkedReader::new( - &[ - b"painted", - b"", - b"", - ], - Arc::clone(&read_count), - ); - let mut processor = RecordingProcessor { - read_count: Arc::clone(&read_count), - body_close_processed_at: Arc::clone(&body_close_processed_at), - }; - let ad_bids_state = Arc::new(Mutex::new(None)); - let ctx = AuctionCollectCtx { - dispatched, - telemetry: AuctionTelemetryCarry { - observation: None, - auction_request: None, - }, - price_granularity: PriceGranularity::default(), - ad_bids_state: &ad_bids_state, - orchestrator: &orchestrator, - services: &services, - settings: &settings, - }; + let mut params = make_html_ssat_params(&settings, Some(dispatched)); let mut output = Vec::new(); + let html = r#"

painted

"#; - body_close_hold_loop(reader, &mut output, &mut processor, ctx) - .await - .expect("should stream body with auction hold"); + stream_publisher_body_async( + EdgeBody::from(html.as_bytes().to_vec()), + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("should stream HTML with parser-safe late binding"); - assert_eq!( - body_close_processed_at.load(Ordering::SeqCst), - 1, - "close-body tail should be processed as soon as it is found, before later chunks are read" + let rewritten = String::from_utf8(output).expect("should be utf8"); + assert!( + rewritten.contains("const marker = \"\";"), + "script literal should remain script text" + ); + let script_literal_pos = rewritten + .find("const marker") + .expect("should preserve script literal"); + let bids_pos = rewritten + .find(".bids=JSON.parse") + .expect("should inject bids script"); + let real_body_close_pos = rewritten.rfind("").expect("should have body close"); + assert!( + bids_pos > script_literal_pos, + "script literal must not trigger early bid insertion" ); - assert_eq!( - std::str::from_utf8(&output).expect("should be utf8"), - "painted", - "post-body chunks should still stream in order" + assert!( + bids_pos < real_body_close_pos, + "bids must be injected before the real parser-confirmed body close" + ); + assert!( + !rewritten.contains("__TSJS_BIDS_PLACEHOLDER"), + "placeholder must not leak to the client" ); } - #[test] - fn body_close_hold_buffer_holds_close_body_tail_in_single_chunk() { - let mut hold = BodyCloseHoldBuffer::new(); + #[tokio::test] + async fn ssat_late_binding_appends_bids_at_eof_when_body_close_missing() { + let settings = create_test_settings(); + let services = noop_services(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let registry = IntegrationRegistry::empty_for_tests(); + let mut params = make_html_ssat_params(&settings, None); + let mut output = Vec::new(); + let html = b"

painted

"; - let ready = hold.push(b"painted"); - let held = hold.finish(); + stream_publisher_body_async( + EdgeBody::from(html.to_vec()), + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("should append EOF fallback bids"); - assert_eq!( - std::str::from_utf8(&ready).expect("should be utf8"), - "painted", - "content before should stream before auction collection" + let rewritten = String::from_utf8(output).expect("should be utf8"); + assert!( + rewritten.contains(".bids=JSON.parse"), + "missing body close should append bids at EOF" ); - assert_eq!( - std::str::from_utf8(&held).expect("should be utf8"), - "", - "the close-body tag and trailing bytes should be held" + assert!( + !rewritten.contains("__TSJS_BIDS_PLACEHOLDER"), + "placeholder must not leak on EOF fallback" ); } - #[test] - fn body_close_hold_buffer_holds_close_body_tail_across_chunks() { - let mut hold = BodyCloseHoldBuffer::new(); + #[tokio::test] + async fn ssat_late_binding_prepends_bootstrap_when_body_closes_without_head() { + let settings = create_test_settings(); + let services = noop_services(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let registry = IntegrationRegistry::empty_for_tests(); + let mut params = make_html_ssat_params(&settings, None); + let mut output = Vec::new(); + let html = b"

painted

"; - let first = hold.push(b"painted"); - let held = hold.finish(); + stream_publisher_body_async( + EdgeBody::from(html.to_vec()), + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("should prepend missing-head bootstrap before body-close bids"); + + let rewritten = String::from_utf8(output).expect("should be utf8"); + let slots_pos = rewritten + .find(".adSlots=") + .expect("should inject ad slot state"); + let bundle_pos = rewritten + .find("/static/tsjs=") + .expect("should inject tsjs script tag"); + let bids_pos = rewritten + .find(".bids=JSON.parse") + .expect("should inject bids script"); + let body_close_pos = rewritten.rfind("").expect("should have body close"); + assert!( + slots_pos < bundle_pos && bundle_pos < bids_pos && bids_pos < body_close_pos, + "missing-head body-close replacement should preserve executable order before " + ); + assert!( + !rewritten.contains("__TSJS_BIDS_PLACEHOLDER"), + "placeholder must not leak on missing-head body-close replacement" + ); + } - let streamed = [first, second].concat(); - assert_eq!( - std::str::from_utf8(&streamed).expect("should be utf8"), - "painted", - "split bytes must not leak before auction collection" + #[tokio::test] + async fn ssat_late_binding_appends_minimal_tail_when_head_and_body_close_missing() { + let settings = create_test_settings(); + let services = noop_services(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let registry = IntegrationRegistry::empty_for_tests(); + let mut params = make_html_ssat_params(&settings, None); + let mut output = Vec::new(); + let html = b"
fragment without document scaffolding
"; + + stream_publisher_body_async( + EdgeBody::from(html.to_vec()), + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect("should append missing-head EOF fallback tail"); + + let rewritten = String::from_utf8(output).expect("should be utf8"); + let slots_pos = rewritten + .find(".adSlots=") + .expect("should append ad slot state"); + let bundle_pos = rewritten + .find("/static/tsjs=") + .expect("should append tsjs script tag"); + let bids_pos = rewritten + .find(".bids=JSON.parse") + .expect("should append bids script"); + assert!( + slots_pos < bundle_pos && bundle_pos < bids_pos, + "fallback tail should preserve executable order" ); - assert_eq!( - std::str::from_utf8(&held).expect("should be utf8"), - "", - "split close-body tag should be held intact" + assert!( + !rewritten.contains("__TSJS_BIDS_PLACEHOLDER"), + "placeholder must not leak on missing-head fallback" + ); + } + + #[tokio::test] + async fn buffered_ssat_postprocessor_path_rejects_processed_output_over_cap() { + let mut settings = create_test_settings(); + settings.publisher.max_buffered_body_bytes = 96; + settings + .integrations + .insert_config( + "nextjs", + &serde_json::json!({ + "enabled": true, + "rewrite_attributes": ["href"], + }), + ) + .expect("should update nextjs config"); + let services = noop_services(); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + assert!( + registry.has_html_post_processors(), + "nextjs should force the buffered post-processor path" + ); + let mut params = make_html_ssat_params(&settings, None); + let mut output = Vec::new(); + let html = b"x"; + assert!( + html.len() < settings.publisher.max_buffered_body_bytes, + "decoded input should fit so the processed-output cap is exercised" + ); + + let err = stream_publisher_body_async( + EdgeBody::from(html.to_vec()), + &mut output, + &mut params, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + .expect_err("should reject processed output over cap"); + + let err = format!("{err:?}"); + assert!( + err.contains("processed output exceeded"), + "error should come from the processed-output cap: {err}" ); } diff --git a/crates/trusted-server-core/src/publisher_late_binding.rs b/crates/trusted-server-core/src/publisher_late_binding.rs new file mode 100644 index 000000000..506279ab0 --- /dev/null +++ b/crates/trusted-server-core/src/publisher_late_binding.rs @@ -0,0 +1,317 @@ +//! Parser-safe SSAT bid placeholder late binding. +//! +//! The publisher HTML rewriter inserts an opaque placeholder only from a +//! parser-confirmed `` handler. This module scans the rewritten, +//! uncompressed HTML stream for that placeholder and lets the caller replace it +//! with the final bids script without scanning raw origin bytes for HTML syntax. + +use std::sync::atomic::{AtomicBool, Ordering}; + +use error_stack::Report; +use uuid::Uuid; + +use crate::error::TrustedServerError; + +/// Maximum processed-output suffix retained after the bid placeholder is found. +pub(crate) const SSAT_HELD_TAIL_CAP_BYTES: usize = 64 * 1024; + +/// Per-request opaque marker inserted before a parser-confirmed ``. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct BidPlaceholder { + html: String, +} + +impl BidPlaceholder { + /// Generate a new high-entropy HTML comment placeholder for one response. + #[must_use] + pub(crate) fn new() -> Self { + let id = Uuid::new_v4(); + Self { + html: format!(""), + } + } + + /// Return the placeholder as HTML. + #[must_use] + pub(crate) fn as_html(&self) -> &str { + &self.html + } + + /// Return the placeholder bytes used by the streaming scanner. + #[must_use] + pub(crate) fn as_bytes(&self) -> &[u8] { + self.html.as_bytes() + } +} + +/// Tracks which parser insertions actually happened for EOF fallback decisions. +#[derive(Debug, Default)] +pub struct HtmlInjectionTracker { + head_injected: AtomicBool, + bid_placeholder_inserted: AtomicBool, +} + +impl HtmlInjectionTracker { + /// Mark that the normal `` bootstrap insertion ran. + pub(crate) fn mark_head_injected(&self) { + self.head_injected.store(true, Ordering::SeqCst); + } + + /// Mark that the body end-tag placeholder insertion ran. + pub(crate) fn mark_bid_placeholder_inserted(&self) { + self.bid_placeholder_inserted.store(true, Ordering::SeqCst); + } + + /// Return whether the normal `` bootstrap insertion ran. + #[must_use] + pub(crate) fn head_injected(&self) -> bool { + self.head_injected.load(Ordering::SeqCst) + } + + /// Return whether the body end-tag placeholder insertion ran. + #[must_use] + pub(crate) fn bid_placeholder_inserted(&self) -> bool { + self.bid_placeholder_inserted.load(Ordering::SeqCst) + } +} + +/// Result of pushing one processed-output chunk through the placeholder scanner. +#[derive(Debug)] +pub(crate) enum PlaceholderScan { + /// No placeholder was found; emit these bytes immediately. + Emit(Vec), + /// The first placeholder was found. Emit `before`, collect/resolve bids, + /// then emit the replacement followed by `after`. + Found { before: Vec, after: Vec }, +} + +/// Streaming scanner for the bid placeholder. +pub(crate) struct PlaceholderLateBinder { + placeholder: Vec, + buffered: Vec, + replaced: bool, + held_tail_cap: usize, +} + +impl PlaceholderLateBinder { + /// Create a scanner for `placeholder` with a bounded post-placeholder hold. + #[must_use] + pub(crate) fn new(placeholder: &BidPlaceholder, held_tail_cap: usize) -> Self { + Self { + placeholder: placeholder.as_bytes().to_vec(), + buffered: Vec::new(), + replaced: false, + held_tail_cap, + } + } + + /// Return true after the first placeholder has been found. + #[must_use] + pub(crate) fn replaced(&self) -> bool { + self.replaced + } + + /// Push processed uncompressed output through the placeholder scanner. + /// + /// # Errors + /// + /// Returns a proxy error if the suffix held after the first placeholder + /// exceeds the configured held-tail cap. + pub(crate) fn push( + &mut self, + chunk: &[u8], + ) -> Result> { + if self.replaced { + let emit = self.push_after_replacement(chunk); + return Ok(PlaceholderScan::Emit(emit)); + } + + self.buffered.extend_from_slice(chunk); + if let Some(position) = find_bytes(&self.buffered, &self.placeholder) { + self.replaced = true; + let before = self.buffered.drain(..position).collect::>(); + self.buffered.drain(..self.placeholder.len()); + let suffix = std::mem::take(&mut self.buffered); + if suffix.len() > self.held_tail_cap { + return Err(Report::new(TrustedServerError::Proxy { + message: format!( + "SSAT placeholder held tail {} bytes exceeds {}-byte limit", + suffix.len(), + self.held_tail_cap + ), + })); + } + let after = self.push_after_replacement(&suffix); + return Ok(PlaceholderScan::Found { before, after }); + } + + Ok(PlaceholderScan::Emit(self.drain_safe_prefix())) + } + + /// Finish scanning and return all remaining safe bytes. + #[must_use] + pub(crate) fn finish(mut self) -> Vec { + if self.replaced { + remove_all_placeholders(&mut self.buffered, &self.placeholder); + } + self.buffered + } + + fn push_after_replacement(&mut self, chunk: &[u8]) -> Vec { + self.buffered.extend_from_slice(chunk); + remove_all_placeholders(&mut self.buffered, &self.placeholder); + self.drain_safe_prefix() + } + + fn drain_safe_prefix(&mut self) -> Vec { + let keep_len = partial_placeholder_prefix_len(&self.buffered, &self.placeholder); + if self.buffered.len() <= keep_len { + return Vec::new(); + } + + let split_at = self.buffered.len() - keep_len; + self.buffered.drain(..split_at).collect() + } +} + +fn partial_placeholder_prefix_len(buffered: &[u8], placeholder: &[u8]) -> usize { + let max_len = buffered.len().min(placeholder.len().saturating_sub(1)); + (1..=max_len) + .rev() + .find(|&len| buffered.ends_with(&placeholder[..len])) + .unwrap_or(0) +} + +fn remove_all_placeholders(buffered: &mut Vec, placeholder: &[u8]) { + while let Some(position) = find_bytes(buffered, placeholder) { + buffered.drain(position..position + placeholder.len()); + } +} + +fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { + if needle.is_empty() { + return Some(0); + } + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bid_placeholder_is_unique_html_comment() { + let first = BidPlaceholder::new(); + let second = BidPlaceholder::new(); + + assert_ne!(first, second, "per-request placeholders should be unique"); + assert!( + first.as_html().starts_with(""), + "placeholder should close the HTML comment" + ); + } + + #[test] + fn late_binder_detects_placeholder_split_across_chunks() { + let placeholder = BidPlaceholder { + html: "".to_string(), + }; + let marker = placeholder.as_html(); + let split = marker.len() / 2; + let mut binder = PlaceholderLateBinder::new(&placeholder, SSAT_HELD_TAIL_CAP_BYTES); + + let first = binder + .push(format!("before{}", &marker[..split]).as_bytes()) + .expect("should scan first chunk"); + let second = binder + .push(format!("{}after", &marker[split..]).as_bytes()) + .expect("should scan second chunk"); + + match first { + PlaceholderScan::Emit(bytes) => assert_eq!( + String::from_utf8(bytes).expect("should be utf8"), + "before", + "safe prefix before the split placeholder should stream" + ), + PlaceholderScan::Found { .. } => panic!("should not find split placeholder yet"), + } + let mut output = Vec::new(); + match second { + PlaceholderScan::Found { before, after } => { + assert!(before.is_empty(), "prefix was already emitted"); + output.extend(after); + } + PlaceholderScan::Emit(_) => panic!("should find placeholder in second chunk"), + } + output.extend(binder.finish()); + assert_eq!( + String::from_utf8(output).expect("should be utf8"), + "after", + "suffix after placeholder should be returned by the scanner" + ); + } + + #[test] + fn late_binder_strips_later_placeholder_occurrences() { + let placeholder = BidPlaceholder { + html: "".to_string(), + }; + let mut binder = PlaceholderLateBinder::new(&placeholder, SSAT_HELD_TAIL_CAP_BYTES); + let first = format!("a{}b", placeholder.as_html()); + let second = format!("c{}d", placeholder.as_html()); + + let first = binder + .push(first.as_bytes()) + .expect("should scan first placeholder"); + let second = binder + .push(second.as_bytes()) + .expect("should scan duplicate placeholder"); + let final_bytes = binder.finish(); + + let mut output = Vec::new(); + match first { + PlaceholderScan::Found { before, after } => { + output.extend(before); + output.extend(b"REPLACED"); + output.extend(after); + } + PlaceholderScan::Emit(_) => panic!("should find first placeholder"), + } + match second { + PlaceholderScan::Emit(bytes) => output.extend(bytes), + PlaceholderScan::Found { .. } => panic!("should not find second placeholder"), + } + output.extend(final_bytes); + + let output = String::from_utf8(output).expect("should be utf8"); + assert_eq!(output, "aREPLACEDbcd"); + assert!( + !output.contains(placeholder.as_html()), + "placeholder should not leak after replacement" + ); + } + + #[test] + fn late_binder_rejects_large_held_tail() { + let placeholder = BidPlaceholder { + html: "".to_string(), + }; + let mut binder = PlaceholderLateBinder::new(&placeholder, 4); + let input = format!("{}12345", placeholder.as_html()); + + let err = binder + .push(input.as_bytes()) + .expect_err("held tail should exceed cap"); + + assert!( + format!("{err:?}").contains("held tail"), + "error should mention held tail cap" + ); + } +} diff --git a/docs/superpowers/plans/2026-07-08-ssat-publisher-streaming-parser-safe-implementation-plan.md b/docs/superpowers/plans/2026-07-08-ssat-publisher-streaming-parser-safe-implementation-plan.md new file mode 100644 index 000000000..2e0fda7fc --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-ssat-publisher-streaming-parser-safe-implementation-plan.md @@ -0,0 +1,740 @@ +# SSAT publisher streaming parser-safe implementation plan + +**Source spec:** `docs/superpowers/specs/2026-07-08-ssat-publisher-streaming-parser-safe-design.md` +**Issue:** #857 +**Status:** Draft implementation plan +**Area:** Trusted Server runtime / publisher fallback / server-side ad stack + +## Objective + +Implement the spec in small, reviewable slices so Fastly publisher SSAT HTML can +stream origin bytes through decode/rewrite/bid late-binding/re-encode to the +client without full origin-body or assembled-body materialization, while keeping +all non-Fastly adapters and full-document post-processors explicitly buffered for +this slice. + +The plan preserves the project constraints in `CLAUDE.md`: minimal unrelated +refactoring, no bare workspace `cargo test`, `error-stack` errors, `log` macros, +no Tokio or OS-only dependencies in core runtime code, and target-aware validation. + +## Current code findings + +- `crates/trusted-server-core/src/publisher.rs` + - `handle_publisher_request` always sends publisher origin requests with + `PlatformHttpRequest::new(req, backend_name)`, so Fastly uses the default + buffered platform response path. + - `body_as_reader()` calls `EdgeBody::into_bytes()`, which is incompatible with + true streaming and would materialize `EdgeBody::Stream(_)`. + - `stream_publisher_body_async` uses `BodyCloseHoldBuffer`, which raw-scans + decoded origin bytes for `` containing `''` does not trigger bid collection; + - script/JSON data containing literal or escaped `` split across origin chunks still injects before the close tag; + - missing `` appends the fallback tail at EOF; + - missing `` plus missing `` appends the minimal executable tail; + - multiple body close tags do not inject multiple bid scripts. +- Routing seams: + - Fastly request-level SSAT streaming candidates set `stream_response = true`; + - registries with HTML post-processors do not set `stream_response = true`; + - non-Fastly adapters keep publisher `stream_response = false`. +- Header seams: + - processed publisher HTML strips stale payload headers; + - pass-through/unmodified streams preserve origin validators and range metadata. + +## Phase 1: Core parser-safe late binding + +### 1.1 Add a late-binding module + +Create `crates/trusted-server-core/src/publisher_late_binding.rs` and export it +from `crates/trusted-server-core/src/lib.rs` or keep it `pub(crate)` from +`publisher.rs`. + +Suggested types: + +```rust +pub(crate) const SSAT_HELD_TAIL_CAP_BYTES: usize = 64 * 1024; + +pub(crate) struct BidPlaceholder { + token: String, + html: String, +} + +pub(crate) struct HtmlInjectionTracker { + head_injected: AtomicBool, + placeholder_inserted: AtomicBool, +} + +pub(crate) enum LateBindingOutcome { + BodyClose, + EofFallback, + MissingHeadEofFallback, +} +``` + +Responsibilities: + +- Generate a high-entropy per-request placeholder with `uuid::Uuid::new_v4()`. +- Render the placeholder as a valid HTML comment. +- Expose byte accessors for streaming scans. +- Track whether `` bootstrap and body-close placeholder insertion occurred. +- Define small helper errors as `TrustedServerError::Proxy` contexts rather than + adding broad new error enums unless needed. + +Acceptance tests: + +- placeholders are unique across requests; +- placeholder bytes are valid UTF-8 and valid comment-shaped HTML; +- `LateBindingScanner` can find a placeholder split across arbitrary chunks; +- later occurrences are stripped after first replacement. + +### 1.2 Extend HTML processor configuration + +Modify `crates/trusted-server-core/src/html_processor.rs`. + +Suggested API: + +```rust +pub enum BidInjectionMode { + DirectState, + Placeholder { html: String, tracker: Arc }, +} + +pub enum HtmlPostProcessingMode { + Enabled, + Disabled, +} +``` + +Add fields to `HtmlProcessorConfig`: + +- `bid_injection_mode: BidInjectionMode` defaulting to `DirectState`; +- `post_processing_mode: HtmlPostProcessingMode` defaulting to `Enabled`; +- optional shared `HtmlInjectionTracker`. + +Add builder methods: + +- `with_ad_state(...)` remains for existing call sites; +- `with_bid_placeholder(placeholder, tracker)` for SSAT late binding; +- `without_post_processing()` for the buffered late-binding-before-postprocess + flow and Fastly true streaming guard. + +Refactor the existing head injection code into a reusable helper, for example: + +```rust +pub(crate) fn build_head_bootstrap_snippet( + integrations: &IntegrationRegistry, + ctx: &IntegrationHtmlContext<'_>, + ad_slots_script: Option<&str>, +) -> String +``` + +The helper must preserve current executable ordering: + +1. ad slot state; +2. integration head config inserts; +3. main TSJS bundle; +4. deferred TSJS bundle tags. + +Body end-tag behavior: + +- If no slots matched, keep skipping bid injection entirely. +- In `DirectState`, preserve current behavior for compatibility. +- In `Placeholder`, insert the placeholder in the first parser-confirmed body + end tag only, using the existing once-only guard pattern. +- Set `tracker.placeholder_inserted = true` only when the placeholder is inserted. +- Set `tracker.head_injected = true` when the `` injection actually runs. + +Acceptance tests: + +- `` inside scripts/comments/attributes does not insert the placeholder; +- a real body close inserts one placeholder before the close tag; +- multiple body tags or end tags still produce at most one placeholder; +- direct-state mode remains compatible with existing tests. + +### 1.3 Build the placeholder late binder + +Implement a streaming scanner over processed uncompressed output. + +Suggested shape: + +```rust +pub(crate) struct PlaceholderLateBinder { + placeholder: BidPlaceholder, + overlap: Vec, + replaced: bool, + held_tail_cap: usize, +} + +pub(crate) enum BinderEvent { + Emit(Vec), + PlaceholderFound { prefix: Vec, suffix: Vec }, +} +``` + +The scanner must: + +- keep at most `placeholder.len() - 1` overlap before the placeholder is found; +- emit all safe prefix bytes immediately; +- on first placeholder, return the prefix and suffix around the placeholder; +- hold only the suffix from the current processed chunk while the auction is + collected; +- enforce `min(SSAT_HELD_TAIL_CAP_BYTES, settings.publisher.max_buffered_body_bytes)`; +- after first replacement, strip any subsequent placeholder bytes defensively; +- never forward the placeholder to the client or to post-processors. + +Acceptance tests: + +- placeholder split across chunks is detected; +- bytes before the placeholder are emitted as soon as safe; +- replacement happens exactly once; +- duplicate placeholder occurrences are stripped; +- held-tail cap violation maps to a proxy error. + +### 1.4 Replace `BodyCloseHoldBuffer` + +Modify `crates/trusted-server-core/src/publisher.rs`. + +Replace raw `BodyCloseHoldBuffer` / `find_ascii_case_insensitive` usage with the +placeholder late-binding flow: + +```text +decoded origin bytes + -> HTML processor configured with placeholder mode and no post-processors + -> processed uncompressed output + -> PlaceholderLateBinder + -> auction collect / empty bid script / EOF fallback + -> output cap counter + -> optional recompression + -> writer +``` + +Important behavior: + +- If `params.dispatched_auction` exists, collect it when the first placeholder is + found; otherwise collect at EOF fallback. +- If no auction was dispatched but slots exist, replace the placeholder + immediately with the current or empty bid script. +- Existing `collect_stream_auction`, `write_bids_to_state`, and + `build_bids_script` paths should be reused so output shape and debug comments + remain stable. +- Auction telemetry must complete or abandon on every exit path. +- Delete or deprecate `BodyCloseHoldBuffer` tests once equivalent placeholder + tests cover the behavior. + +### 1.5 Implement EOF fallback tail + +On EOF, after finalizing `lol_html`: + +1. Feed final processor output through the binder. +2. If a placeholder appears in final output, replace it normally. +3. If no placeholder was ever found: + - collect any dispatched auction; + - if `tracker.head_injected` is true, append only the bids script; + - if `tracker.head_injected` is false, append the minimal bootstrap tail in + executable order using the extracted head-snippet helper plus the bids + script. + +The fallback tail is best-effort malformed-document handling; it must still: + +- count against the processed-output cap; +- never expose placeholders; +- preserve current privacy behavior for SSAT HTML. + +## Phase 2: Streaming pipeline, compression, and caps + +### 2.1 Add a non-materializing publisher body pump + +The current synchronous `StreamingPipeline::process` is safe for buffered +`EdgeBody::Once` but not enough for Fastly true streaming because `body_as_reader` +uses `into_bytes()`. + +Add a publisher-specific async body pump in `publisher.rs` or a new core module +such as `publisher_body_stream.rs`. + +Requirements: + +- Consume `EdgeBody::Once` by chunking the bytes without copying the full payload + again. +- Consume `EdgeBody::Stream(_)` with `StreamExt::next().await`. +- Never call `EdgeBody::into_bytes()`, `into_bytes_bounded()`, or equivalent on + the true streaming SSAT path. +- Convert origin stream errors into `TrustedServerError::Proxy` with context. +- Preserve upstream backpressure by pausing reads while auction collection runs. + +Compression implementation options: + +- Prefer explicit incremental decoder/encoder state machines driven by origin + chunks: + - `flate2::Decompress` / `flate2::Compress` for gzip and deflate if practical; + - brotli streaming reader/writer APIs with small bounded buffers. +- If reusing `std::io::Read`-based decoders with a custom stream reader, document + why it does not materialize and test that it consumes one upstream chunk at a + time. Avoid introducing Tokio/OS-only async dependencies into core. + +### 2.2 Preserve compression finalization + +For processed streaming HTML with supported encodings: + +- identity: write processed bytes directly; +- gzip: finalize with `finish()` and propagate errors; +- deflate: finalize with `finish()` and propagate errors; +- brotli: explicitly flush/finalize through the available brotli writer API and + propagate write errors where available. + +The placeholder scanner must run after HTML rewriting and before recompression. + +Acceptance tests: + +- gzip, deflate, and brotli origin HTML decode/rewrite/re-encode successfully; +- decoded client output has bids before the real parser-confirmed ``; +- final compressed response can be decoded by a client; +- finalization errors are propagated or logged/dropped after commit, depending + on where they occur. + +### 2.3 Enforce streaming caps + +Use `settings.publisher.max_buffered_body_bytes` as the effective cumulative +limit for this slice. + +Counters: + +- `decoded_input_bytes`: increment after decompression and before `lol_html`; +- `processed_output_bytes`: increment after late binding and before + recompression; +- `held_tail_bytes`: enforce via `PlaceholderLateBinder`. + +Pre-commit rejection: + +- For identity responses, if `Content-Length` exceeds the limit, reject before + `stream_to_client()`. +- Do not use compressed `Content-Length` as proof of decoded size. + +Mid-stream violation: + +- Return a proxy error from the streaming loop. +- Fastly send path logs and drops the `StreamingBody` without `finish()` because + headers are already committed. +- Buffered paths return a normal error response as they do today. + +Acceptance tests: + +- large identity HTML below cap succeeds; +- decoded input over cap fails/aborts; +- processed output over cap fails/aborts; +- gzip/deflate/br expansion over cap is caught after decode; +- held-tail cap is enforced. + +### 2.4 Normalize transformed response headers + +Add a helper in `publisher.rs`, for example: + +```rust +pub(crate) fn strip_transformed_payload_headers(headers: &mut HeaderMap) +``` + +Remove at minimum: + +- `Content-Length` +- `Content-MD5` +- `Digest` +- `Repr-Digest` +- `Content-Range` +- `Accept-Ranges` +- `ETag` + +Apply this helper to every processed publisher HTML response, buffered or +streaming. Do not apply it to unmodified pass-through streams. + +Keep `Content-Encoding` when the body is re-encoded with the same encoding. +`Transfer-Encoding` remains adapter-owned. + +## Phase 3: Fastly request/response streaming path + +### 3.1 Add request-level streaming candidate options + +Modify `handle_publisher_request` without breaking existing adapters. + +Suggested API: + +```rust +pub struct PublisherRequestOptions { + pub allow_origin_streaming: bool, +} + +pub async fn handle_publisher_request_with_options(..., options: PublisherRequestOptions) +``` + +Keep the current `handle_publisher_request(...)` as a wrapper with +`allow_origin_streaming = false` so Axum/Cloudflare/Spin remain buffered by +default. + +Request-level Fastly candidate gate: + +- method is `GET` and not `HEAD`; +- request is a navigation; +- server-side ad stack may run for the request; +- no full-document HTML post-processors are registered; +- Fastly caller can preserve publisher streaming state out-of-band; +- origin fetch uses `send`, not `send_async`. + +When all gates pass, build the origin request with: + +```rust +PlatformHttpRequest::new(req, backend_name).with_stream_response() +``` + +Tests should assert `StubHttpClient::recorded_stream_response_flags()` for +eligible and ineligible requests. If needed, extend `StubHttpClient` so a queued +response can return `EdgeBody::Stream(_)` when `stream_response` is true. + +### 3.2 Refine response-level route decisions + +The existing `classify_response_route` treats processable non-HTML content as +`Stream`. For this issue, true Fastly SSAT streaming is only for SSAT HTML. + +Add either a new classifier or extend route context: + +```rust +pub(crate) enum PublisherSendRoute { + ProcessedHtmlStream, + StreamUnmodified, + BufferedProcessed, + BufferedUnmodified, + PassThrough, + Bodiless, +} +``` + +Response-level stream eligibility for Fastly: + +- request/response can carry a body (`GET`, not `HEAD`; not `204`, `205`, `304`); +- response is HTML and SSAT assembly is needed; +- content encoding is identity/gzip/deflate/br; +- no post-processors; +- identity `Content-Length` preflight passes; +- processor construction succeeds before client commit. + +If a streaming candidate resolves to non-HTML or unsupported encoding: + +- stream the origin body unmodified when safe; +- abandon any dispatched auction with telemetry; +- preserve bodyless semantics for `HEAD`, `204`, `205`, and `304`. + +If the response needs buffered mode and that requirement was known before fetch +(post-processors), it should never have requested `with_stream_response()`. + +### 3.3 Preserve publisher-streaming state across the Fastly boundary + +Do not put the SSAT streaming state into `http::Extensions`: `DispatchedAuction` +can carry adapter-specific pending request handles and should be treated as +`!Send` / not extension-safe. + +Add a Fastly-specific out-of-band outcome, likely in a new file +`crates/trusted-server-adapter-fastly/src/publisher_streaming.rs`: + +```rust +pub(crate) enum FastlyPublisherFallbackOutcome { + Response(HttpResponse), + Stream(FastlyPublisherStream), +} + +pub(crate) struct FastlyPublisherStream { + response: HttpResponse, + body: EdgeBody, + params: Box, + method: Method, + ec_state: Option, + request_filter_effects: Option, + services: RuntimeServices, + settings: Arc, + registry: IntegrationRegistry, + orchestrator: Arc, +} +``` + +The exact ownership can vary, but the stream outcome must carry everything the +send path needs after headers are finalized: + +- response skeleton; +- origin stream; +- `OwnedProcessResponseParams`; +- method/status for no-body checks; +- settings and registry; +- orchestrator and runtime services; +- EC/request-filter/finalization state. + +### 3.4 Split Fastly publisher fallback dispatch from generic EdgeZero response dispatch + +The existing router returns only `HttpResponse`, which forces buffering. Add a +Fastly-specific publisher fallback dispatcher that can return +`FastlyPublisherFallbackOutcome` before generic `send_edgezero_response` loses +state. + +Recommended approach: + +1. Extract shared publisher fallback setup from `dispatch_fallback` into helper + functions where practical, without refactoring unrelated named routes. +2. In `edgezero_main`, after request conversion and app state construction, + detect the fallback publisher route that is eligible for the Fastly SSAT path. +3. For that path, call the new Fastly publisher dispatcher directly. +4. For all other paths, continue using `app.router().oneshot(core_req)` and + `send_edgezero_response` unchanged. + +The Fastly publisher dispatcher must preserve middleware ordering: + +1. forwarded-header sanitization already happened on the original Fastly request; +2. EC request-state setup; +3. pre-route request filters; +4. asset/named/integration routes must still bypass this path; +5. `handle_publisher_request_with_options(... allow_origin_streaming = true ...)`; +6. buffered fallback for non-stream outcomes; +7. attach or carry EC finalize state and request-filter effects; +8. entry-point finalize headers; +9. EC finalization; +10. request-filter response effects; +11. final set-cookie cache privacy guard. + +If this direct path would duplicate too much router logic, an acceptable +alternative is to make `execute_fallback` return an adapter-private enum and keep +the router for all ordinary responses. The key invariant is that the publisher +stream state reaches Fastly `main.rs` without becoming a plain `EdgeBody::Stream`. + +### 3.5 Add Fastly publisher stream send path + +In `crates/trusted-server-adapter-fastly/src/main.rs`: + +- Add `send_fastly_publisher_stream(FastlyPublisherStream)`. +- Apply request-filter effects to headers before commit. +- Reapply final set-cookie privacy guard before commit. +- Strip transformed payload headers before commit. +- Convert response headers to a Fastly skeleton. +- Call `stream_to_client()`. +- Drive `stream_publisher_body_async(...)` into the Fastly `StreamingBody`. +- Call `finish()` only on success. +- On mid-stream error, log and drop the `StreamingBody` without buffered fallback. +- Run pull-sync-after-send behavior equivalent to the existing EC path once the + response has been sent or attempted. + +Acceptance tests: + +- Fastly SSAT HTML takes the publisher stream path, not asset `stream_asset_body`; +- headers are finalized before streaming starts; +- `Content-Length` and stale validators are absent for processed streaming HTML; +- non-HTML/unsupported-encoding candidate responses stream unmodified and + abandon auction; +- `HEAD`, `204`, `205`, and `304` do not attach processed streaming bodies. + +## Phase 4: Buffered-mode guards, documentation, and adapter parity + +### 4.1 Route post-processors to buffered mode + +Use `IntegrationRegistry::has_html_post_processors()` in the request-level gate +before origin fetch. If true: + +- do not call `with_stream_response()`; +- keep using `buffer_publisher_response_async`; +- run parser-safe late binding before post-processors; +- ensure no placeholder is visible to post-processors or clients. + +Implementation detail: + +- Configure `lol_html` with placeholder mode and post-processing disabled. +- Run late binding into the bounded buffer. +- Then run registered post-processors over the full final HTML. + +This may require extracting post-processing from `HtmlWithPostProcessing` into a +reusable helper, while keeping existing public behavior unchanged for other +callers. + +### 4.2 Keep non-Fastly adapters explicitly buffered + +Update comments and tests in: + +- `crates/trusted-server-adapter-axum/src/app.rs` +- `crates/trusted-server-adapter-cloudflare/src/app.rs` +- `crates/trusted-server-adapter-spin/src/app.rs` + +Expected behavior: + +- they call the default non-streaming publisher handler; +- they call `buffer_publisher_response_async`; +- they benefit from parser-safe late binding and EOF fallback; +- they enforce the existing buffered cap; +- they are not described as true streaming. + +### 4.3 Preserve privacy/cache behavior + +Regression tests should prove SSAT HTML still gets: + +```http +Cache-Control: private, max-age=0 +``` + +and strips shared/runtime edge-cache headers: + +- `Surrogate-Control` +- `Fastly-Surrogate-Control` +- `CDN-Cache-Control` +- `Cloudflare-CDN-Cache-Control` + +The final set-cookie cache privacy guard in Fastly must still run after EC +finalization and request-filter effects. + +### 4.4 Update docs + +Candidate docs to update after implementation behavior exists: + +- Fastly runtime / architecture docs: Fastly SSAT publisher HTML is true + streaming only on the guarded path. +- Adapter docs: Axum, Cloudflare, and Spin publisher SSAT are buffered for this + slice. +- Next.js integration docs: full-document post-processing remains buffered. + +Run docs formatting if docs are edited: + +```bash +cd docs && npm run format +``` + +## Phase 5: Verification plan + +Run focused validation after each phase, then the full target-matched gate before +handoff. + +Minimum Rust verification: + +```bash +cargo fmt --all -- --check +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 +``` + +If JS/TS is touched unexpectedly: + +```bash +cd crates/trusted-server-js/lib && npx vitest run +cd crates/trusted-server-js/lib && npm run format +``` + +If docs are touched: + +```bash +cd docs && npm run format +``` + +Do **not** use bare `cargo test --workspace`; it compiles Fastly for the wrong +target in this workspace. + +## Acceptance criteria checklist + +- [ ] Fastly request-level SSAT candidates request origin streaming with + `with_stream_response()`. +- [ ] Fastly processed SSAT HTML writes to `fastly::StreamingBody` without first + materializing the origin body or final assembled body. +- [ ] Publisher SSAT streaming state is not mistaken for asset pass-through + `EdgeBody::Stream(_)`. +- [ ] ``. +- [ ] Missing `` appends bids or minimal SSAT fallback tail at EOF. +- [ ] gzip, deflate, and brotli stream through decode/rewrite/re-encode. +- [ ] Decoded-input, processed-output, and held-tail caps are enforced without a + full-document allocation. +- [ ] Full-document post-processors route to buffered mode and never see a raw + placeholder. +- [ ] Axum, Cloudflare, and Spin remain documented/tested buffered mode. +- [ ] SSAT HTML retains `Cache-Control: private, max-age=0` and no shared edge + cache headers. +- [ ] Processed HTML strips stale payload validators/range metadata; unmodified + pass-through preserves them. +- [ ] Every dispatched auction is collected or abandoned on all exit paths. + +## Risks and open questions + +- **Fastly fallback parity risk:** a direct Fastly publisher streaming dispatcher + can accidentally bypass auth, request filters, EC finalization, pull sync, or + cache privacy guards. Keep the direct path narrow and share helpers with the + existing fallback path where possible. +- **Streaming decoder complexity:** current compression helpers are `Read`/`Write` + based. The true stream path needs chunk-driven processing without + `into_bytes()`. Prefer a small publisher-specific pump and extensive tests over + broad refactors to `StreamingPipeline`. +- **Post-processor ordering:** Next.js/full-document post-processing must see + final HTML with bids, not placeholders. This likely requires extracting + post-processing into an explicit buffered step. +- **Mid-stream failures:** once Fastly commits headers, cap/decode/processor/write + errors can only truncate the response. Tests and logs should reflect that; do + not attempt buffered fallback after commit. +- **Type ownership:** avoid storing `DispatchedAuction` or other adapter-specific + pending handles in `http::Extensions`; use an adapter-private outcome that can + carry non-`Send` state directly. +- **Scope control:** do not make Next.js post-processing streaming-safe and do not + add true streaming for Axum/Cloudflare/Spin in this issue. diff --git a/docs/superpowers/specs/2026-07-08-ssat-publisher-streaming-parser-safe-design.md b/docs/superpowers/specs/2026-07-08-ssat-publisher-streaming-parser-safe-design.md new file mode 100644 index 000000000..0744909f5 --- /dev/null +++ b/docs/superpowers/specs/2026-07-08-ssat-publisher-streaming-parser-safe-design.md @@ -0,0 +1,823 @@ +# Make SSAT publisher HTML assembly truly streaming and parser-safe + +**Issue:** #857 +**Status:** Draft +**Area:** Trusted Server runtime / publisher fallback / server-side ad stack + +## Summary + +Trusted Server's server-side ad stack (SSAT) publisher HTML path should be a +true streaming assembly path on Fastly: origin HTML bytes should flow through +Trusted Server and reach the browser before the origin response is fully read, +while bid injection remains parser-safe and privacy-preserving. + +Today the code has a streaming-shaped publisher API, but the Fastly origin +response can still be materialized into a single WASM-heap allocation before +client bytes flow, and the EdgeZero publisher fallback buffers the assembled +publisher response before sending it. The current SSAT bid hold also scans raw +origin bytes for `` when one is + present. +- If no parser-confirmed body close exists, append the SSAT fallback tail at EOF + as a best-effort fallback. +- Streaming mode supports gzip, deflate, and brotli origin HTML by decoding, + rewriting, and re-encoding incrementally. +- Streaming mode enforces cumulative decoded-input and processed-output caps + without full-document allocation. +- Full-document HTML post-processors, including the current Next.js + post-processor, are explicitly routed through buffered mode for this slice. +- Axum, Cloudflare, and Spin publisher SSAT behavior is documented and tested as + buffered mode for this slice. +- Existing SSAT privacy behavior remains unchanged: + `Cache-Control: private, max-age=0` and no shared/runtime edge-cache headers + on assembled per-user HTML. +- Processed HTML responses do not retain stale payload validators or range + metadata from the origin representation. + +## Non-goals + +- Origin-template caching. +- Transformed-template caching. +- Dynamic HTML/RSC/API cache-key design. +- SSAT compression offload via `Accept-Encoding: identity` / `X-Compress-Hint`. +- Akamai-specific cache behavior. +- Auction backend timeout/name fixes. +- Making Next.js RSC post-processing streaming-safe in this slice. +- Making Axum, Cloudflare, or Spin publisher SSAT truly streaming in this slice. + +## Current behavior + +### Publisher response shape + +`crates/trusted-server-core/src/publisher.rs` exposes: + +- `PublisherResponse::Buffered` +- `PublisherResponse::Stream` +- `PublisherResponse::PassThrough` + +`PublisherResponse::Stream` currently means "processable body with headers +separated from the body", not necessarily client-visible streaming. Its docs +already note that, on the interim path, the body may have been materialized in +WASM heap upstream. + +Also, the current Fastly send boundary treats a plain `EdgeBody::Stream(_)` as +an asset pass-through stream. It does not carry the publisher-specific state +needed to run the SSAT HTML assembly loop, collect/abandon an auction, or apply +parser-safe bid late binding. True publisher streaming therefore needs an +explicit publisher-streaming envelope or equivalent dispatch path, not just a +processed response whose body happens to be `EdgeBody::Stream(_)`. + +### Fastly origin body materialization + +`handle_publisher_request` sends the origin request with: + +```rust +services + .http_client() + .send(PlatformHttpRequest::new(req, backend_name)) + .await +``` + +On Fastly, `PlatformHttpRequest::stream_response` defaults to `false`, so +`crates/trusted-server-adapter-fastly/src/platform.rs` converts the +`fastly::Response` body through `take_body_bytes()` before returning the +platform-neutral response. That can allocate the full origin response body before +any client bytes are sent. + +### EdgeZero publisher fallback buffering + +`crates/trusted-server-adapter-fastly/src/app.rs` currently resolves publisher +fallback responses with `buffer_publisher_response_async`, producing a single +`Body::Once` response. `crates/trusted-server-adapter-fastly/src/main.rs` only +streams `EdgeBody::Stream(_)` bodies directly to Fastly's `StreamingBody`; SSAT +publisher HTML is therefore sent as a completed buffered response. + +### Raw close-body hold + +`stream_publisher_body_async` uses `BodyCloseHoldBuffer`, which searches decoded +origin bytes for the case-insensitive raw prefix ` + const marker = '' + +``` + +The actual injection is parser-aware because it happens in a `lol_html` body +end-tag handler, but the decision about when to stop streaming and collect the +auction is not parser-aware. + +### Full-document HTML post-processors + +`crates/trusted-server-core/src/html_processor.rs` wraps the HTML rewriter in +`HtmlWithPostProcessing`. When any `IntegrationHtmlPostProcessor` is registered, +it accumulates the rewritten document and runs post-processors only at EOF. The +current Next.js integration registers such a post-processor. + +This is intentionally not stream-safe today and must be treated as buffered mode +for this issue. + +## Locked decisions + +1. **Missing `` fallback:** append the SSAT fallback tail at EOF when no + parser-confirmed body close is available. +2. **Post-processors:** full-document HTML post-processors are an acceptable + buffered-mode tradeoff for this slice. Next.js streaming-safe post-processing + is deferred. +3. **Compression:** true streaming should support gzip, deflate, and brotli by + incrementally decoding, rewriting, and re-encoding. +4. **Adapter scope:** Fastly is the first true-streaming target. Axum, + Cloudflare, and Spin are documented/tested as buffered mode. +5. **Caps:** streaming mode enforces cumulative decoded-input and + processed-output caps, plus a small held-tail cap. + +## Architecture + +### High-level Fastly SSAT streaming flow + +```text +Client request + -> Fastly entry point + -> publisher fallback route + -> dispatch SSAT auction requests + -> fetch publisher origin with streaming response body for request-level SSAT candidates + -> inspect origin response metadata and choose the response-level route + -> if stream-eligible, finalize response headers before client commit + -> Fastly response.stream_to_client() + -> incremental origin body assembly: + encoded origin bytes + -> decoder, if Content-Encoding is gzip/deflate/br + -> cumulative decoded-input cap + -> lol_html processor + -> parser-inserted bid placeholder detection + -> auction collect at placeholder, or EOF fallback + -> cumulative processed-output cap + -> encoder, if response remains compressed + -> Fastly StreamingBody + -> finish StreamingBody +``` + +### Buffered-mode flow + +Buffered mode remains the compatibility path for: + +- non-Fastly adapters in this slice; +- Fastly SSAT HTML when full-document HTML post-processors are registered; +- request shapes that are known before fetch not to be safe streaming candidates; +- unsupported response shapes where the stream cannot safely commit headers; +- existing unmodified buffered routes. + +Buffered mode may still use the same parser-safe placeholder late-binding logic, +but its output sink is a bounded in-memory writer rather than Fastly's +`StreamingBody`. + +## Parser-safe bid late binding + +### Problem with raw-byte scanning + +Raw ``; +- JSON data inside a script block; +- text in a comment; +- attribute content; +- malformed markup that `lol_html` does not interpret as a body end tag. + +Therefore raw scanning is not a safe trigger for auction collection or bid-tail +holding. + +### Proposed mechanism + +Use `lol_html` as the only authority for detecting the real body end tag. + +1. For SSAT requests, configure the HTML processor with a per-request opaque bid + placeholder token instead of directly injecting bids from shared state in the + `body` end-tag handler. +2. The body end-tag handler inserts that placeholder immediately before the real + parser-confirmed ``. +3. The publisher streaming loop scans **processed uncompressed output**, not raw + origin input, for the opaque placeholder. +4. Until the placeholder is found, processed output streams through immediately, + subject only to the placeholder scanner's overlap buffer. +5. When the placeholder is found: + - emit bytes before the placeholder; + - stop reading additional origin bytes after the current decoded/processed + chunk has been handled; + - hold the placeholder plus any suffix from the current processed chunk; + - collect the dispatched auction; + - build the bids script; + - replace the placeholder with the bids script; + - emit the held suffix; + - resume reading and streaming origin bytes. +6. At EOF, if no placeholder was found: + - collect the dispatched auction if it has not already been collected; + - finalize the HTML processor; + - if final processor output contains the placeholder, replace it normally; + - otherwise append the SSAT fallback tail at EOF. + +Pausing origin reads while the auction is collected is intentional. The +implementation should rely on the runtime's normal upstream backpressure rather +than draining the rest of the origin response into memory. The wait remains +bounded by the existing auction collection timeout/deadline; if collection fails +or times out, replace the placeholder with the empty/current bids script and +continue streaming. + +The EOF fallback covers both documents that have a `` without a parsed end +tag and malformed documents that never expose a body end tag. If the normal +`` injection has already run, the fallback tail may be just the bids +script. If no head injection ran, the fallback tail must include the minimal SSAT +bootstrap in executable order — ad slot state, integration head config required +by the TSJS bundle, the TSJS script tag(s), and then the bids script. This is a +best-effort malformed-document path; it must still be bounded by the processed +output cap and must not leak placeholders. + +The placeholder should be a per-request high-entropy token, such as an HTML +comment containing a UUID, for example: + +```html + +``` + +The exact format is an implementation detail, but it must be: + +- generated per request; +- impossible for normal origin content to predict; +- valid HTML when inserted by `lol_html`; +- scanned only in processed uncompressed output; +- fully removed from the client response on success. + +### Placeholder scanner requirements + +The placeholder scanner must be streaming-safe: + +- handle the placeholder split across processor output chunks; +- emit all bytes before the placeholder as soon as safely possible; +- keep at most `placeholder.len() - 1` bytes of overlap while searching; +- once the placeholder is found, hold only the placeholder and suffix bytes until + auction collection completes; +- enforce a held-tail cap so the hold cannot become accidental full-document + buffering; +- after the first successful replacement, strip any later occurrence of the same + placeholder token from processed output rather than forwarding it to the + client. + +### Bid source behavior + +If an auction was dispatched, the first parser-confirmed placeholder triggers +auction collection. The resulting winning bids are written through the existing +bid-script builder path. + +If no auction was dispatched but the SSAT ad stack still needs to inject slot +state and an empty bids script, the late-binding layer should replace the +placeholder immediately with the empty/current bids script without awaiting an +auction. + +If multiple body end tags exist, only the first placeholder is replaced with +bids. The preferred implementation is for the `lol_html` body end-tag handler to +insert at most one placeholder, using the existing once-only guard pattern. The +late-binding scanner must still defensively remove any later placeholders, if +any, so no raw placeholder leaks to the client and bids are not injected multiple +times. + +## Compression design + +Streaming SSAT HTML must support the currently supported origin content +encodings: + +- identity / no `Content-Encoding`; +- `gzip`; +- `deflate`; +- `br`. + +The streaming path should preserve the existing response encoding unless a +separate route explicitly strips it. Compression offload and forcing identity +origin fetches belong to #858 and are out of scope here. + +### Pipeline placement + +Parser-safe placeholder detection must occur after HTML rewriting and before +recompression: + +```text +encoded origin chunk + -> decoder + -> decoded HTML bytes + -> lol_html processor + -> processed uncompressed bytes containing parser placeholder + -> placeholder late binder / bid insertion + -> encoder + -> client writer +``` + +Scanning compressed output would be incorrect because the placeholder would not +be visible. Scanning raw decoded input would reintroduce the original parser +context bug. + +The true-streaming implementation must drive this pipeline from origin stream +chunks. It must not convert the publisher body through `EdgeBody::into_bytes()`, +`take_body_bytes()`, or any equivalent full-body materialization before decoding +and rewriting. Buffered adapters may continue to use the bounded in-memory path. + +### Decoder/encoder finalization + +The existing synchronous pipeline already explicitly finalizes gzip and deflate +encoders with `finish()` and uses explicit brotli flush/finalization behavior. +The new SSAT streaming loop must preserve equivalent finalization semantics: + +- gzip: call `finish()` and propagate errors; +- deflate: call `finish()` and propagate errors; +- brotli: explicitly flush/finalize through the available brotli writer API and + propagate write errors where the API exposes them. + +If compression finalization fails after headers have been committed, log the +error and abort the streaming body. + +## Streaming caps + +True streaming removes the full-document allocation, but it must still protect +WASM memory and CPU from unbounded input/output growth. + +### Cap types + +Use the existing publisher body limit initially: + +```text +settings.publisher.max_buffered_body_bytes +``` + +Despite the current name, it should be applied to streaming SSAT as the +cumulative safety limit until/unless a separate streaming-specific setting is +introduced. + +Enforce: + +1. **Decoded input cap** + - Count bytes after decompression and before HTML parsing. + - Protects against compressed expansion bombs and excessive parser workload. +2. **Processed uncompressed output cap** + - Count bytes emitted by the HTML processor and late binder before + recompression. + - Protects against output amplification from rewrites and injected scripts. +3. **Held-tail cap** + - Count bytes retained after the parser placeholder is found and before the + auction is collected. + - Use a concrete default so tests and operators can reason about behavior: + `SSAT_HELD_TAIL_CAP_BYTES = 64 * 1024` (8× the current 8 KiB stream chunk), + with the effective cap clamped to `settings.publisher.max_buffered_body_bytes` + when that setting is smaller. + - If exceeded, treat it as a streaming safety violation. + +### Pre-commit rejection vs mid-stream abort + +When headers have not yet been committed, known oversized responses should fail +cleanly with an error response. For example, an identity response with +`Content-Length` greater than the configured cap can be rejected before +`stream_to_client()`. Compressed `Content-Length` is not a decoded-size signal and +must not be used as proof that the decoded input is under the cap; compressed +responses are checked by the cumulative decoded-input counter while streaming. + +For compressed, chunked, or unknown-size responses, the true size may only be +known after streaming begins. If a cumulative cap is exceeded after headers are +committed: + +- log the violation with enough context for diagnosis; +- abort/drop the streaming body; +- do not attempt to recover with a buffered fallback because the client-visible + response has already started. + +This matches standard reverse-proxy behavior for mid-stream processing failures. + +## Fastly adapter design + +### Origin fetch + +Fastly has to make the platform request before it knows the origin response's +`Content-Type`, status, or `Content-Encoding`, so the route decision is +intentionally two-phase: + +1. **Request-level candidate decision, before origin fetch.** Use a streaming + platform response only when request metadata and configuration make true SSAT + streaming possible: the method can carry a response body (`GET`, not `HEAD`), + the server-side ad stack may run for this navigation, no full-document HTML + post-processors are registered, and the Fastly send boundary can preserve the + publisher-streaming state through response finalization. +2. **Response-level route decision, after origin headers arrive and before + client commit.** Inspect status, `Content-Type`, `Content-Encoding`, and + relevant headers to choose processed streaming, streamed unmodified + pass-through, bodiless response handling, or a pre-commit error. + +Request-level Fastly candidates should use the existing platform flag shape: + +```rust +PlatformHttpRequest::new(req, backend_name).with_stream_response() +``` + +On Fastly, this preserves the origin response body as `EdgeBody::Stream(_)` +instead of using `take_body_bytes()`. + +The platform-level body materialization cap still applies to non-streaming +requests. Streaming SSAT applies its own cumulative decoded/processed caps in +core. Because a candidate request may later prove not to be processable SSAT +HTML, the Fastly path must be able to forward a preserved origin stream +unmodified without first buffering it. If a request is known before fetch to +require buffered mode, such as post-processor-enabled HTML, do not request a +streaming origin body. + +### Client commit + +For true-streaming Fastly SSAT responses: + +1. Build and mutate response headers in core as today. +2. Preserve the publisher streaming state across the fallback/entry-point + boundary. Do not encode a processed publisher stream as a plain + `EdgeBody::Stream(_)` unless the send path can distinguish it from the asset + pass-through stream case. A dedicated `PublisherResponse` variant, + response extension, or Fastly-specific fallback result is acceptable as long + as it carries the response skeleton, origin stream, processing params, + orchestrator/services access, and auction telemetry token. +3. Apply all finalization that must happen before client commit, including: + - EC/privacy finalization; + - request-filter response effects; + - final cache/privacy guards; + - transformed-response header normalization; + - removal of `Content-Length` for processed streaming bodies. +4. Convert the response headers to a Fastly skeleton response. +5. Call `stream_to_client()`. +6. Run the SSAT streaming body assembly loop, writing to the Fastly + `StreamingBody`. +7. Call `finish()` on success. +8. On any mid-stream error, log and drop the streaming body. + +Once `stream_to_client()` is called, no response header mutation is possible. +The Fastly entry point therefore owns the boundary between final response header +mutation and body streaming. + +### Route decision + +A Fastly publisher response is response-level stream-eligible when all of the +following are true: + +- the request/response can carry a body (`GET`, not `HEAD`, not `204`, `205`, or + `304`); +- the response is HTML that needs SSAT assembly, not merely a text/JS/CSS/JSON + response that the generic publisher processor could rewrite; +- the content encoding is supported (`identity`, `gzip`, `deflate`, `br`); +- no full-document HTML post-processors are registered for the active + integration registry; +- pre-commit header checks, including identity `Content-Length` preflight, have + not rejected the response; +- response headers can be finalized before commit; +- processor construction succeeds before commit. + +If the request was fetched as a streaming candidate but the response is not +processable SSAT HTML, Fastly should stream the origin body unmodified when that +is safe, abandon any dispatched auction with telemetry, and preserve bodyless +status semantics by not attaching a body for `HEAD`, `204`, `205`, or `304`. +If the response requires buffered mode and that requirement was known before the +origin fetch, the request should not have used `with_stream_response()`. + +## Non-Fastly adapters + +For this issue, Axum, Cloudflare, and Spin remain buffered for publisher SSAT +HTML. Their behavior must be explicit in docs and tests: + +- they may still call `buffer_publisher_response_async`; +- they must preserve parser-safe bid injection semantics; +- they must enforce the existing buffered body cap; +- they should remove or normalize headers such as `Transfer-Encoding` where the + adapter already does so after buffering; +- they do not satisfy the Fastly true-streaming acceptance criterion and should + not be described as true streaming in docs or comments. + +Future work can add adapter-specific streaming support once each runtime has a +safe response-commit and body-streaming boundary. + +## HTML post-processors + +Any registered `IntegrationHtmlPostProcessor` means the HTML path requires the +full rewritten document. For this slice: + +- Fastly SSAT HTML with post-processors must route to buffered mode. +- Next.js is explicitly accepted as buffered mode. +- The request-level Fastly route decision should use + `IntegrationRegistry::has_html_post_processors()` or an equivalent presence + check before requesting a streaming origin body. +- Tests should verify that a registry with a post-processor does not enter the + true Fastly streaming path and does not call `with_stream_response()` for the + publisher origin fetch. + +Buffered post-processor mode must preserve the current observable ordering: +post-processors should see the rewritten HTML with the final bids script, not a +raw placeholder. The bounded buffered pipeline should therefore be: + +```text +decode origin body + -> lol_html rewrite with parser placeholder + -> parser-safe late binding / bids replacement or EOF fallback + -> full-document post-processors + -> encode or buffer final body +``` + +No placeholder token may be visible to post-processors unless the post-processor +API explicitly opts into that in future work, and no placeholder may leak to the +client. + +This does not prevent script rewriters or attribute rewriters from streaming; +those run inside `lol_html` and are distinct from full-document post-processors. + +## Privacy and cache behavior + +SSAT-assembled HTML can contain per-user slot state and bid data. This issue +must preserve the existing privacy contract: + +```http +Cache-Control: private, max-age=0 +``` + +and strip runtime/shared edge-cache headers, including: + +```http +Surrogate-Control +Fastly-Surrogate-Control +CDN-Cache-Control +Cloudflare-CDN-Cache-Control +``` + +This applies before Fastly commits the streaming response. Streaming must not +weaken the final cache/privacy guard that protects responses with `Set-Cookie` +or per-user SSAT data. + +### Transformed-response header normalization + +Any processed HTML response has a different byte representation from the origin +response, even when `Content-Encoding` is preserved through decode/re-encode. +Before client commit, the streaming and buffered processed paths should remove +payload-derived headers unless they are recomputed for the transformed payload. +At minimum, processed publisher HTML should remove: + +```http +Content-Length +Content-MD5 +Digest +Repr-Digest +Content-Range +Accept-Ranges +ETag +``` + +`Content-Encoding` should be preserved when the response is re-encoded with the +same encoding. `Transfer-Encoding` remains adapter-owned and should continue to +be removed or normalized where the adapter already does so. Unmodified +pass-through streams keep origin payload validators and range metadata. + +## Error handling + +### Before client commit + +Errors before `stream_to_client()` should return a normal error response where +possible: + +- invalid origin configuration; +- backend registration failure; +- origin fetch failure before headers; +- unsupported route discovered before commit that cannot be safely streamed + unmodified; +- processor construction failure; +- known oversized identity body from `Content-Length` preflight. + +A streaming candidate that resolves to non-HTML, unsupported-encoding HTML, or a +bodiless status is not automatically an error: it may be streamed or returned +unmodified when headers can still be finalized safely. If an SSAT auction was +dispatched and the response cannot be processed, consume or abandon the dispatch +token explicitly so telemetry remains accurate. + +### After client commit + +Errors after headers are committed cannot be converted into a clean HTTP error +response. The streaming path should: + +- log the error; +- emit abandonment/completion telemetry where applicable; +- drop/abort the streaming body without calling `finish()`; +- let the client observe a truncated response. + +Mid-stream errors include: + +- origin stream read failure; +- decompression failure; +- HTML processor failure; +- placeholder hold cap exceeded; +- decoded-input or processed-output cap exceeded; +- write failure to the client streaming body; +- compression finalization failure. + +## Observability + +Add low-cardinality logs or telemetry fields sufficient to understand streaming +behavior without exposing user data: + +- route mode: `fastly_streaming`, `streamed_unmodified_non_html`, + `streamed_unmodified_unsupported_encoding`, `buffered_post_processor`, + `buffered_adapter`, `pass_through`, `buffered_unmodified`; +- request-level candidate decision and response-level route decision; +- whether bid insertion used `body_close`, `eof_fallback`, or + `missing_head_eof_fallback`; +- decoded input bytes; +- processed output bytes; +- held-tail bytes; +- whether the response was compressed and which encoding was used; +- auction collect wait duration at the body-close hold; +- cap violation reason, if any. + +Do not log bid payloads, EC IDs, cookies, consent strings, or full URLs with +sensitive query parameters. + +## Testing strategy + +### Core parser-safety tests + +- Inline script contains `""`; auction collection is not triggered until + the real parser-confirmed body close. +- JSON/script data contains escaped or literal `` split across origin chunks still produces a parser placeholder + and bid injection before the close tag. +- Placeholder token split across processor output chunks is detected and + replaced exactly once. +- Multiple body close tags do not inject bids multiple times and do not leak + placeholders. +- Missing `` appends bids or the SSAT fallback tail at EOF. +- Missing `` plus missing `` appends the minimal SSAT fallback tail + at EOF without leaking placeholders. +- Normal bid injection still places bids before `` when the close tag is + present. + +### Streaming cap tests + +- Large identity HTML below the cap streams successfully. +- Decoded input exceeding the cap fails/aborts without allocating the full body. +- Processed output exceeding the cap fails/aborts. +- Highly compressible gzip/deflate/br HTML that expands over the decoded cap is + rejected during streaming. +- Held-tail cap violation aborts rather than growing an unbounded hold buffer. +- A streaming-body test proves the true Fastly path consumes chunks incrementally + and does not call `EdgeBody::into_bytes()` or an equivalent full-body materializer. + +### Compression tests + +For gzip, deflate, and brotli: + +- compressed origin HTML streams through decode/rewrite/re-encode; +- decompressed output contains injected bids at the correct location; +- final compressed response can be decoded by a client; +- encoder finalization errors, where testable, are propagated as stream errors. + +### Fastly adapter tests + +- Fastly SSAT stream-eligible HTML preserves the origin body as + `EdgeBody::Stream(_)` before client send. +- Fastly SSAT stream-eligible HTML does not call the buffered publisher response + resolver. +- Fastly streaming uses an explicit publisher-streaming dispatch path and is not + mistaken for an asset pass-through `EdgeBody::Stream(_)`. +- Fastly streaming removes `Content-Length` and other payload-derived headers + for processed bodies. +- Fastly response headers are finalized before the streaming body is opened. +- Fastly post-processor-enabled HTML routes to buffered mode and does not request + `with_stream_response()` from the publisher origin. +- Fastly streaming candidates that resolve to non-HTML or unsupported-encoding + HTML stream unmodified safely and abandon any dispatched auction. +- Fastly `HEAD`, 204, 205, and 304 responses do not attach a streaming processed + body. + +### Non-Fastly adapter tests + +- Axum publisher SSAT is explicitly buffered in this slice. +- Cloudflare publisher SSAT is explicitly buffered in this slice. +- Spin publisher SSAT is explicitly buffered in this slice. +- Buffered non-Fastly paths preserve parser-safe bid injection and EOF fallback. +- Buffered post-processor paths run parser-safe late binding before + post-processors and never expose placeholders to post-processors or clients. + +### Privacy regression tests + +- SSAT HTML still emits `Cache-Control: private, max-age=0`. +- Shared/runtime cache headers are stripped from SSAT HTML. +- Streaming Fastly SSAT responses with cookies or per-user data do not regain + shared cacheability after finalization. +- Processed streaming HTML strips stale entity validators/range headers while + unmodified pass-through streams preserve them. + +## Implementation phases + +### Phase 1: Parser-safe late-binding in core + +- Add per-request bid placeholder support to the HTML processor configuration. +- Add a streaming placeholder late-binder that scans processed uncompressed + output and replaces the parser-inserted placeholder with bids. +- Replace raw `BodyCloseHoldBuffer` usage for SSAT collection with + placeholder-triggered collection. +- Add EOF fallback bid append, including the missing-head minimal SSAT fallback + tail when normal head injection never ran. +- Keep existing buffered adapters working through the new parser-safe path. +- Ensure buffered post-processor mode performs late binding before post-processing + so post-processors see final HTML rather than raw placeholders. + +### Phase 2: Streaming caps + +- Add decoded-input and processed-output cumulative counters to the SSAT + streaming loop. +- Add the concrete 64 KiB held-tail cap, clamped by the publisher body limit. +- Map cap violations to the existing proxy error type. +- Add tests for identity and compressed expansion cases. + +### Phase 3: Fastly origin streaming and client streaming + +- Add the two-phase Fastly route decision: request-level streaming candidates + before fetch and response-level stream eligibility after origin headers. +- Request streaming origin responses only for Fastly request-level SSAT + candidates. +- Preserve `EdgeBody::Stream(_)` through an explicit publisher-streaming dispatch + path rather than the asset pass-through stream path. +- Add a Fastly entry-point path that finalizes headers, opens + `stream_to_client()`, and drives the SSAT streaming loop into the + `StreamingBody`. +- Stream non-HTML or unsupported-encoding candidate responses unmodified when + safe, with auction abandonment telemetry. +- Ensure dispatched auctions are collected or abandoned on every exit path. + +### Phase 4: Buffered-mode documentation and route guards + +- Route full-document post-processor HTML to buffered mode before requesting a + streaming origin response. +- Normalize transformed-response headers on both buffered and streaming + processed HTML paths. +- Document Next.js as buffered mode for this slice. +- Document Axum, Cloudflare, and Spin publisher SSAT as buffered mode. +- Add adapter tests so future changes do not accidentally claim streaming parity. + +### Phase 5: Verification + +Minimum targeted verification for touched Rust code: + +```bash +cargo fmt --all -- --check +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 +``` + +If implementation touches JS/TS, also run: + +```bash +cd crates/trusted-server-js/lib && npx vitest run +cd crates/trusted-server-js/lib && npm run format +``` + +## Acceptance criteria mapping + +| Issue acceptance criterion | Design coverage | +| --------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Fastly SSAT HTML path no longer requires full origin body materialization before sending client bytes. | Two-phase Fastly route decision uses streaming origin bodies for request-level candidates, preserves publisher-streaming state explicitly, avoids `into_bytes()`/`take_body_bytes()`, and writes to `StreamingBody`. | +| Streaming path enforces a cumulative body cap without requiring a single full-body allocation. | Decoded-input and processed-output cumulative caps plus concrete held-tail cap. | +| Body-close hold is parser-context-aware and does not trigger on `