Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 68 additions & 52 deletions crates/trusted-server-adapter-fastly/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ use trusted_server_core::http_util::sanitize_forwarded_headers;
use trusted_server_core::integrations::IntegrationRegistry;
use trusted_server_core::platform::RuntimeServices;
use trusted_server_core::proxy::{
handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild,
handle_first_party_proxy_sign,
handle_asset_proxy_request, handle_first_party_click, handle_first_party_proxy,
handle_first_party_proxy_rebuild, handle_first_party_proxy_sign,
};
use trusted_server_core::publisher::{
handle_publisher_request, handle_tsjs_dynamic, stream_publisher_body, PublisherResponse,
Expand Down Expand Up @@ -150,6 +150,11 @@ async fn route_request(
let path = req.get_path().to_string();
let method = req.get_method().clone();

let matched_asset_route = match &method {
&Method::GET | &Method::HEAD => settings.asset_route_for_path(&path),
_ => None,
};
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ refactormatch &method { &Method::GET | &Method::HEAD => ..., _ => None } is awkward; the &pat borrow dance only exists to avoid moving method before the outer match consumes it. Cleaner:

let matched_asset_route = matches!(method, Method::GET | Method::HEAD)
    .then(|| settings.asset_route_for_path(&path))
    .flatten();


// Match known routes and handle them
let result = match (method, path.as_str()) {
// Serve the tsjs library
Expand Down Expand Up @@ -202,62 +207,73 @@ async fn route_request(
}))
}),

