Skip to content

Commit 9a64c17

Browse files
committed
Merge remote-tracking branch 'origin/main' into server-side-ad-templates-impl
2 parents 9c0c424 + a2b9e06 commit 9a64c17

7 files changed

Lines changed: 351 additions & 100 deletions

File tree

crates/trusted-server-adapter-fastly/src/app.rs

Lines changed: 100 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,10 @@
6565
//! run on these responses. Legacy ran EC finalization on its own auth
6666
//! challenges. Like the 401 geo-skip, this is privacy-conservative: no EC
6767
//! cookies are issued to unauthenticated callers.
68-
//! - **Streaming publisher responses** are buffered (bounded by
68+
//! - **Publisher responses** are buffered (bounded by
6969
//! `publisher.max_buffered_body_bytes`) instead of streamed to the client.
70+
//! Asset responses are streamed straight to the client (see
71+
//! [`dispatch_asset_fallback`]), matching legacy.
7072
//! - **Router-level 405s** (unregistered verbs) skip EC finalization along
7173
//! with the middleware chain; the entry point still adds TS headers.
7274
//!
@@ -110,12 +112,11 @@ use trusted_server_core::integrations::{
110112
use trusted_server_core::platform::{ClientInfo, GeoInfo, PlatformKvStore, RuntimeServices};
111113
use trusted_server_core::proxy::{
112114
handle_asset_proxy_request, handle_first_party_click, handle_first_party_proxy,
113-
handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, stream_asset_body,
114-
AssetProxyCachePolicy,
115+
handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, AssetProxyCachePolicy,
115116
};
116117
use trusted_server_core::publisher::{
117118
buffer_publisher_response_async, handle_page_bids, handle_publisher_request,
118-
handle_tsjs_dynamic, page_bids_preflight_denied, AuctionDispatch, BoundedWriter,
119+
handle_tsjs_dynamic, page_bids_preflight_denied, AuctionDispatch,
119120
};
120121
use trusted_server_core::request_signing::{
121122
handle_deactivate_key, handle_rotate_key, handle_trusted_server_discovery,
@@ -798,13 +799,13 @@ async fn dispatch_fallback(
798799
attach_dispatch_extensions(response, ec, effects)
799800
}
800801

801-
/// Returns `true` when an asset response should carry a buffered body and a
802-
/// recomputed `Content-Length`.
802+
/// Returns `true` when an asset response should carry a (streamed) body.
803803
///
804804
/// `HEAD` responses and bodiless statuses (204, 304) advertise the origin
805805
/// representation length in their `Content-Length` header while carrying no
806-
/// body. Rewriting that header to the buffered byte count (0) would corrupt the
807-
/// metadata, so those responses keep the origin's `Content-Length` untouched.
806+
/// body. Attaching the origin stream would either contradict that header or
807+
/// stream bytes a client does not expect, so those responses drop the stream and
808+
/// keep the origin's `Content-Length` untouched.
808809
fn asset_response_carries_body(method: &Method, status: StatusCode) -> bool {
809810
*method != Method::HEAD
810811
&& status != StatusCode::NO_CONTENT
@@ -820,13 +821,13 @@ fn asset_response_carries_body(method: &Method, status: StatusCode) -> bool {
820821
/// is intentionally skipped: no [`EcFinalizeState`] is attached, matching the
821822
/// legacy `should_finalize_ec = false` behavior for asset responses.
822823
///
823-
/// Unlike legacy `route_request`, which streams asset bodies straight to the
824-
/// client with no cap, the `EdgeZero` path buffers them: `edgezero_main`
825-
/// converts the whole response before sending, so there is no streaming seam
826-
/// yet. The buffer is bounded by `publisher.max_buffered_body_bytes` as an
827-
/// interim Wasm-heap OOM guard. Reusing the publisher cap and restoring
828-
/// uncapped streaming are both resolved by the streaming cutover (issue #495);
829-
/// whether assets get a dedicated cap is deferred to that work.
824+
/// Like legacy `route_request`, asset bodies are streamed straight to the client
825+
/// with no cap: the origin stream is attached to the response and `edgezero_main`
826+
/// commits the headers, then pipes the body chunk-by-chunk via
827+
/// [`fastly::Response::stream_to_client`] and
828+
/// [`stream_asset_body`](trusted_server_core::proxy::stream_asset_body). The
829+
/// origin stream is left untouched here, so large images never materialize in
830+
/// the Wasm heap.
830831
async fn dispatch_asset_fallback(
831832
state: &AppState,
832833
services: &RuntimeServices,
@@ -843,30 +844,13 @@ async fn dispatch_asset_fallback(
843844
let cache_policy = asset_response.cache_policy();
844845
let (mut response, stream_body) = asset_response.into_response_and_body();
845846

847+
// Attach the origin stream so `edgezero_main` streams it to the
848+
// client. HEAD and bodiless statuses (204, 304) advertise the origin
849+
// Content-Length but carry no body, so their stream is dropped to
850+
// preserve that header and avoid a length/body mismatch.
846851
if let Some(body) = stream_body {
847-
match buffer_asset_body(body, state.settings.publisher.max_buffered_body_bytes)
848-
.await
849-
{
850-
Ok(bytes) => {
851-
// Preserve the origin's Content-Length for HEAD and
852-
// bodiless statuses; only body-bearing responses get a
853-
// recomputed length and the buffered body attached.
854-
if asset_response_carries_body(&method, response.status()) {
855-
response.headers_mut().insert(
856-
header::CONTENT_LENGTH,
857-
HeaderValue::from(bytes.len() as u64),
858-
);
859-
*response.body_mut() = edgezero_core::body::Body::from(bytes);
860-
}
861-
}
862-
Err(report) => {
863-
let mut response = http_error(&report);
864-
response
865-
.extensions_mut()
866-
.insert(AssetProxyCachePolicy::NoStorePrivate);
867-
attach_request_filter_effects(&mut response, effects);
868-
return response;
869-
}
852+
if asset_response_carries_body(&method, response.status()) {
853+
*response.body_mut() = body;
870854
}
871855
}
872856

@@ -897,23 +881,6 @@ fn attach_request_filter_effects(response: &mut Response, effects: &RequestFilte
897881
}
898882
}
899883

900-
/// Buffers a streaming asset body into memory, bounded by `max_bytes`
901-
/// (the interim `publisher.max_buffered_body_bytes` OOM guard; see
902-
/// [`dispatch_asset_fallback`]).
903-
///
904-
/// # Errors
905-
///
906-
/// Returns an error if the body exceeds the configured cap or the underlying
907-
/// stream yields an error.
908-
async fn buffer_asset_body(
909-
body: edgezero_core::body::Body,
910-
max_bytes: usize,
911-
) -> Result<Vec<u8>, Report<TrustedServerError>> {
912-
let mut output = BoundedWriter::new(max_bytes);
913-
stream_asset_body(body, &mut output).await?;
914-
Ok(output.into_inner())
915-
}
916-
917884
// ---------------------------------------------------------------------------
918885
// Error helper
919886
// ---------------------------------------------------------------------------
@@ -1213,6 +1180,10 @@ mod tests {
12131180
build_state_from_settings, startup_error_router, AppState, NamedRouteHandler,
12141181
TrustedServerApp, NAMED_ROUTES,
12151182
};
1183+
use crate::route_tests::{
1184+
test_runtime_services_with_secret_http_client_and_geo, us_california_geo, FixedBackend,
1185+
FixedGeo, NoopSecretStore, StreamingRecordingHttpClient,
1186+
};
12161187

12171188
use edgezero_core::body::Body;
12181189
use edgezero_core::http::{header, request_builder, Method, StatusCode};
@@ -1230,7 +1201,7 @@ mod tests {
12301201
HeaderMutation, IntegrationRegistry, IntegrationRequestFilter, RequestFilterDecision,
12311202
RequestFilterEffects, RequestFilterInput,
12321203
};
1233-
use trusted_server_core::platform::ClientInfo;
1204+
use trusted_server_core::platform::{ClientInfo, PlatformHttpClient};
12341205
use trusted_server_core::settings::Settings;
12351206

12361207
fn settings_with_missing_consent_store() -> Settings {
@@ -2108,6 +2079,79 @@ mod tests {
21082079
);
21092080
}
21102081

2082+
#[test]
2083+
fn dispatch_asset_fallback_streams_origin_body_without_buffering() {
2084+
// Regression guard for the EdgeZero asset streaming cutover: a successful
2085+
// asset proxy must hand `edgezero_main` a streaming body (`Body::Stream`)
2086+
// rather than draining the origin into a buffered `Body::Once`. Injecting a
2087+
// streaming HTTP client lets us assert the body type the entry point will
2088+
// pipe straight to the client via `stream_to_client`.
2089+
let settings = Settings::from_toml(
2090+
r#"
2091+
[[handlers]]
2092+
path = "^/_ts/admin"
2093+
username = "admin"
2094+
password = "admin-pass"
2095+
2096+
[publisher]
2097+
domain = "test-publisher.com"
2098+
cookie_domain = ".test-publisher.com"
2099+
origin_url = "https://origin.test-publisher.com"
2100+
proxy_secret = "unit-test-proxy-secret"
2101+
2102+
[ec]
2103+
passphrase = "test-secret-key-32-bytes-minimum"
2104+
2105+
[request_signing]
2106+
enabled = false
2107+
config_store_id = "test-config-store-id"
2108+
secret_store_id = "test-secret-store-id"
2109+
2110+
[proxy]
2111+
2112+
[[proxy.asset_routes]]
2113+
prefix = "/.images/"
2114+
origin_url = "https://assets.example.com"
2115+
"#,
2116+
)
2117+
.expect("should parse asset-route settings");
2118+
let state = build_state_from_settings(settings).expect("should build state");
2119+
2120+
let fastly_req = fastly::Request::get("https://test-publisher.com/.images/logo.png");
2121+
let http_client = Arc::new(StreamingRecordingHttpClient::new());
2122+
let services = test_runtime_services_with_secret_http_client_and_geo(
2123+
&fastly_req,
2124+
Arc::new(FixedBackend),
2125+
Arc::new(NoopSecretStore),
2126+
Arc::clone(&http_client) as Arc<dyn PlatformHttpClient>,
2127+
Arc::new(FixedGeo(us_california_geo())),
2128+
);
2129+
let req = crate::compat::from_fastly_request(fastly_req);
2130+
let asset_route = state
2131+
.settings
2132+
.asset_route_for_path("/.images/logo.png")
2133+
.expect("should match the configured asset route");
2134+
let effects = RequestFilterEffects::default();
2135+
2136+
let response = block_on(super::dispatch_asset_fallback(
2137+
&state,
2138+
&services,
2139+
req,
2140+
asset_route,
2141+
&effects,
2142+
));
2143+
2144+
assert_eq!(
2145+
response.status(),
2146+
StatusCode::OK,
2147+
"should preserve the streaming origin status"
2148+
);
2149+
assert!(
2150+
matches!(response.body(), Body::Stream(_)),
2151+
"EdgeZero asset dispatch must stream the origin body, not buffer it"
2152+
);
2153+
}
2154+
21112155
#[test]
21122156
fn dispatch_runs_request_filter_and_threads_response_effects() {
21132157
// Regression guard for the EdgeZero request-filter bypass: the publisher

crates/trusted-server-adapter-fastly/src/main.rs

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -541,7 +541,7 @@ fn edgezero_main(mut req: FastlyRequest, config_store: ConfigStoreHandle) {
541541
// `apply_finalize_headers` ran, so re-apply the privacy
542542
// downgrade before send, mirroring legacy_main.
543543
crate::middleware::enforce_set_cookie_cache_privacy(&mut response);
544-
compat::to_fastly_response(response).send_to_client();
544+
send_core_response(response);
545545

546546
if ec_state.is_real_browser {
547547
if let Some(context) = build_pull_sync_context(&ec_state.ec_context) {
@@ -573,7 +573,44 @@ fn edgezero_main(mut req: FastlyRequest, config_store: ConfigStoreHandle) {
573573
// Final cache guard for the no-EC-finalization fallback: request-filter
574574
// effects may still have added a per-user Set-Cookie after finalize headers.
575575
crate::middleware::enforce_set_cookie_cache_privacy(&mut response);
576-
compat::to_fastly_response(response).send_to_client();
576+
send_core_response(response);
577+
}
578+
579+
/// Sends a finalized `EdgeZero` response to the client.
580+
///
581+
/// Asset responses carry an [`EdgeBody::Stream`] body: the headers are committed
582+
/// first via [`fastly::Response::stream_to_client`], then the origin body is
583+
/// piped chunk-by-chunk with [`stream_asset_body`] so large images never
584+
/// materialize in the Wasm heap. This mirrors the legacy
585+
/// `HandlerOutcome::AssetStreaming` path. All other responses carry a buffered
586+
/// [`EdgeBody::Once`] body and are sent in a single shot.
587+
fn send_core_response(response: HttpResponse) {
588+
let (parts, body) = response.into_parts();
589+
match body {
590+
EdgeBody::Stream(_) => {
591+
let skeleton = compat::to_fastly_response_skeleton(HttpResponse::from_parts(
592+
parts,
593+
EdgeBody::empty(),
594+
));
595+
let mut streaming_body = skeleton.stream_to_client();
596+
match futures::executor::block_on(stream_asset_body(body, &mut streaming_body)) {
597+
Ok(()) => {
598+
if let Err(e) = streaming_body.finish() {
599+
log::error!("failed to finish EdgeZero asset streaming body: {e}");
600+
}
601+
}
602+
Err(e) => {
603+
log::error!("EdgeZero asset streaming failed: {e:?}");
604+
// Headers already committed; drop the body so the client sees
605+
// a truncated response (EOF mid-stream), standard proxy behavior.
606+
drop(streaming_body);
607+
}
608+
}
609+
}
610+
once => {
611+
compat::to_fastly_response(HttpResponse::from_parts(parts, once)).send_to_client();
612+
}
613+
}
577614
}
578615

579616
fn take_finalize_sentinel(response: &mut HttpResponse) -> bool {

crates/trusted-server-adapter-fastly/src/route_tests.rs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ impl PlatformConfigStore for StubJwksConfigStore {
7373
}
7474
}
7575

76-
struct NoopSecretStore;
76+
pub(crate) struct NoopSecretStore;
7777

7878
struct HashMapSecretStore {
7979
data: HashMap<String, Vec<u8>>,
@@ -155,7 +155,7 @@ struct RecordingHttpClient {
155155
response_body: Vec<u8>,
156156
}
157157

158-
struct StreamingRecordingHttpClient {
158+
pub(crate) struct StreamingRecordingHttpClient {
159159
calls: Mutex<Vec<RecordedHttpCall>>,
160160
}
161161

@@ -187,7 +187,7 @@ impl RecordingHttpClient {
187187
}
188188

189189
impl StreamingRecordingHttpClient {
190-
fn new() -> Self {
190+
pub(crate) fn new() -> Self {
191191
Self {
192192
calls: Mutex::new(Vec::new()),
193193
}
@@ -201,7 +201,7 @@ struct RecordedHttpCall {
201201
stream_response: bool,
202202
}
203203

204-
struct FixedBackend;
204+
pub(crate) struct FixedBackend;
205205

206206
impl PlatformBackend for FixedBackend {
207207
fn predict_name(&self, spec: &PlatformBackendSpec) -> Result<String, Report<PlatformError>> {
@@ -365,15 +365,15 @@ impl AuctionProvider for DisabledRouteProvider {
365365
}
366366
}
367367

368-
struct FixedGeo(GeoInfo);
368+
pub(crate) struct FixedGeo(pub(crate) GeoInfo);
369369

370370
impl PlatformGeo for FixedGeo {
371371
fn lookup(&self, _client_ip: Option<IpAddr>) -> Result<Option<GeoInfo>, Report<PlatformError>> {
372372
Ok(Some(self.0.clone()))
373373
}
374374
}
375375

376-
fn us_california_geo() -> GeoInfo {
376+
pub(crate) fn us_california_geo() -> GeoInfo {
377377
GeoInfo {
378378
city: "Example City".to_string(),
379379
country: "US".to_string(),
@@ -559,7 +559,7 @@ fn test_runtime_services_with_secret_and_http_client(
559559
)
560560
}
561561

562-
fn test_runtime_services_with_secret_http_client_and_geo(
562+
pub(crate) fn test_runtime_services_with_secret_http_client_and_geo(
563563
req: &Request,
564564
backend: Arc<dyn PlatformBackend>,
565565
secret_store: Arc<dyn PlatformSecretStore>,

0 commit comments

Comments
 (0)