// No known route matched, proxy to publisher origin as fallback
// No known route matched, proxy to an asset origin or publisher origin as fallback
_ => {
log::info!(
"No known route matched for path: {}, proxying to publisher origin",
path
);

match runtime_services_for_consent_route(settings, runtime_services) {
Ok(publisher_services) => {
match handle_publisher_request(
settings,
integration_registry,
&publisher_services,
req,
) {
Ok(PublisherResponse::Stream {
mut response,
body,
params,
}) => {
// Streaming path: finalize headers, then stream body to client.
finalize_response(settings, geo_info.as_ref(), &mut response);
let mut streaming_body = response.stream_to_client();
if let Err(e) = stream_publisher_body(
if let Some(asset_route) = matched_asset_route {
log::info!(
"No explicit route matched for path: {}, proxying via asset route prefix {} to {}",
path,
asset_route.prefix,
asset_route.origin_url
);
handle_asset_proxy_request(settings, runtime_services, req, &asset_route.origin_url)
.await
} else {
log::info!(
"No known route matched for path: {}, proxying to publisher origin",
path
);

match runtime_services_for_consent_route(settings, runtime_services) {
Ok(publisher_services) => {
match handle_publisher_request(
settings,
integration_registry,
&publisher_services,
req,
) {
Ok(PublisherResponse::Stream {
mut response,
body,
&mut streaming_body,
&params,
settings,
integration_registry,
) {
// Headers already committed. Log and abort — client
// sees a truncated response. Standard proxy behavior.
log::error!("Streaming processing failed: {e:?}");
drop(streaming_body);
} else if let Err(e) = streaming_body.finish() {
log::error!("Failed to finish streaming body: {e}");
params,
}) => {
// Streaming path: finalize headers, then stream body to client.
finalize_response(settings, geo_info.as_ref(), &mut response);
let mut streaming_body = response.stream_to_client();
if let Err(e) = stream_publisher_body(
body,
&mut streaming_body,
&params,
settings,
integration_registry,
) {
// Headers already committed. Log and abort — client
// sees a truncated response. Standard proxy behavior.
log::error!("Streaming processing failed: {e:?}");
drop(streaming_body);
} else if let Err(e) = streaming_body.finish() {
log::error!("Failed to finish streaming body: {e}");
}
// Response already sent via stream_to_client()
return None;
}
Ok(PublisherResponse::PassThrough { mut response, body }) => {
// Binary pass-through: reattach body and send via send_to_client().
// This preserves Content-Length and avoids chunked encoding overhead.
// Fastly streams the body from its internal buffer — no WASM
// memory buffering occurs.
response.set_body(body);
Ok(response)
}
Ok(PublisherResponse::Buffered(response)) => Ok(response),
Err(e) => {
log::error!("Failed to proxy to publisher origin: {:?}", e);
Err(e)
}
// Response already sent via stream_to_client()
return None;
}
Ok(PublisherResponse::PassThrough { mut response, body }) => {
// Binary pass-through: reattach body and send via send_to_client().
// This preserves Content-Length and avoids chunked encoding overhead.
// Fastly streams the body from its internal buffer — no WASM
// memory buffering occurs.
response.set_body(body);
Ok(response)
}
Ok(PublisherResponse::Buffered(response)) => Ok(response),
Err(e) => {
log::error!("Failed to proxy to publisher origin: {:?}", e);
Err(e)
}
}
Err(e) => Err(e),
}
Err(e) => Err(e),
}
}
};
Expand Down
91 changes: 90 additions & 1 deletion crates/trusted-server-adapter-fastly/src/route_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use trusted_server_core::platform::{
StoreName,
};
use trusted_server_core::request_signing::JWKS_CONFIG_STORE_NAME;
use trusted_server_core::settings::Settings;
use trusted_server_core::settings::{ProxyAssetRoute, Settings};

use super::route_request;

Expand Down Expand Up @@ -249,3 +249,92 @@ fn configured_missing_consent_store_only_breaks_consent_routes() {
"should scope consent store failures to the consent-dependent routes"
);
}

#[test]
fn asset_routes_bypass_publisher_consent_dependencies() {
let mut settings = create_test_settings();
settings.proxy.asset_routes = vec![ProxyAssetRoute {
prefix: "/.images/".to_string(),
origin_url: "https://assets.example.com".to_string(),
}];
let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator");
let integration_registry =
IntegrationRegistry::new(&settings).expect("should create integration registry");

let asset_req = Request::get("https://test.com/.images/logo.png?auto=webp");
let asset_services = test_runtime_services(&asset_req);
let asset_resp = futures::executor::block_on(route_request(
&settings,
&orchestrator,
&integration_registry,
&asset_services,
asset_req,
))
.expect("should return an error response for asset proxy requests");
assert_eq!(
asset_resp.get_status(),
StatusCode::BAD_GATEWAY,
"should bypass publisher consent dependencies and fail only on the missing upstream client"
);
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench — The spec (§9 / §"Failure Semantics") explicitly forbids silent fallback to publisher.origin_url when an asset origin fails. The current implementation does the right thing, but no test pins that behavior — a future refactor could regress it without anyone noticing.

asset_routes_bypass_publisher_consent_dependencies shows that asset routes don't run the consent path, but it doesn't prove the failure path stops at 502 instead of attempting publisher.origin_url. Please add a focused test that asserts:

  1. an asset-route request whose backend ensure() (or upstream send()) fails returns 502, and
  2. the publisher origin's send() is never invoked for that request (e.g., via a stub PlatformHttpClient that records call counts and asserts zero calls to the publisher origin).


#[test]
fn built_in_routes_take_precedence_over_asset_routes() {
let mut settings = create_test_settings();
settings.proxy.asset_routes = vec![ProxyAssetRoute {
prefix: "/.well-known/".to_string(),
origin_url: "https://assets.example.com".to_string(),
}];
let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator");
let integration_registry =
IntegrationRegistry::new(&settings).expect("should create integration registry");

let req = Request::get("https://test.com/.well-known/trusted-server.json");
let services = test_runtime_services(&req);
let resp = futures::executor::block_on(route_request(
&settings,
&orchestrator,
&integration_registry,
&services,
req,
))
.expect("should route discovery request");
assert_eq!(
resp.get_status(),
StatusCode::OK,
"should keep explicit built-in routes ahead of asset routes"
);
}

#[test]
fn integration_routes_take_precedence_over_asset_routes() {
let mut settings = create_test_settings();
settings.proxy.asset_routes = vec![ProxyAssetRoute {
prefix: "/prebid.js".to_string(),
origin_url: "https://assets.example.com".to_string(),
}];
let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator");
let integration_registry =
IntegrationRegistry::new(&settings).expect("should create integration registry");

let req = Request::get("https://test.com/prebid.js");
let services = test_runtime_services(&req);
let mut resp = futures::executor::block_on(route_request(
&settings,
&orchestrator,
&integration_registry,
&services,
req,
))
.expect("should route integration request");
assert_eq!(
resp.get_status(),
StatusCode::OK,
"should keep explicit integration routes ahead of asset routes"
);
assert_eq!(
resp.take_body_str(),
"// Script overridden by Trusted Server\n",
"should serve the integration response instead of proxying to the asset origin"
);
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 thinking — The spec's "Recommended Tests" section calls out several invariants that aren't covered yet. All are a few lines given existing helpers (StubHttpClient, StaticResponseHttpClient):

  • HEAD passthrough (spec §12 / §"Method forwarding"): only GET is exercised in handle_asset_proxy_request_forwards_asset_headers_and_host.
  • Non-GET/HEAD bypass (spec §6): no test that POST /.images/foo skips asset matching and falls through to publisher.
  • Redirect pass-through (spec §11): no test that a 301/302 from upstream is returned to the client unchanged.
  • Query string ignored for matching (spec §"Matching algorithm"): not pinned in proxy_asset_route_for_path_* tests.

These keep the spec and tests in lockstep so behavior shifts surface as failing tests.

Loading
Loading