From 0567c33503549df8626220be8e6007084087a90c Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 11 Oct 2026 09:31:21 +0100 Subject: [PATCH 01/11] A middleware may prepare, with the request's services, and may refuse `Middleware::prepare` answers a future for a document whose decision needs the request's services or has to wait, on the key-value store or the network. The request's handler runs it before the document is processed, once per phase the document goes through, as the middleware's module and with the permissions it declares, and keeps what it answers under the middleware's name in the context's `Prepared`, where `create` takes it as the type it prepared. `create` and every streaming path stay synchronous, so the Fastly adapter is as it was. A middleware that prepares nothing is asked `create` as before. A preparation may answer a `Refusal` instead, with a status, an optional `Retry-After` and a message. The handler answers the request with that status and the message as plain text, marks the response private and unstorable, and processes no document, so a middleware responsible for a file fails closed when it cannot make it. The first refusal ends the preparations of a document. A copy read from the store prepares its serve chain the same way before it is assembled. The middleware fixture gains one that prepares and one that refuses, and the tests cover what is prepared reaching `create` in either phase, the refusal's status, headers and body, the chain keeping each answer under its name, and the first refusal ending the preparation. --- .../benches/html_processor_bench.rs | 1 + .../trusted-server-core/src/html_processor.rs | 27 +- .../src/integrations/js_asset_proxy.rs | 1 + .../src/integrations/registry.rs | 111 +++++- crates/trusted-server-core/src/middleware.rs | 155 +++++++- .../src/middleware/tests.rs | 166 ++++++++ crates/trusted-server-core/src/publisher.rs | 366 +++++++++++++++++- 7 files changed, 812 insertions(+), 15 deletions(-) diff --git a/crates/trusted-server-core/benches/html_processor_bench.rs b/crates/trusted-server-core/benches/html_processor_bench.rs index bbb5412f21..27e0496722 100644 --- a/crates/trusted-server-core/benches/html_processor_bench.rs +++ b/crates/trusted-server-core/benches/html_processor_bench.rs @@ -16,6 +16,7 @@ fn make_config() -> HtmlProcessorConfig { permissions_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, + prepared: std::sync::Arc::new(trusted_server_core::middleware::Prepared::default()), request_state: trusted_server_core::integrations::IntegrationRequestState::default(), // The benchmark measures URL rewriting, not ad injection, and // `ad_slots_script` is `None` here — matching the previous behaviour, diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index 8442971433..a151d5a11b 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -18,7 +18,7 @@ use crate::integrations::{ ScriptRewriteAction, }; use crate::middleware::{ - ElementHandler, MatchedAttribute, MiddlewareChain, MiddlewareContext, MiddlewarePlan, + ElementHandler, MatchedAttribute, MiddlewareChain, MiddlewareContext, MiddlewarePlan, Prepared, TextHandler, }; use crate::publisher::build_empty_bids_script; @@ -112,6 +112,9 @@ pub struct HtmlProcessorConfig { /// `None` on every path that cannot store a shared template, so an ordinary inline /// request does not pay for handlers whose only consumer is the template-cache gate. pub csp_nonce_observed: Option>, + /// What the middleware prepared for this document, see + /// [`Middleware::prepare`](crate::middleware::Middleware::prepare). + pub prepared: Arc, } impl HtmlProcessorConfig { @@ -136,6 +139,7 @@ impl HtmlProcessorConfig { request_state: IntegrationRequestState::default(), body_close: BodyCloseInjection::None, csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), } } @@ -195,6 +199,13 @@ impl HtmlProcessorConfig { self.csp_nonce_observed = observed; self } + + /// Attach what the middleware prepared for this document. + #[must_use] + pub fn with_prepared(mut self, prepared: Arc) -> Self { + self.prepared = prepared; + self + } } /// The chunk of text being rewritten, as the last handler to change it left @@ -282,6 +293,7 @@ pub fn create_html_processor_with_middleware( origin_host: &config.origin_host, document_state: &document_state, max_buffered_script_bytes: config.max_buffered_body_bytes, + prepared: &config.prepared, })?; let reader = match serve { Some(serve) => create_serve_processor( @@ -292,6 +304,7 @@ pub fn create_html_processor_with_middleware( origin_host: &config.origin_host, request_state: &config.request_state, max_buffered_script_bytes: config.max_buffered_body_bytes, + prepared: &config.prepared, }, )?, None => None, @@ -314,6 +327,8 @@ pub struct ReaderDocument<'a> { pub request_state: &'a IntegrationRequestState, /// The most a middleware may hold of one script while it decides. pub max_buffered_script_bytes: usize, + /// What the middleware prepared for the reader's copy. + pub prepared: &'a Prepared, } /// A processor that carries out a serve chain alone, on one reader's copy of @@ -344,6 +359,7 @@ pub fn create_serve_processor( origin_host: document.origin_host, document_state: &document_state, max_buffered_script_bytes: document.max_buffered_script_bytes, + prepared: document.prepared, })?; if plan.is_empty() { return Ok(None); @@ -1107,6 +1123,7 @@ mod tests { fn create_test_config() -> HtmlProcessorConfig { HtmlProcessorConfig { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), body_close: BodyCloseInjection::None, origin_host: "origin.example.com".to_owned(), request_host: "test.example.com".to_owned(), @@ -1688,6 +1705,7 @@ mod tests { fn injects_ad_slots_at_head_open() { let config = HtmlProcessorConfig { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), body_close: BodyCloseInjection::None, origin_host: "origin.example.com".to_string(), request_host: "example.com".to_string(), @@ -1766,6 +1784,7 @@ mod tests { let state = std::sync::Arc::new(std::sync::Mutex::new(Some(bids_script.to_string()))); let config = HtmlProcessorConfig { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), body_close: BodyCloseInjection::InlineBids, origin_host: "origin.example.com".to_string(), request_host: "example.com".to_string(), @@ -1805,6 +1824,7 @@ mod tests { let state = std::sync::Arc::new(std::sync::Mutex::new(Some(bids_script.to_string()))); let config = HtmlProcessorConfig { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), body_close: BodyCloseInjection::InlineBids, origin_host: "origin.example.com".to_string(), request_host: "example.com".to_string(), @@ -1845,6 +1865,7 @@ mod tests { let request_host = "proxy.test-publisher.example.com"; let config = HtmlProcessorConfig { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), body_close: BodyCloseInjection::None, origin_host: "origin.test-publisher.example.com".to_string(), request_host: request_host.to_string(), @@ -1899,6 +1920,7 @@ mod tests { let state = std::sync::Arc::new(std::sync::Mutex::new(None)); let config = HtmlProcessorConfig { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), body_close: BodyCloseInjection::InlineBids, origin_host: "origin.example.com".to_string(), request_host: "example.com".to_string(), @@ -1931,6 +1953,7 @@ mod tests { let state = std::sync::Arc::new(std::sync::Mutex::new(None)); let config = HtmlProcessorConfig { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), body_close: BodyCloseInjection::None, origin_host: "origin.example.com".to_string(), request_host: "example.com".to_string(), @@ -1965,6 +1988,7 @@ mod tests { permissions_script: None, ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, + prepared: Arc::new(Prepared::default()), request_state: IntegrationRequestState::default(), } } @@ -2173,6 +2197,7 @@ mod tests { const MARKER: &str = ""; let config = HtmlProcessorConfig { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), body_close: BodyCloseInjection::Marker(MARKER.to_string()), origin_host: "origin.example.com".to_string(), request_host: "example.com".to_string(), diff --git a/crates/trusted-server-core/src/integrations/js_asset_proxy.rs b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs index a8b8a38b0b..450d3501c1 100644 --- a/crates/trusted-server-core/src/integrations/js_asset_proxy.rs +++ b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs @@ -679,6 +679,7 @@ mod tests { ); let config = HtmlProcessorConfig { csp_nonce_observed: None, + prepared: std::sync::Arc::new(crate::middleware::Prepared::default()), body_close: BodyCloseInjection::None, origin_host: "origin.example.com".to_string(), request_host: "publisher.example.com".to_string(), diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index b29039819e..fbacf0acfb 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -2908,7 +2908,9 @@ pub(crate) mod test_support { use crate::integrations::{CORE_SOURCE, IntegrationBuilder}; use crate::middleware::{ AttributeRewrite, Middleware, MiddlewareAction, MiddlewareContext, MiddlewarePhase, + Preparation, Refusal, }; + use crate::module_context::ModuleCall; use crate::settings::Settings; /// The integration id the stand-in registers under. @@ -2937,6 +2939,89 @@ pub(crate) mod test_support { pub(crate) const TEXT_MEDIA_TYPE: &str = "text/plain"; /// The line the text middleware adds at the end of a text body. pub(crate) const TEXT_LINE: &str = "# added by the middleware fixture\n"; + /// The name of the fetch middleware that prepares. + pub(crate) const PREPARING: &str = "testing.middleware-fixture.preparing"; + /// The name of the fetch middleware that refuses every request. + pub(crate) const REFUSING: &str = "testing.middleware-fixture.refusing"; + /// How long the refusing middleware asks the caller to wait. + pub(crate) const REFUSAL_RETRY_AFTER: std::time::Duration = + std::time::Duration::from_secs(30); + /// What the refusing middleware says. + pub(crate) const REFUSAL_MESSAGE: &str = "the middleware fixture refuses"; + /// What the preparing middleware writes when nothing was prepared. + pub(crate) const NOTHING_PREPARED: &str = + ""; + + /// The marker the preparing middleware writes, naming the module it + /// was called as and the host it was told of. + pub(crate) fn prepared_marker(module: &str, request_host: &str) -> String { + format!( + "" + ) + } + + struct Preparing; + + impl Middleware for Preparing { + fn middleware_id(&self) -> &'static str { + PREPARING + } + + fn phases(&self) -> &[MiddlewarePhase] { + &MiddlewarePhase::ALL + } + + fn prepare<'a>( + &'a self, + call: ModuleCall<'a>, + context: &'a MiddlewareContext<'a>, + ) -> Option> { + Some(Box::pin(async move { + let marker = prepared_marker(call.module(), context.request_host); + Ok(Box::new(marker) as Box) + })) + } + + fn create(&self, context: &MiddlewareContext<'_>) -> MiddlewareAction { + let marker: String = context + .prepared + .take(PREPARING) + .unwrap_or_else(|| NOTHING_PREPARED.to_owned()); + MiddlewareAction { + head_inserts: vec![marker], + ..MiddlewareAction::pass() + } + } + } + + struct Refusing; + + impl Middleware for Refusing { + fn middleware_id(&self) -> &'static str { + REFUSING + } + + fn phases(&self) -> &[MiddlewarePhase] { + &[MiddlewarePhase::Fetch] + } + + fn prepare<'a>( + &'a self, + _call: ModuleCall<'a>, + _context: &'a MiddlewareContext<'a>, + ) -> Option> { + Some(Box::pin(async { + Err(Refusal::service_unavailable( + REFUSAL_RETRY_AFTER, + REFUSAL_MESSAGE, + )) + })) + } + + fn create(&self, _context: &MiddlewareContext<'_>) -> MiddlewareAction { + MiddlewareAction::pass() + } + } /// The builder core's test build lists beside its own. pub(crate) const BUILDER: IntegrationBuilder = @@ -3138,6 +3223,8 @@ pub(crate) mod test_support { .with_middleware(Arc::new(Reader)) .with_middleware(Arc::new(BrokenReader)) .with_middleware(Arc::new(Text)) + .with_middleware(Arc::new(Preparing)) + .with_middleware(Arc::new(Refusing)) .build(), )) } @@ -6044,6 +6131,8 @@ mod tests { fixture::READER, fixture::BROKEN_READER, fixture::TEXT, + fixture::PREPARING, + fixture::REFUSING, ], "should list the middleware the selected module supplies" ); @@ -6053,13 +6142,15 @@ mod tests { fixture::HEAD, fixture::LINKS, fixture::BROKEN, - fixture::TEXT + fixture::TEXT, + fixture::PREPARING, + fixture::REFUSING ], "should list the middleware that run in the fetch phase" ); assert_eq!( registry.middleware_in(MiddlewarePhase::Serve), - [fixture::READER, fixture::BROKEN_READER], + [fixture::READER, fixture::BROKEN_READER, fixture::PREPARING], "should list the middleware that run in the serve phase" ); let chain_for = |media_type: &str, path: &str| { @@ -6174,13 +6265,15 @@ mod tests { message.contains( "[[fetch]] entry 2 names `testing.nothing`, which no module that runs supplies" ) && message.contains(&format!( - "[{}, {}, {}, {}, {}, {}]", + "[{}, {}, {}, {}, {}, {}, {}, {}]", fixture::HEAD, fixture::LINKS, fixture::BROKEN, fixture::READER, fixture::BROKEN_READER, - fixture::TEXT + fixture::TEXT, + fixture::PREPARING, + fixture::REFUSING )), "should name the entry and list what could be named: {message}" ); @@ -6233,6 +6326,8 @@ mod tests { fixture::READER, fixture::BROKEN_READER, fixture::TEXT, + fixture::PREPARING, + fixture::REFUSING, ], "should list every middleware when there are no entries" ); @@ -6245,7 +6340,9 @@ mod tests { fixture::BROKEN, fixture::READER, fixture::BROKEN_READER, - fixture::TEXT + fixture::TEXT, + fixture::PREPARING, + fixture::REFUSING ], "should leave out a middleware any entry names" ); @@ -6313,7 +6410,9 @@ mod tests { fixture::LINKS, fixture::BROKEN, fixture::BROKEN_READER, - fixture::TEXT + fixture::TEXT, + fixture::PREPARING, + fixture::REFUSING ], "should count a middleware as named only by an entry of a phase it runs in" ); diff --git a/crates/trusted-server-core/src/middleware.rs b/crates/trusted-server-core/src/middleware.rs index 21a3f8f861..16759bd4c2 100644 --- a/crates/trusted-server-core/src/middleware.rs +++ b/crates/trusted-server-core/src/middleware.rs @@ -34,14 +34,28 @@ //! response. A serve middleware runs for each reader, on that reader's copy, //! whether the page came from the store or from the origin, and nothing it //! writes is stored. - +//! +//! A middleware whose decision needs the request's services, or has to wait +//! on a store or the network, prepares. Before the document is processed it +//! is called in the request's handler, and what it answers is kept for its +//! `create` under its name. It may refuse the request instead, which is then +//! answered with the status it gives and no document, and nothing is stored. + +use std::any::Any; +use std::collections::HashMap; use std::fmt; +use std::future::Future; +use std::pin::Pin; use std::rc::Rc; -use std::sync::Arc; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; +use std::time::Duration; +use http::StatusCode; use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use crate::integrations::{AttributeRewriteAction, IntegrationDocumentState, ScriptRewriteAction}; +use crate::module_context::{ModuleCall, ModuleContext}; +use crate::permissions::PermissionSet; use crate::streaming_processor::StreamProcessor; /// The media type a document is matched as, and the one a middleware runs on @@ -130,6 +144,9 @@ pub struct MiddlewareContext<'a> { pub document_state: &'a IntegrationDocumentState, /// The most a middleware may hold of one script while it decides. pub max_buffered_script_bytes: usize, + /// What the middleware prepared for this document, see + /// [`Middleware::prepare`]. + pub prepared: &'a Prepared, } /// One attribute of one matched element, as a handler is asked about it. @@ -332,6 +349,80 @@ fn selectors_of_text(handlers: &[Box]) -> Vec { .collect() } +/// A middleware's answer to a request it gives no document to, see +/// [`Middleware::prepare`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Refusal { + /// The status the request is answered with. + pub status: StatusCode, + /// What `Retry-After` says, for a status that asks the caller back. + pub retry_after: Option, + /// What the response says, as plain text. + pub message: String, +} + +impl Refusal { + /// A `503 Service Unavailable` asking the caller back after + /// `retry_after`. + #[must_use] + pub fn service_unavailable(retry_after: Duration, message: impl Into) -> Self { + Self { + status: StatusCode::SERVICE_UNAVAILABLE, + retry_after: Some(retry_after), + message: message.into(), + } + } +} + +/// What a middleware prepared for one document, see +/// [`Middleware::prepare`]. +pub type Preparation<'a> = Pin, Refusal>> + 'a>>; + +/// What the middleware of one document prepared, each under its own name, +/// for its `create` to take. +#[derive(Default)] +pub struct Prepared(Mutex>>); + +impl Prepared { + /// Keeps what the middleware named `id` prepared. + pub fn insert(&self, id: &'static str, value: Box) { + self.lock().insert(id, value); + } + + /// What the middleware named `id` prepared, as the type it prepared, + /// taken out so it is used once. `None` when it prepared nothing or + /// something of another type. + #[must_use] + pub fn take(&self, id: &str) -> Option { + let mut values = self.lock(); + if !values.get(id)?.is::() { + return None; + } + let value = values.remove(id)?; + value.downcast::().ok().map(|value| *value) + } + + /// Whether nothing was prepared. + #[must_use] + pub fn is_empty(&self) -> bool { + self.lock().is_empty() + } + + fn lock(&self) -> MutexGuard<'_, HashMap<&'static str, Box>> { + self.0.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +impl fmt::Debug for Prepared { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let names: Vec<&str> = self.lock().keys().copied().collect(); + formatter + .debug_struct("Prepared") + .field("names", &names) + .finish() + } +} + /// A page change a module supplies. /// /// A registered middleware holds no request state. For each document it is @@ -357,6 +448,32 @@ pub trait Middleware: Send + Sync { /// The phases an entry may name this middleware in. fn phases(&self) -> &[MiddlewarePhase]; + /// The permissions this middleware declares, which decide whether a + /// gated value is passed to it in [`prepare`](Self::prepare). None by + /// default. + fn required_permissions(&self) -> PermissionSet { + PermissionSet::none() + } + + /// Work done for one document before `create` is asked, where deciding + /// needs the request's services or has to wait, on a store or the + /// network. `None`, the default, means none is needed. + /// + /// The future runs in the request's handler before the document is + /// processed, with `call` carrying what the request's services allow this + /// middleware. What it answers is kept under this middleware's name in + /// the context's [`Prepared`], for `create` to take. A refusal answers + /// the request with the status it carries and no document, and nothing + /// is stored. + fn prepare<'a>( + &'a self, + call: ModuleCall<'a>, + context: &'a MiddlewareContext<'a>, + ) -> Option> { + let _ = (call, context); + None + } + /// Decides what to do with one document. fn create(&self, context: &MiddlewareContext<'_>) -> MiddlewareAction; } @@ -626,6 +743,30 @@ impl MiddlewareChain { self.middleware.is_empty() } + /// Runs the preparation of every middleware that has one, in the order + /// they run, keeping what each prepared in the context's [`Prepared`]. + /// Each is called as the module it belongs to, with the permissions it + /// declares. + /// + /// # Errors + /// + /// The first refusal, which ends the request. + pub async fn prepare( + &self, + module_context: &ModuleContext<'_>, + context: &MiddlewareContext<'_>, + ) -> Result<(), Refusal> { + for middleware in &self.middleware { + let id = middleware.middleware_id(); + let call = module_context.call(id, middleware.required_permissions()); + if let Some(preparation) = middleware.prepare(call, context) { + let value = preparation.await?; + context.prepared.insert(id, value); + } + } + Ok(()) + } + /// Asks every middleware in turn what it will do with one document. /// /// A selector cannot be checked when the settings load, because a @@ -740,8 +881,18 @@ pub mod test_support { origin_host: ORIGIN_HOST, document_state, max_buffered_script_bytes: 16 * 1024 * 1024, + prepared: nothing_prepared(), } } + + /// A [`Prepared`](super::Prepared) with nothing in it, for a middleware + /// that prepares nothing. + #[must_use] + pub fn nothing_prepared() -> &'static super::Prepared { + static NOTHING: std::sync::LazyLock = + std::sync::LazyLock::new(super::Prepared::default); + &NOTHING + } } #[cfg(test)] diff --git a/crates/trusted-server-core/src/middleware/tests.rs b/crates/trusted-server-core/src/middleware/tests.rs index d496b7a418..ca93e31731 100644 --- a/crates/trusted-server-core/src/middleware/tests.rs +++ b/crates/trusted-server-core/src/middleware/tests.rs @@ -69,6 +69,7 @@ fn reader(request_state: &IntegrationRequestState) -> ReaderDocument<'_> { origin_host: ORIGIN_HOST, request_state, max_buffered_script_bytes: 16 * 1024 * 1024, + prepared: super::test_support::nothing_prepared(), } } @@ -1531,3 +1532,168 @@ fn the_creating_entry_is_the_first_with_a_path_covering_the_request_that_names_a "should make nothing where no entry with a path covers the request, whatever covers every path" ); } + +#[test] +fn what_is_prepared_is_taken_once_by_name_and_as_its_type() { + let prepared = Prepared::default(); + assert!(prepared.is_empty()); + prepared.insert("example.one", Box::new(String::from("one"))); + prepared.insert("example.two", Box::new(7_u32)); + + assert_eq!( + prepared.take::("example.one"), + None, + "should leave a value of another type where it is" + ); + assert_eq!( + prepared.take::("example.one").as_deref(), + Some("one") + ); + assert_eq!( + prepared.take::("example.one"), + None, + "should give what was prepared once" + ); + assert_eq!(prepared.take::("example.two"), Some(7)); + assert!(prepared.is_empty()); +} + +/// Prepares the name it is called as, and writes it into the head. +struct Prepares; + +impl Middleware for Prepares { + fn middleware_id(&self) -> &'static str { + "example.prepares" + } + + fn phases(&self) -> &[MiddlewarePhase] { + &[MiddlewarePhase::Fetch] + } + + fn prepare<'a>( + &'a self, + call: ModuleCall<'a>, + _context: &'a MiddlewareContext<'a>, + ) -> Option> { + Some(Box::pin(async move { + Ok(Box::new(call.module().to_owned()) as Box) + })) + } + + fn create(&self, context: &MiddlewareContext<'_>) -> MiddlewareAction { + let prepared: String = context + .prepared + .take("example.prepares") + .unwrap_or_else(|| "nothing prepared".to_owned()); + MiddlewareAction { + head_inserts: vec![prepared], + ..MiddlewareAction::pass() + } + } +} + +/// Prepares nothing. +struct Plain; + +impl Middleware for Plain { + fn middleware_id(&self) -> &'static str { + "example.plain" + } + + fn phases(&self) -> &[MiddlewarePhase] { + &[MiddlewarePhase::Fetch] + } + + fn create(&self, _context: &MiddlewareContext<'_>) -> MiddlewareAction { + MiddlewareAction::pass() + } +} + +/// Refuses every request. +struct Refuses; + +impl Middleware for Refuses { + fn middleware_id(&self) -> &'static str { + "example.refuses" + } + + fn phases(&self) -> &[MiddlewarePhase] { + &[MiddlewarePhase::Fetch] + } + + fn prepare<'a>( + &'a self, + _call: ModuleCall<'a>, + _context: &'a MiddlewareContext<'a>, + ) -> Option> { + Some(Box::pin(async { + Err(Refusal::service_unavailable( + Duration::from_secs(5), + "later", + )) + })) + } + + fn create(&self, _context: &MiddlewareContext<'_>) -> MiddlewareAction { + MiddlewareAction::pass() + } +} + +#[tokio::test] +async fn a_chain_keeps_what_each_middleware_prepares_under_its_name() { + let chain = MiddlewareChain::new( + MiddlewarePhase::Fetch, + HTML_MEDIA_TYPE, + vec![Arc::new(Plain), Arc::new(Prepares)], + ); + let document_state = IntegrationDocumentState::default(); + let prepared = Prepared::default(); + let context = MiddlewareContext { + prepared: &prepared, + ..test_support::context(MiddlewarePhase::Fetch, &document_state) + }; + + chain + .prepare(ModuleContext::empty(), &context) + .await + .expect("should prepare"); + let plan = chain.plan(&context).expect("should plan"); + + assert_eq!( + plan.head_inserts, + vec!["example.prepares".to_owned()], + "should hand create what the middleware prepared, called as its own name" + ); + assert!( + prepared.is_empty(), + "should have given the preparation to create" + ); +} + +#[tokio::test] +async fn the_first_refusal_ends_the_preparation() { + let chain = MiddlewareChain::new( + MiddlewarePhase::Fetch, + HTML_MEDIA_TYPE, + vec![Arc::new(Refuses), Arc::new(Prepares)], + ); + let document_state = IntegrationDocumentState::default(); + let prepared = Prepared::default(); + let context = MiddlewareContext { + prepared: &prepared, + ..test_support::context(MiddlewarePhase::Fetch, &document_state) + }; + + let refusal = chain + .prepare(ModuleContext::empty(), &context) + .await + .expect_err("should refuse"); + + assert_eq!(refusal.status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(refusal.retry_after, Some(Duration::from_secs(5))); + assert_eq!(refusal.message, "later"); + assert!( + prepared.is_empty(), + "should prepare nothing after the refusal" + ); +} diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 4b97eac044..523627aa8b 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -69,7 +69,8 @@ use crate::error::TrustedServerError; use crate::html_processor::BodyCloseInjection; use crate::http_util::{RequestInfo, is_navigation_request, serve_static_with_etag}; use crate::integrations::{IntegrationDocumentState, IntegrationRegistry, IntegrationRequestState}; -use crate::middleware::{MiddlewarePhase, media_type_of}; +use crate::middleware::{MiddlewarePhase, Prepared, Refusal, media_type_of}; +use crate::module_context::{ModuleContext, ModuleRequest}; use crate::permissions::PermissionState; use crate::platform::{ GeoInfo, PlatformBackendSpec, PlatformHttpRequest, RuntimeServices, @@ -647,14 +648,16 @@ struct ProcessResponseParams<'a> { shared_template_authorized: bool, /// See [`HtmlStreamProcessorParams::csp_nonce_observed`]. csp_nonce_observed: Option<&'a Arc>, + prepared: &'a Arc, } struct PublisherBodyProcessor { inner: Box, } -/// A response that is not HTML, as the middleware the entries covering it -/// are told of it. +/// A response as the middleware the entries covering it are told of it: +/// any document for its preparation, and one that is not HTML for the +/// stream processors run on its body. struct TextDocument<'a> { /// The type and subtype of the response, in lower case. media_type: String, @@ -664,6 +667,8 @@ struct TextDocument<'a> { origin_host: &'a str, request_state: &'a IntegrationRequestState, max_buffered_script_bytes: usize, + /// What the middleware prepared for the document. + prepared: &'a Prepared, } /// The stream processors the entries covering a response that is not HTML @@ -711,6 +716,7 @@ fn middleware_stream_processors( origin_host: document.origin_host, document_state: &document_state, max_buffered_script_bytes: document.max_buffered_script_bytes, + prepared: document.prepared, }) .map_err(|message| Report::new(TrustedServerError::Configuration { message }))?; processors.extend(plan.processors); @@ -718,6 +724,75 @@ fn middleware_stream_processors( Ok(processors) } +/// Runs the preparation of the middleware the entries of `phases` select +/// for `document`, each as its module with the request's services, keeping +/// what they prepare in the document's [`Prepared`]. +/// +/// # Errors +/// +/// The first refusal, which ends the request. +async fn prepare_document( + document: &TextDocument<'_>, + phases: &[MiddlewarePhase], + settings: &Settings, + integration_registry: &IntegrationRegistry, + module_context: &ModuleContext<'_>, +) -> Result<(), Refusal> { + use crate::middleware::{MiddlewareContext, is_dispatched_media_type}; + + if !is_dispatched_media_type(&document.media_type) { + return Ok(()); + } + for phase in phases { + let chain = integration_registry.middleware_chain( + settings.phase_entries(*phase), + *phase, + &document.media_type, + document.request_path, + ); + if chain.is_empty() { + continue; + } + let document_state = IntegrationDocumentState::default(); + if *phase == MiddlewarePhase::Serve { + document.request_state.seed(&document_state); + } + chain + .prepare( + module_context, + &MiddlewareContext { + phase: *phase, + request_host: document.request_host, + request_scheme: document.request_scheme, + origin_host: document.origin_host, + document_state: &document_state, + max_buffered_script_bytes: document.max_buffered_script_bytes, + prepared: document.prepared, + }, + ) + .await?; + } + Ok(()) +} + +/// The response a refusal answers the request with: its status, +/// `Retry-After` when it gives one, the message as plain text, and no +/// store. +fn refusal_response(refusal: &Refusal) -> Result, Report> { + let mut builder = Response::builder() + .status(refusal.status) + .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") + .header(header::CACHE_CONTROL, "no-store"); + if let Some(after) = refusal.retry_after { + builder = builder.header(header::RETRY_AFTER, after.as_secs().to_string()); + } + builder + .body(EdgeBody::from(refusal.message.clone().into_bytes())) + .change_context(TrustedServerError::Proxy { + message: "failed to build a middleware's refusal".to_string(), + }) +} + /// A processor followed by the ones the entries add, each handed what the /// one before produced. struct WithStreamProcessors { @@ -777,6 +852,7 @@ impl PublisherBodyProcessor { request_state: params.request_state.clone(), shared_template_authorized: params.template_cache_key.is_some(), csp_nonce_observed: params.csp_nonce_observed.clone(), + prepared: Arc::clone(¶ms.prepared), deferred_inline_marker: inline_seam_token .as_ref() .map(|token| String::from_utf8_lossy(token).into_owned()), @@ -806,6 +882,7 @@ impl PublisherBodyProcessor { origin_host: ¶ms.origin_host, request_state: ¶ms.request_state, max_buffered_script_bytes: settings.publisher.max_buffered_body_bytes, + prepared: ¶ms.prepared, }, phases, settings, @@ -887,6 +964,7 @@ fn process_response_streaming( request_state: params.request_state.clone(), shared_template_authorized: params.shared_template_authorized, csp_nonce_observed: params.csp_nonce_observed.cloned(), + prepared: Arc::clone(params.prepared), deferred_inline_marker: None, })?; StreamingPipeline::new(config, processor) @@ -919,6 +997,7 @@ fn process_response_streaming( origin_host: params.origin_host, request_state: params.request_state, max_buffered_script_bytes: params.settings.publisher.max_buffered_body_bytes, + prepared: params.prepared, }, phases, params.settings, @@ -1479,6 +1558,7 @@ struct HtmlStreamProcessorParams<'a> { csp_nonce_observed: Option>, /// Request-specific parser marker used by a pending inline auction. deferred_inline_marker: Option, + prepared: Arc, } /// The marker emitted at the `` seam under [`AssemblyMode::Esi`], reserving the @@ -1668,7 +1748,8 @@ fn create_html_stream_processor( .with_ad_state(params.ad_slots_script, params.ad_bids_state) .with_request_state(params.request_state) .with_body_close(body_close) - .with_csp_nonce_observer(csp_nonce_observed); + .with_csp_nonce_observer(csp_nonce_observed) + .with_prepared(params.prepared); // The middleware of the first [[fetch]] entry covering this path. What // they leave is what a shared template stores, and they are handed @@ -1722,6 +1803,7 @@ fn reader_serve_processor( origin_host: &origin_host, request_state: ¶ms.request_state, max_buffered_script_bytes: settings.publisher.max_buffered_body_bytes, + prepared: ¶ms.prepared, }, &[MiddlewarePhase::Serve], settings, @@ -1751,6 +1833,7 @@ fn reader_serve_processor( origin_host: &origin_host, request_state: ¶ms.request_state, max_buffered_script_bytes: settings.publisher.max_buffered_body_bytes, + prepared: ¶ms.prepared, }, ) .map_err(|message| Report::new(TrustedServerError::Configuration { message })) @@ -2011,6 +2094,9 @@ pub struct OwnedProcessResponseParams { /// rescanned from the output, which cannot tell a `nonce` attribute from the same /// word inside a script. pub(crate) csp_nonce_observed: Option>, + /// What the middleware prepared for this document, see + /// [`Middleware::prepare`](crate::middleware::Middleware::prepare). + pub(crate) prepared: Arc, } /// Response-authorized template cache insert inputs. The key is built before origin lookup; the @@ -2438,6 +2524,7 @@ fn build_template_assembly_params( ) -> OwnedProcessResponseParams { OwnedProcessResponseParams { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), // Already stored; storing again on a hit would be pointless work. template_cache_key: None, seam_ad_slots: None, @@ -3247,6 +3334,7 @@ pub fn stream_publisher_body( request_state: ¶ms.request_state, shared_template_authorized: params.template_cache_key.is_some(), csp_nonce_observed: params.csp_nonce_observed.as_ref(), + prepared: ¶ms.prepared, }; let input_compression = Compression::from_content_encoding(¶ms.content_encoding); let output_compression = if params.template_cache_key.is_some() { @@ -3341,6 +3429,7 @@ pub async fn stream_publisher_body_async( true, ); let mut processor = match create_html_stream_processor(HtmlStreamProcessorParams { + prepared: Arc::clone(¶ms.prepared), origin_host: ¶ms.origin_host, request_host: ¶ms.request_host, request_scheme: ¶ms.request_scheme, @@ -5483,6 +5572,49 @@ pub async fn handle_publisher_request( params.dispatched_auction = dispatched_auction.take(); params.auction_observation = auction_observation.take(); params.auction_request = auction_request_for_telemetry.clone(); + let prepared = Arc::new(Prepared::default()); + let module_request = ModuleRequest::new( + &request_method, + request_host, + request_scheme, + &request_path, + ); + let module_context = + ModuleContext::new(module_request).with_request_state(ec_context, services); + let stored_origin_host = settings.publisher.origin_host(); + let document = TextDocument { + media_type: crate::middleware::HTML_MEDIA_TYPE.to_owned(), + request_path: &request_path, + request_host, + request_scheme, + origin_host: &stored_origin_host, + request_state: ¶ms.request_state, + max_buffered_script_bytes: settings.publisher.max_buffered_body_bytes, + prepared: &prepared, + }; + if let Err(refusal) = prepare_document( + &document, + &[MiddlewarePhase::Serve], + settings, + integration_registry, + &module_context, + ) + .await + { + if let Some(dispatched) = params.dispatched_auction.take() { + emit_abandoned_auction( + services, + params.auction_observation.take(), + dispatched, + "middleware_refusal", + ) + .await; + } + let mut response = refusal_response(&refusal)?; + enforce_terminal_private_cache_privacy(&mut response); + return Ok(PublisherResponse::Buffered(response)); + } + params.prepared = prepared; return Ok(PublisherResponse::AssembleTemplate { response, template: entry.body, @@ -5772,6 +5904,54 @@ pub async fn handle_publisher_request( .trim() .to_ascii_lowercase(); let route = classify_response_route(status, &content_type, &content_encoding, request_host); + + // What the middleware the entries name for this document prepare, with + // the request's services, before the document is processed. A refusal + // answers the request here, and nothing is stored. + let prepared = Arc::new(Prepared::default()); + if route == ResponseRoute::Stream { + let phases: &[MiddlewarePhase] = if template_cache_key.is_some() { + &[MiddlewarePhase::Fetch] + } else { + &MiddlewarePhase::ALL + }; + let module_request = + ModuleRequest::new(&request_method, request_host, request_scheme, &request_path); + let module_context = + ModuleContext::new(module_request).with_request_state(ec_context, services); + let document = TextDocument { + media_type: media_type_of(&content_type), + request_path: &request_path, + request_host, + request_scheme, + origin_host: &origin_host, + request_state: &request_state, + max_buffered_script_bytes: settings.publisher.max_buffered_body_bytes, + prepared: &prepared, + }; + if let Err(refusal) = prepare_document( + &document, + phases, + settings, + integration_registry, + &module_context, + ) + .await + { + if let Some(dispatched) = dispatched_auction.take() { + emit_abandoned_auction( + services, + auction_observation.take(), + dispatched, + "middleware_refusal", + ) + .await; + } + let mut response = refusal_response(&refusal)?; + enforce_terminal_private_cache_privacy(&mut response); + return Ok(PublisherResponse::Buffered(response)); + } + } if template_cache_key.is_some() { set_response_compression(&mut response, reader_compression); } @@ -5878,6 +6058,7 @@ pub async fn handle_publisher_request( auction_request: auction_request_for_telemetry, dispatched_auction, price_granularity, + prepared, }), }) } @@ -9502,6 +9683,7 @@ mod tests { ) -> OwnedProcessResponseParams { OwnedProcessResponseParams { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), template_cache_key: None, seam_ad_slots: None, policy_headers: Vec::new(), @@ -9808,6 +9990,7 @@ mod tests { let config = HtmlProcessorConfig { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), origin_host: "origin.example.com".to_string(), request_host: "example.com".to_string(), request_scheme: "https".to_string(), @@ -20792,6 +20975,7 @@ mod tests { let registry = IntegrationRegistry::new(settings).expect("should build a registry"); let params = OwnedProcessResponseParams { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), template_cache_key: None, seam_ad_slots: None, policy_headers: Vec::new(), @@ -20865,6 +21049,7 @@ mod tests { let services = noop_services(); let mut params = OwnedProcessResponseParams { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), template_cache_key: None, seam_ad_slots: None, policy_headers: Vec::new(), @@ -20944,12 +21129,22 @@ mod tests { /// fixture in the entries given and the origin answering `status` with /// a page. async fn proxy(fetch: Vec, method: Method, path: &str, status: u16) -> Proxied { - let settings = settings_running_the_middleware_fixture(fetch); + proxy_with( + settings_running_the_middleware_fixture(fetch), + method, + path, + status, + ) + .await + } + + /// As [`proxy`], with the settings given. + async fn proxy_with(settings: Settings, method: Method, path: &str, status: u16) -> Proxied { let registry = IntegrationRegistry::new(&settings).expect("should build a registry"); let stub = Arc::new(StubHttpClient::new()); stub.push_response_with_headers( status, - b"the origin's page".to_vec(), + b"the origin's page".to_vec(), vec![("content-type", "text/html; charset=utf-8")], ); let services = build_services_with_http_client( @@ -21135,6 +21330,142 @@ mod tests { ); } + #[tokio::test] + async fn what_a_middleware_prepares_reaches_its_create() { + use crate::integrations::registry_test_support::middleware_fixture as fixture; + use crate::middleware::HTML_MEDIA_TYPE; + + let proxied = proxy( + vec![PhaseEntry { + media_type: HTML_MEDIA_TYPE.to_owned(), + path: None, + middleware: vec![fixture::PREPARING.to_owned()], + }], + Method::GET, + "/article", + 200, + ) + .await; + let PublisherResponse::Stream { body, params, .. } = proxied.response else { + panic!("a page should stream through the entry's middleware"); + }; + let mut params = *params; + let mut output = Vec::new(); + stream_publisher_body_async( + body, + &mut output, + &mut params, + &proxied.settings, + &proxied.registry, + &proxied.orchestrator, + &proxied.services, + ) + .await + .expect("should stream the page"); + let page = String::from_utf8(output).expect("html"); + + assert!( + page.contains(&fixture::prepared_marker( + fixture::PREPARING, + "publisher.example" + )), + "should write what the preparation answered, called as the middleware for the \ + reader's host: {page}" + ); + assert!( + !page.contains(fixture::NOTHING_PREPARED), + "should not ask create before the preparation" + ); + } + + #[tokio::test] + async fn a_serve_middleware_prepares_for_each_reader() { + use crate::integrations::registry_test_support::middleware_fixture as fixture; + use crate::middleware::HTML_MEDIA_TYPE; + + let mut settings = settings_running_the_middleware_fixture(Vec::new()); + settings.serve = PhaseEntries::new(vec![PhaseEntry { + media_type: HTML_MEDIA_TYPE.to_owned(), + path: None, + middleware: vec![fixture::PREPARING.to_owned()], + }]); + let proxied = proxy_with(settings, Method::GET, "/article", 200).await; + let PublisherResponse::Stream { body, params, .. } = proxied.response else { + panic!("a page should stream through the entry's middleware"); + }; + let mut params = *params; + let mut output = Vec::new(); + stream_publisher_body_async( + body, + &mut output, + &mut params, + &proxied.settings, + &proxied.registry, + &proxied.orchestrator, + &proxied.services, + ) + .await + .expect("should stream the page"); + let page = String::from_utf8(output).expect("html"); + + assert!( + page.contains(&fixture::prepared_marker( + fixture::PREPARING, + "publisher.example" + )), + "should prepare in the serve phase for this reader: {page}" + ); + } + + #[tokio::test] + async fn a_refusal_answers_the_request_with_its_status_and_stores_nothing() { + use crate::integrations::registry_test_support::middleware_fixture as fixture; + use crate::middleware::HTML_MEDIA_TYPE; + + let proxied = proxy( + vec![PhaseEntry { + media_type: HTML_MEDIA_TYPE.to_owned(), + path: None, + middleware: vec![fixture::REFUSING.to_owned()], + }], + Method::GET, + "/article", + 200, + ) + .await; + let PublisherResponse::Buffered(response) = proxied.response else { + panic!("a refusal is answered whole, with no document to stream"); + }; + let header_value = |name: header::HeaderName| { + response + .headers() + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) + }; + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(header_value(header::RETRY_AFTER).as_deref(), Some("30")); + assert_eq!( + header_value(header::CONTENT_TYPE).as_deref(), + Some("text/plain; charset=utf-8") + ); + assert_eq!( + header_value(header::CACHE_CONTROL).as_deref(), + Some("no-store, private"), + "should be stored and shared by nothing" + ); + assert!( + response + .extensions() + .get::() + .is_some(), + "should be marked as this request's alone" + ); + let body = response.into_body().into_bytes().expect("a whole body"); + assert_eq!(String::from_utf8_lossy(&body), fixture::REFUSAL_MESSAGE); + } + #[test] fn tsjs_dynamic_serves_a_standalone_module_by_registration_flag_not_by_name() { // The module the registration marks standalone is one a vendor crate @@ -21312,6 +21643,7 @@ mod tests { let body = EdgeBody::from(compressed); let params = OwnedProcessResponseParams { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), template_cache_key: None, seam_ad_slots: None, policy_headers: Vec::new(), @@ -21375,6 +21707,7 @@ mod tests { IntegrationRegistry::new(&settings).expect("should create integration registry"); let params = OwnedProcessResponseParams { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), template_cache_key: None, seam_ad_slots: None, policy_headers: Vec::new(), @@ -21459,6 +21792,7 @@ mod tests { ), params: Box::new(OwnedProcessResponseParams { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), template_cache_key: None, seam_ad_slots: None, policy_headers: Vec::new(), @@ -21552,6 +21886,7 @@ mod tests { let params = || { Box::new(OwnedProcessResponseParams { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), template_cache_key: None, seam_ad_slots: None, policy_headers: Vec::new(), @@ -21660,6 +21995,7 @@ mod tests { let params = OwnedProcessResponseParams { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), template_cache_key: None, seam_ad_slots: None, policy_headers: Vec::new(), @@ -21709,6 +22045,7 @@ mod tests { .expect("should create integration registry"); let params = OwnedProcessResponseParams { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), template_cache_key: None, seam_ad_slots: None, policy_headers: Vec::new(), @@ -21838,6 +22175,7 @@ mod tests { let services = noop_services(); let mut params = OwnedProcessResponseParams { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), template_cache_key: None, seam_ad_slots: None, policy_headers: Vec::new(), @@ -21905,6 +22243,7 @@ mod tests { let services = noop_services(); let mut params = OwnedProcessResponseParams { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), template_cache_key: None, seam_ad_slots: None, policy_headers: Vec::new(), @@ -21975,6 +22314,7 @@ mod tests { let services = noop_services(); let mut params = OwnedProcessResponseParams { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), template_cache_key: None, seam_ad_slots: None, policy_headers: Vec::new(), @@ -22045,6 +22385,7 @@ mod tests { let services = noop_services(); let mut params = OwnedProcessResponseParams { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), template_cache_key: None, seam_ad_slots: None, policy_headers: Vec::new(), @@ -22115,6 +22456,7 @@ mod tests { let services = noop_services(); let mut params = OwnedProcessResponseParams { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), template_cache_key: None, seam_ad_slots: None, policy_headers: Vec::new(), @@ -22165,6 +22507,7 @@ mod tests { fn non_html_stream_params(content_encoding: &str) -> OwnedProcessResponseParams { OwnedProcessResponseParams { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), template_cache_key: None, seam_ad_slots: None, policy_headers: Vec::new(), @@ -22391,6 +22734,7 @@ mod tests { let state = AdBidsState::default(); let mut params = OwnedProcessResponseParams { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), template_cache_key: None, seam_ad_slots: None, policy_headers: Vec::new(), @@ -22469,6 +22813,7 @@ mod tests { let state = AdBidsState::default(); let mut params = OwnedProcessResponseParams { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), template_cache_key: None, seam_ad_slots: None, policy_headers: Vec::new(), @@ -22549,6 +22894,7 @@ mod tests { let services = noop_services(); let mut params = OwnedProcessResponseParams { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), template_cache_key: None, seam_ad_slots: None, policy_headers: Vec::new(), @@ -22623,6 +22969,7 @@ mod tests { .expect("should build response"); let params = OwnedProcessResponseParams { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), template_cache_key: None, seam_ad_slots: None, policy_headers: Vec::new(), @@ -22779,6 +23126,7 @@ mod tests { ) -> OwnedProcessResponseParams { OwnedProcessResponseParams { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), template_cache_key: None, seam_ad_slots: None, policy_headers: Vec::new(), @@ -23455,6 +23803,7 @@ mod tests { EcContext::new_for_test(None, crate::consent::types::ConsentContext::default()); OwnedProcessResponseParams { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), template_cache_key: None, seam_ad_slots: None, policy_headers: Vec::new(), @@ -23653,6 +24002,7 @@ mod tests { .collect(); let params = OwnedProcessResponseParams { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), template_cache_key: None, seam_ad_slots: None, policy_headers: Vec::new(), @@ -23735,6 +24085,7 @@ mod tests { let state = AdBidsState::with_script(bids_script); let params = OwnedProcessResponseParams { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), template_cache_key: None, seam_ad_slots: None, policy_headers: Vec::new(), @@ -23800,6 +24151,7 @@ mod tests { // error as soon as it tries to read the gzip header. let params = OwnedProcessResponseParams { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), template_cache_key: None, seam_ad_slots: None, policy_headers: Vec::new(), @@ -23920,6 +24272,7 @@ mod tests { let params = OwnedProcessResponseParams { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), template_cache_key: None, seam_ad_slots: None, policy_headers: Vec::new(), @@ -23980,6 +24333,7 @@ mod tests { let html = br#""#; let params = OwnedProcessResponseParams { csp_nonce_observed: None, + prepared: Arc::new(Prepared::default()), template_cache_key: None, seam_ad_slots: None, policy_headers: Vec::new(), From 012d5bff8104764ee2c625a15b765e887cb187e3 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 11 Oct 2026 09:31:22 +0100 Subject: [PATCH 02/11] The integration guide describes a middleware that prepares The section says when a middleware prepares, what it is called with, where its answer goes, how a refusal is answered, and what each phase's preparation must not depend on. The changelog records the addition. --- CHANGELOG.md | 1 + docs/guide/integration-guide.md | 50 +++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3d44b2a9c..4c120f5e10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- A middleware may prepare, and may refuse. `Middleware::prepare` runs in the request's handler before the document is processed, as the middleware's module with the request's services, and what it answers is handed to `create` under the middleware's name, so a decision that needs a store or the network is made there while `create` stays synchronous. A refusal answers the request with its status and `Retry-After`, with no document, and nothing is stored. - A file the origin does not have is made by the middleware a `[[fetch]]` entry with a `path` names for it. When the origin answers `404 Not Found` for that path, the middleware runs on an empty document of the entry's media type and its output is the file, so `ads.txt` is made from the sellers lines alone where the publisher has none. With no such entry the origin's answer stands. - `[ads-txt] modules = ["sellers"]` adds the lines `[ads-txt.sellers] lines` gives to the publisher's `ads.txt`, from `crates/ads-txt/sellers`. The module is a middleware for `text/plain`, named in a `[[fetch]]` entry on the file's path, which passes the origin's file through and writes the lines after its last one, so the publisher's file stays authoritative, and a publisher whose origin has no file gets one made of the lines. Each line is checked as the settings load as a data record or a variable of the ads.txt grammar, and a selected module no entry places is refused. - A `[[fetch]]` or `[[serve]]` entry may cover a media type other than `text/html`: another `text/` type except `text/x-component`, `application/javascript`, `application/json` or `image/svg+xml`, written as a bare `type/subtype` in lower case. A middleware says which media types it works on with `handles_media_type`, HTML alone unless it says otherwise, and an entry naming one under a media type it does not handle is refused when the registry is built. On a media type other than HTML a middleware's stream processor alone runs on the body, after core has moved the origin's addresses, in the fetch phase and then, for a response that is not stored, the serve phase, and an action that decides about HTML there is refused by name. diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index cf57fc81b6..cbe18865c6 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -243,6 +243,56 @@ The entry that places it names the media type and, where the change is for one file, its path. See "Entries for a media type other than HTML" in the [configuration guide](/guide/configuration#entries-for-a-media-type-other-than-html). +### A middleware that prepares, and one that refuses + +A middleware whose decision needs the request's services, or has to wait on +the key-value store or the network, answers `prepare` with a future. Core +runs it in the request's handler, before the document is processed, as the +module the middleware belongs to and with the permissions it declares in +`required_permissions`, so the module call carries what a request hook or a +robots.txt contributor is given. What the future answers is kept under the +middleware's name in the context's `prepared`, and `create`, which stays +synchronous, takes it from there as the type it prepared. A middleware that +prepares nothing leaves `prepare` at its default and is asked `create` as +before. + +```rust +impl Middleware for Sellers { + fn prepare<'a>( + &'a self, + call: ModuleCall<'a>, + context: &'a MiddlewareContext<'a>, + ) -> Option> { + Some(Box::pin(async move { + let lines = self.lines_for(call, context.request_host).await?; + Ok(Box::new(lines) as Box) + })) + } + + fn create(&self, context: &MiddlewareContext<'_>) -> MiddlewareAction { + let lines: Vec = context.prepared.take(MODULE).unwrap_or_default(); + MiddlewareAction { + stream: Some(Box::new(AppendLines::new(lines))), + ..MiddlewareAction::pass() + } + } +} +``` + +The future may answer a `Refusal` instead, with a status, an optional +`Retry-After` and a message. The request is then answered with that status +and the message as plain text, no document is processed, and nothing is +stored or served from the store, so a middleware that cannot make the +document it is responsible for fails closed rather than letting an empty or +unchanged one through. `Refusal::service_unavailable` is the one for a +source that cannot answer now. The preparations of one document run in the +order the entry names the middleware, and the first refusal ends them. + +In the fetch phase a preparation is made once for the document that may be +stored, so it must not depend on the reader. In the serve phase it is made +for each reader's copy, on a document fetched for the request and on one +read from the store alike. + ## Proxy implementation rules An `IntegrationProxy::handle` implementation receives the request and its From 9aebc6671887b8c264072f4331ecb690a1a8d1a0 Mon Sep 17 00:00:00 2001 From: James Rogers Date: Mon, 12 Oct 2026 15:19:04 +0100 Subject: [PATCH 03/11] A fetch preparation of a document that may be stored is not handed the reader A document that may be stored is prepared with the request's services alone, so no reader permission or Edge Cookie reaches the shared template. Retry-After rounds up to whole seconds. TextDocument is renamed ResponseDocument, the prepared field docs use one phrase, and the guide states which phases are prepared. --- .../trusted-server-core/src/html_processor.rs | 3 +- crates/trusted-server-core/src/middleware.rs | 7 -- crates/trusted-server-core/src/publisher.rs | 88 +++++++++++++++++-- docs/guide/integration-guide.md | 13 +-- 4 files changed, 88 insertions(+), 23 deletions(-) diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index c1691d5e52..03b35dfbb3 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -317,7 +317,8 @@ pub struct ReaderDocument<'a> { pub request_state: &'a IntegrationRequestState, /// The most a middleware may hold of one script while it decides. pub max_buffered_script_bytes: usize, - /// What the middleware prepared for the reader's copy. + /// What the middleware prepared for this document, see + /// [`Middleware::prepare`](crate::middleware::Middleware::prepare). pub prepared: &'a Prepared, } diff --git a/crates/trusted-server-core/src/middleware.rs b/crates/trusted-server-core/src/middleware.rs index eb7ef7b7ff..718ecc201c 100644 --- a/crates/trusted-server-core/src/middleware.rs +++ b/crates/trusted-server-core/src/middleware.rs @@ -457,13 +457,6 @@ pub trait Middleware: Send + Sync { /// Work done for one document before `create` is asked, where deciding /// needs the request's services or has to wait, on a store or the /// network. `None`, the default, means none is needed. - /// - /// The future runs in the request's handler before the document is - /// processed, with `call` carrying what the request's services allow this - /// middleware. What it answers is kept under this middleware's name in - /// the context's [`Prepared`], for `create` to take. A refusal answers - /// the request with the status it carries and no document, and nothing - /// is stored. fn prepare<'a>( &'a self, call: ModuleCall<'a>, diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 0fe6cd403f..55df948e57 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -669,7 +669,8 @@ struct TextResponse<'a> { origin_host: &'a str, request_state: &'a IntegrationRequestState, max_buffered_script_bytes: usize, - /// What the middleware prepared for the document. + /// What the middleware prepared for this document, see + /// [`Middleware::prepare`](crate::middleware::Middleware::prepare). prepared: &'a Prepared, } @@ -731,7 +732,9 @@ fn middleware_stream_processors( /// Runs the preparation of the middleware the entries of `phases` select /// for `document`, each as its module with the request's services, keeping -/// what they prepare in the document's [`Prepared`]. +/// what they prepare in the document's [`Prepared`]. Each phase's +/// preparations are handed a fresh document state, not the one the plan is +/// handed, so anything a preparation writes there is dropped. /// /// # Errors /// @@ -781,6 +784,24 @@ async fn prepare_document( Ok(()) } +/// The context the preparations of a document are called with. A document +/// that may be stored is prepared in the fetch phase alone, and is handed +/// the request's services and nothing of the reader, because what its +/// preparations answer is stored with it. +fn preparation_context<'r>( + request: ModuleRequest<'r>, + stored: bool, + ec_context: &'r EcContext, + services: &'r RuntimeServices, +) -> ModuleContext<'r> { + let context = ModuleContext::new(request); + if stored { + context.with_services(services) + } else { + context.with_request_state(ec_context, services) + } +} + /// The response a refusal answers the request with: its status, /// `Retry-After` when it gives one, the message as plain text, and no /// store. @@ -790,7 +811,8 @@ fn refusal_response(refusal: &Refusal) -> Result, Report 0); + builder = builder.header(header::RETRY_AFTER, seconds.to_string()); } builder .body(EdgeBody::from(refusal.message.clone().into_bytes())) @@ -5869,20 +5891,17 @@ pub async fn handle_publisher_request( .to_ascii_lowercase(); let route = classify_response_route(status, &content_type, &content_encoding, request_host); - // What the middleware the entries name for this document prepare, with - // the request's services, before the document is processed. A refusal - // answers the request here, and nothing is stored. let prepared = Arc::new(Prepared::default()); if route == ResponseRoute::Stream { - let phases: &[MiddlewarePhase] = if template_cache_key.is_some() { + let stored = template_cache_key.is_some(); + let phases: &[MiddlewarePhase] = if stored { &[MiddlewarePhase::Fetch] } else { &MiddlewarePhase::ALL }; let module_request = ModuleRequest::new(&request_method, request_host, request_scheme, &request_path); - let module_context = - ModuleContext::new(module_request).with_request_state(ec_context, services); + let module_context = preparation_context(module_request, stored, ec_context, services); let document = TextResponse { content_type: &content_type, request_path: &request_path, @@ -21553,6 +21572,57 @@ mod tests { ); } + #[test] + fn a_refusal_rounds_retry_after_up_to_whole_seconds() { + let response = refusal_response(&Refusal::service_unavailable( + std::time::Duration::from_millis(1_500), + "later", + )) + .expect("should build the refusal"); + + assert_eq!( + response + .headers() + .get(header::RETRY_AFTER) + .and_then(|value| value.to_str().ok()), + Some("2"), + "should not ask the caller back before the refusal's time" + ); + } + + #[test] + fn a_fetch_preparation_of_a_document_that_may_be_stored_is_not_handed_the_reader() { + use crate::module_context::{EdgeCookie, FromModuleContext}; + + let ec_context = EcContext::new_for_test( + Some(crate::ec::tests::CANONICAL_COOKIE_VALUE.to_owned()), + ConsentContext::default(), + ) + .with_module_for_test(Arc::new(crate::ec::tests::CanonicalizingModule)); + let services = noop_services(); + let method = Method::GET; + let handed = |stored: bool| { + let request = ModuleRequest::new(&method, "publisher.example", "https", "/article"); + let context = preparation_context(request, stored, &ec_context, &services); + let call = context.call("testing.preparation", PermissionSet::none()); + ( + <&PermissionState>::from_module_context(&call).is_ok(), + EdgeCookie::from_module_context(&call).is_ok(), + ) + }; + + assert_eq!( + handed(false), + (true, true), + "should hand a document that is not stored the reader's permissions and Edge Cookie" + ); + assert_eq!( + handed(true), + (false, false), + "should hand a document that may be stored neither the reader's permissions nor Edge Cookie" + ); + } + #[test] fn tsjs_dynamic_serves_a_standalone_module_by_registration_flag_not_by_name() { // The module the registration marks standalone is one a vendor crate diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index a891bed5aa..6bf5e37a86 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -249,8 +249,7 @@ A middleware whose decision needs the request's services, or has to wait on the key-value store or the network, answers `prepare` with a future. Core runs it in the request's handler, before the document is processed, as the module the middleware belongs to and with the permissions it declares in -`required_permissions`, so the module call carries what a request hook or a -robots.txt contributor is given. What the future answers is kept under the +`required_permissions`. What the future answers is kept under the middleware's name in the context's `prepared`, and `create`, which stays synchronous, takes it from there as the type it prepared. A middleware that prepares nothing leaves `prepare` at its default and is asked `create` as @@ -288,10 +287,12 @@ unchanged one through. `Refusal::service_unavailable` is the one for a source that cannot answer now. The preparations of one document run in the order the entry names the middleware, and the first refusal ends them. -In the fetch phase a preparation is made once for the document that may be -stored, so it must not depend on the reader. In the serve phase it is made -for each reader's copy, on a document fetched for the request and on one -read from the store alike. +On a document that may be stored only the fetch phase is prepared, and its +preparations are handed the request's services and nothing of the reader, +because what they answer is stored with the document. The serve middleware +of the reader it was fetched for then find nothing prepared. On a document +that is not stored both phases are prepared, and on a copy read from the +store the serve phase alone. ## Proxy implementation rules From 1c0c244f7173964d7905e689f1d3b7423a2bd880 Mon Sep 17 00:00:00 2001 From: James Rogers Date: Mon, 12 Oct 2026 22:54:52 +0100 Subject: [PATCH 04/11] What a middleware prepares is kept for the phase it prepared in A document that is not stored is prepared in both phases before either is planned, and both kept what they prepared in one store under the middleware's name alone. A middleware running in both phases had its fetch preparation replaced by its serve one, so the fetch create took the serve value and the serve create took nothing. Prepared is now keyed by phase and name, and MiddlewareContext::take_prepared takes what was prepared in the context's own phase. --- .../src/integrations/registry.rs | 3 +- crates/trusted-server-core/src/middleware.rs | 50 +++++++--- .../src/middleware/tests.rs | 93 +++++++++++++++++-- docs/guide/integration-guide.md | 2 +- 4 files changed, 122 insertions(+), 26 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index d274035759..5456149370 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -2973,8 +2973,7 @@ pub(crate) mod test_support { fn create(&self, context: &MiddlewareContext<'_>) -> MiddlewareAction { let marker: String = context - .prepared - .take(PREPARING) + .take_prepared(PREPARING) .unwrap_or_else(|| NOTHING_PREPARED.to_owned()); MiddlewareAction { head_inserts: vec![marker], diff --git a/crates/trusted-server-core/src/middleware.rs b/crates/trusted-server-core/src/middleware.rs index 63bb3d1a62..b8551b442b 100644 --- a/crates/trusted-server-core/src/middleware.rs +++ b/crates/trusted-server-core/src/middleware.rs @@ -152,6 +152,15 @@ pub struct MiddlewareContext<'a> { pub prepared: &'a Prepared, } +impl MiddlewareContext<'_> { + /// What the middleware named `id` prepared for this phase, see + /// [`Prepared::take`]. + #[must_use] + pub fn take_prepared(&self, id: &str) -> Option { + self.prepared.take(self.phase, id) + } +} + /// One attribute of one matched element, as a handler is asked about it. #[derive(Debug, Clone, Copy)] pub struct MatchedAttribute<'a> { @@ -381,27 +390,34 @@ impl Refusal { /// [`Middleware::prepare`]. pub type Preparation<'a> = Pin, Refusal>> + 'a>>; -/// What the middleware of one document prepared, each under its own name, -/// for its `create` to take. +/// What the middleware of one document prepared, each under its own name +/// and phase, for its `create` in that phase to take. #[derive(Default)] -pub struct Prepared(Mutex>>); +pub struct Prepared(Mutex); + +/// What each middleware prepared, under its phase and name. +type PreparedValues = HashMap<(MiddlewarePhase, &'static str), Box>; impl Prepared { - /// Keeps what the middleware named `id` prepared. - pub fn insert(&self, id: &'static str, value: Box) { - self.lock().insert(id, value); + /// Keeps what the middleware named `id` prepared in `phase`. + pub fn insert(&self, phase: MiddlewarePhase, id: &'static str, value: Box) { + self.lock().insert((phase, id), value); } - /// What the middleware named `id` prepared, as the type it prepared, - /// taken out so it is used once. `None` when it prepared nothing or - /// something of another type. + /// What the middleware named `id` prepared in `phase`, as the type it + /// prepared, taken out so it is used once. `None` when it prepared + /// nothing or something of another type. #[must_use] - pub fn take(&self, id: &str) -> Option { + pub fn take(&self, phase: MiddlewarePhase, id: &str) -> Option { let mut values = self.lock(); - if !values.get(id)?.is::() { + let key = values + .keys() + .find(|(at, name)| *at == phase && *name == id) + .copied()?; + if !values.get(&key)?.is::() { return None; } - let value = values.remove(id)?; + let value = values.remove(&key)?; value.downcast::().ok().map(|value| *value) } @@ -411,14 +427,18 @@ impl Prepared { self.lock().is_empty() } - fn lock(&self) -> MutexGuard<'_, HashMap<&'static str, Box>> { + fn lock(&self) -> MutexGuard<'_, PreparedValues> { self.0.lock().unwrap_or_else(PoisonError::into_inner) } } impl fmt::Debug for Prepared { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - let names: Vec<&str> = self.lock().keys().copied().collect(); + let names: Vec = self + .lock() + .keys() + .map(|(phase, name)| format!("{phase} {name}")) + .collect(); formatter .debug_struct("Prepared") .field("names", &names) @@ -770,7 +790,7 @@ impl MiddlewareChain { let call = module_context.call(id, middleware.required_permissions()); if let Some(preparation) = middleware.prepare(call, context) { let value = preparation.await?; - context.prepared.insert(id, value); + context.prepared.insert(context.phase, id, value); } } Ok(()) diff --git a/crates/trusted-server-core/src/middleware/tests.rs b/crates/trusted-server-core/src/middleware/tests.rs index 9373b66b18..d95a52b8e7 100644 --- a/crates/trusted-server-core/src/middleware/tests.rs +++ b/crates/trusted-server-core/src/middleware/tests.rs @@ -1555,24 +1555,25 @@ fn the_making_entry_has_a_path_covering_the_request_and_names_a_middleware() { fn what_is_prepared_is_taken_once_by_name_and_as_its_type() { let prepared = Prepared::default(); assert!(prepared.is_empty()); - prepared.insert("example.one", Box::new(String::from("one"))); - prepared.insert("example.two", Box::new(7_u32)); + let fetch = MiddlewarePhase::Fetch; + prepared.insert(fetch, "example.one", Box::new(String::from("one"))); + prepared.insert(fetch, "example.two", Box::new(7_u32)); assert_eq!( - prepared.take::("example.one"), + prepared.take::(fetch, "example.one"), None, "should leave a value of another type where it is" ); assert_eq!( - prepared.take::("example.one").as_deref(), + prepared.take::(fetch, "example.one").as_deref(), Some("one") ); assert_eq!( - prepared.take::("example.one"), + prepared.take::(fetch, "example.one"), None, "should give what was prepared once" ); - assert_eq!(prepared.take::("example.two"), Some(7)); + assert_eq!(prepared.take::(fetch, "example.two"), Some(7)); assert!(prepared.is_empty()); } @@ -1600,8 +1601,7 @@ impl Middleware for Prepares { fn create(&self, context: &MiddlewareContext<'_>) -> MiddlewareAction { let prepared: String = context - .prepared - .take("example.prepares") + .take_prepared("example.prepares") .unwrap_or_else(|| "nothing prepared".to_owned()); MiddlewareAction { head_inserts: vec![prepared], @@ -1688,6 +1688,83 @@ async fn a_chain_keeps_what_each_middleware_prepares_under_its_name() { ); } +/// Prepares the phase it is called in, and writes it into the head. +struct PreparesItsPhase; + +impl Middleware for PreparesItsPhase { + fn middleware_id(&self) -> &'static str { + "example.both" + } + + fn phases(&self) -> &[MiddlewarePhase] { + &MiddlewarePhase::ALL + } + + fn prepare<'a>( + &'a self, + _call: ModuleCall<'a>, + context: &'a MiddlewareContext<'a>, + ) -> Option> { + let phase = context.phase.to_string(); + Some(Box::pin(async move { + Ok(Box::new(phase) as Box) + })) + } + + fn create(&self, context: &MiddlewareContext<'_>) -> MiddlewareAction { + MiddlewareAction { + head_inserts: vec![ + context + .take_prepared::("example.both") + .unwrap_or_else(|| "nothing prepared".to_owned()), + ], + ..MiddlewareAction::pass() + } + } +} + +#[tokio::test] +async fn a_middleware_preparing_in_both_phases_keeps_what_each_phase_prepared() { + let prepared = Prepared::default(); + let document_state = IntegrationDocumentState::default(); + let chains: Vec<(MiddlewarePhase, MiddlewareChain)> = MiddlewarePhase::ALL + .into_iter() + .map(|phase| { + ( + phase, + MiddlewareChain::new(phase, HTML_MEDIA_TYPE, vec![Arc::new(PreparesItsPhase)]), + ) + }) + .collect(); + // A document that is not stored is prepared in both phases before + // either phase is planned, so both preparations share one store. + for (phase, chain) in &chains { + let context = MiddlewareContext { + prepared: &prepared, + ..test_support::context(*phase, &document_state) + }; + chain + .prepare(ModuleContext::empty(), &context) + .await + .expect("should prepare"); + } + let mut plans = Vec::new(); + for (phase, chain) in &chains { + let context = MiddlewareContext { + prepared: &prepared, + ..test_support::context(*phase, &document_state) + }; + plans.push(chain.plan(&context).expect("should plan").head_inserts); + } + + assert_eq!( + plans, + vec![vec!["fetch".to_owned()], vec!["serve".to_owned()]], + "should hand each phase what was prepared in it" + ); + assert!(prepared.is_empty(), "should have given every preparation"); +} + #[tokio::test] async fn the_first_refusal_ends_the_preparation() { let chain = MiddlewareChain::new( diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index 6bf5e37a86..442e95a59c 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -269,7 +269,7 @@ impl Middleware for Sellers { } fn create(&self, context: &MiddlewareContext<'_>) -> MiddlewareAction { - let lines: Vec = context.prepared.take(MODULE).unwrap_or_default(); + let lines: Vec = context.take_prepared(MODULE).unwrap_or_default(); MiddlewareAction { stream: Some(Box::new(AppendLines::new(lines))), ..MiddlewareAction::pass() From f3935477db9b0c36d92bf6d35f8727f145b9d3f3 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Tue, 13 Oct 2026 01:33:42 +0100 Subject: [PATCH 05/11] The serve phase of a document that may be stored is prepared for its reader --- crates/trusted-server-core/src/publisher.rs | 116 +++++++++++++++----- docs/guide/integration-guide.md | 13 ++- 2 files changed, 98 insertions(+), 31 deletions(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 55df948e57..343228b174 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -730,21 +730,20 @@ fn middleware_stream_processors( Ok(processors) } -/// Runs the preparation of the middleware the entries of `phases` select -/// for `document`, each as its module with the request's services, keeping -/// what they prepare in the document's [`Prepared`]. Each phase's -/// preparations are handed a fresh document state, not the one the plan is -/// handed, so anything a preparation writes there is dropped. +/// Runs the preparation of the middleware the entries of each phase in +/// `phases` select for `document`, each as its module with the context the +/// phase is given, keeping what they prepare in the document's [`Prepared`]. +/// Each phase's preparations are handed a fresh document state, not the one +/// the plan is handed, so anything a preparation writes there is dropped. /// /// # Errors /// /// The first refusal, which ends the request. async fn prepare_document( document: &TextResponse<'_>, - phases: &[MiddlewarePhase], + phases: &[(MiddlewarePhase, &ModuleContext<'_>)], settings: &Settings, integration_registry: &IntegrationRegistry, - module_context: &ModuleContext<'_>, ) -> Result<(), Refusal> { use crate::middleware::{MiddlewareContext, is_dispatched_media_type}; @@ -752,7 +751,7 @@ async fn prepare_document( if !is_dispatched_media_type(&media_type) { return Ok(()); } - for phase in phases { + for (phase, module_context) in phases { let chain = integration_registry.middleware_chain( settings.phase_entries(*phase), *phase, @@ -784,10 +783,11 @@ async fn prepare_document( Ok(()) } -/// The context the preparations of a document are called with. A document -/// that may be stored is prepared in the fetch phase alone, and is handed -/// the request's services and nothing of the reader, because what its -/// preparations answer is stored with it. +/// The context a phase's preparations of a document are called with. The +/// fetch phase of a document that may be stored is handed the request's +/// services and nothing of the reader, because what its preparations answer +/// is stored with the document. Every other preparation, the serve phase's +/// on every document included, is handed the reader's state. fn preparation_context<'r>( request: ModuleRequest<'r>, stored: bool, @@ -802,6 +802,28 @@ fn preparation_context<'r>( } } +/// The contexts the two phases of a fetched document are prepared with. The +/// fetch phase's carries the services alone when the document may be stored, +/// because what it answers is stored with the document, and the serve +/// phase's carries the reader's state on every document, because what it +/// answers is this reader's, so the reader a document is fetched for is +/// prepared as every later reader of the stored copy is. +fn preparation_contexts<'r>( + request_method: &'r Method, + request_host: &'r str, + request_scheme: &'r str, + request_path: &'r str, + stored: bool, + ec_context: &'r EcContext, + services: &'r RuntimeServices, +) -> (ModuleContext<'r>, ModuleContext<'r>) { + let request = || ModuleRequest::new(request_method, request_host, request_scheme, request_path); + ( + preparation_context(request(), stored, ec_context, services), + preparation_context(request(), false, ec_context, services), + ) +} + /// The response a refusal answers the request with: its status, /// `Retry-After` when it gives one, the message as plain text, and no /// store. @@ -5578,10 +5600,9 @@ pub async fn handle_publisher_request( }; if let Err(refusal) = prepare_document( &document, - &[MiddlewarePhase::Serve], + &[(MiddlewarePhase::Serve, &module_context)], settings, integration_registry, - &module_context, ) .await { @@ -5893,15 +5914,15 @@ pub async fn handle_publisher_request( let prepared = Arc::new(Prepared::default()); if route == ResponseRoute::Stream { - let stored = template_cache_key.is_some(); - let phases: &[MiddlewarePhase] = if stored { - &[MiddlewarePhase::Fetch] - } else { - &MiddlewarePhase::ALL - }; - let module_request = - ModuleRequest::new(&request_method, request_host, request_scheme, &request_path); - let module_context = preparation_context(module_request, stored, ec_context, services); + let (fetch_context, serve_context) = preparation_contexts( + &request_method, + request_host, + request_scheme, + &request_path, + template_cache_key.is_some(), + ec_context, + services, + ); let document = TextResponse { content_type: &content_type, request_path: &request_path, @@ -5914,10 +5935,12 @@ pub async fn handle_publisher_request( }; if let Err(refusal) = prepare_document( &document, - phases, + &[ + (MiddlewarePhase::Fetch, &fetch_context), + (MiddlewarePhase::Serve, &serve_context), + ], settings, integration_registry, - &module_context, ) .await { @@ -21623,6 +21646,49 @@ mod tests { ); } + #[test] + fn the_serve_phase_of_a_document_that_may_be_stored_is_prepared_for_the_reader() { + use crate::module_context::{EdgeCookie, FromModuleContext}; + + let ec_context = EcContext::new_for_test( + Some(crate::ec::tests::CANONICAL_COOKIE_VALUE.to_owned()), + ConsentContext::default(), + ) + .with_module_for_test(Arc::new(crate::ec::tests::CanonicalizingModule)); + let services = noop_services(); + let method = Method::GET; + let handed = |context: &ModuleContext<'_>| { + let call = context.call("testing.preparation", PermissionSet::none()); + ( + <&PermissionState>::from_module_context(&call).is_ok(), + EdgeCookie::from_module_context(&call).is_ok(), + ) + }; + + for stored in [true, false] { + let (fetch, serve) = preparation_contexts( + &method, + "publisher.example", + "https", + "/article", + stored, + &ec_context, + &services, + ); + + assert_eq!( + handed(&fetch), + (!stored, !stored), + "the fetch phase should see the reader only when the document is not stored" + ); + assert_eq!( + handed(&serve), + (true, true), + "the serve phase should see the reader whether or not the document is stored" + ); + } + } + #[test] fn tsjs_dynamic_serves_a_standalone_module_by_registration_flag_not_by_name() { // The module the registration marks standalone is one a vendor crate diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index 442e95a59c..29b1a539f8 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -287,12 +287,13 @@ unchanged one through. `Refusal::service_unavailable` is the one for a source that cannot answer now. The preparations of one document run in the order the entry names the middleware, and the first refusal ends them. -On a document that may be stored only the fetch phase is prepared, and its -preparations are handed the request's services and nothing of the reader, -because what they answer is stored with the document. The serve middleware -of the reader it was fetched for then find nothing prepared. On a document -that is not stored both phases are prepared, and on a copy read from the -store the serve phase alone. +On a document that may be stored the fetch phase's preparations are handed +the request's services and nothing of the reader, because what they answer +is stored with the document. The serve phase is prepared with the reader's +state on every document, the one fetched for this reader as much as a copy +read from the store, so what a serve middleware finds prepared never depends +on whether its reader was the first. On a document that is not stored both +phases are prepared with the reader's state. ## Proxy implementation rules From 1b4fd72b824e4a3444a0fd03bc516cea5902478d Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 17:04:03 +0100 Subject: [PATCH 06/11] A fetch preparation of a stored document gets no client IP or client signals What the fetch phase of a document that may be stored prepares is stored with the document and served to every later reader, so the preparation must not see anything of the reader whose request fetched it. Core already gave it neither the permissions nor the Edge Cookie, but it handed over the request's services whole, and those carry the reader's client IP, the TLS details and the host's client signals (TLS JA4 and HTTP/2). The fetch phase of such a document is now prepared with StoredDocumentServices, a copy of the services made with the new RuntimeServices::without_client, which empties the client metadata and drops the host's client signals. The serve phase, whose answer is this reader's alone, is prepared with the reader's state as before. The preparing fixture now writes what its preparation could read of the reader, and a_fetch_preparation_of_a_stored_page_reads_nothing_of_the_reader drives a page through handle_publisher_request with a template cache and a reader carrying a client IP, TLS details, client signals and a User-Agent. The stored page is prepared with nothing of the reader and none of those values reach it, and the same page in inline mode, which is not stored, is prepared with the reader's client IP. Also repairs an assertion message in the pairing test that carried a run of spaces where a line continuation was meant. --- .../src/integrations/registry.rs | 79 +++++++++- .../trusted-server-core/src/platform/types.rs | 14 ++ crates/trusted-server-core/src/publisher.rs | 146 ++++++++++++++++-- 3 files changed, 222 insertions(+), 17 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 684b851dcc..b1bda71930 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -2972,6 +2972,10 @@ pub(crate) mod test_support { pub(crate) const NOTHING_PREPARED: &str = ""; + /// What the reader marker says when the preparation could read + /// nothing of the reader. + pub(crate) const NOTHING_OF_THE_READER: &str = "nothing of the reader"; + /// The marker the preparing middleware writes, naming the module it /// was called as and the host it was told of. pub(crate) fn prepared_marker(module: &str, request_host: &str) -> String { @@ -2980,6 +2984,78 @@ pub(crate) mod test_support { ) } + /// The marker the preparing middleware writes after + /// [`prepared_marker`], saying what its preparation could read of the + /// reader. + pub(crate) fn reader_marker(seen: &str) -> String { + format!("") + } + + /// What a preparation called with `call` can read of the reader, + /// through the services or as a value of its own. + fn reader_seen(call: &ModuleCall<'_>) -> String { + use crate::consent::ConsentContext; + use crate::ec::device::DeviceSignals; + use crate::evidence::RequestInfo; + use crate::geo::GeoInfo; + use crate::module_context::{EdgeCookie, FromModuleContext}; + use crate::permissions::PermissionState; + use crate::platform::{ClientInfo, RuntimeServices}; + + let mut seen = Vec::new(); + if let Ok(services) = <&RuntimeServices>::from_module_context(call) { + let client = services.client_info(); + if let Some(ip) = client.client_ip { + seen.push(format!("client IP {ip}")); + } + if client.tls_protocol.is_some() + || client.tls_cipher.is_some() + || client.tls_ja4.is_some() + || client.h2_fingerprint.is_some() + { + seen.push("TLS details".to_owned()); + } + if services.host_signals().is_some() { + seen.push("client signals".to_owned()); + } + } + if let Ok(evidence) = <&dyn RequestInfo>::from_module_context(call) { + seen.push(format!( + "evidence with user-agent {}", + evidence.user_agent() + )); + } + for (value, handed) in [ + ( + "connection", + <&ClientInfo>::from_module_context(call).is_ok(), + ), + ( + "consent", + <&ConsentContext>::from_module_context(call).is_ok(), + ), + ( + "permissions", + <&PermissionState>::from_module_context(call).is_ok(), + ), + ("geo", <&GeoInfo>::from_module_context(call).is_ok()), + ( + "device signals", + <&DeviceSignals>::from_module_context(call).is_ok(), + ), + ("Edge Cookie", EdgeCookie::from_module_context(call).is_ok()), + ] { + if handed { + seen.push(value.to_owned()); + } + } + if seen.is_empty() { + NOTHING_OF_THE_READER.to_owned() + } else { + seen.join(", ") + } + } + struct Preparing; impl Middleware for Preparing { @@ -2997,7 +3073,8 @@ pub(crate) mod test_support { context: &'a MiddlewareContext<'a>, ) -> Option> { Some(Box::pin(async move { - let marker = prepared_marker(call.module(), context.request_host); + let marker = prepared_marker(call.module(), context.request_host) + + &reader_marker(&reader_seen(&call)); Ok(Box::new(marker) as Box) })) } diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs index 837873359c..1fedbc5364 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -405,6 +405,20 @@ impl RuntimeServices { } } + /// Returns a clone of this instance carrying nothing of the client, being + /// empty client metadata and no host-computed client signals. + /// + /// For work whose answer is kept and served to other readers, which must + /// not depend on the reader whose request ran it. + #[must_use] + pub fn without_client(self) -> Self { + Self { + client_info: ClientInfo::default(), + host_signals: None, + ..self + } + } + /// Returns a clone of this instance with the Edge Cookie module replaced. /// /// Adapters use this to apply the module-supplied module selected by diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 6d51f09dac..66b73d1b31 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -809,27 +809,36 @@ async fn prepare_document( Ok(()) } +/// The request's services with nothing of the reader's connection, being +/// what the fetch phase of a document that may be stored is prepared with. +struct StoredDocumentServices(RuntimeServices); + +impl StoredDocumentServices { + fn of(services: &RuntimeServices) -> Self { + Self(services.clone().without_client()) + } +} + /// The context a phase's preparations of a document are called with. The -/// fetch phase of a document that may be stored is handed the request's -/// services and nothing of the reader, because what its preparations answer -/// is stored with the document. Every other preparation, the serve phase's -/// on every document included, is handed the reader's state. +/// fetch phase of a document that may be stored is handed `stored` and +/// nothing of the reader, because what its preparations answer is stored +/// with the document. Every other preparation, the serve phase's on every +/// document included, is handed the reader's state. fn preparation_context<'r>( request: ModuleRequest<'r>, - stored: bool, + stored: Option<&'r StoredDocumentServices>, ec_context: &'r EcContext, services: &'r RuntimeServices, ) -> ModuleContext<'r> { let context = ModuleContext::new(request); - if stored { - context.with_services(services) - } else { - context.with_request_state(ec_context, services) + match stored { + Some(StoredDocumentServices(stored)) => context.with_services(stored), + None => context.with_request_state(ec_context, services), } } /// The contexts the two phases of a fetched document are prepared with. The -/// fetch phase's carries the services alone when the document may be stored, +/// fetch phase's carries `stored` alone when the document may be stored, /// because what it answers is stored with the document, and the serve /// phase's carries the reader's state on every document, because what it /// answers is this reader's, so the reader a document is fetched for is @@ -839,14 +848,14 @@ fn preparation_contexts<'r>( request_host: &'r str, request_scheme: &'r str, request_path: &'r str, - stored: bool, + stored: Option<&'r StoredDocumentServices>, ec_context: &'r EcContext, services: &'r RuntimeServices, ) -> (ModuleContext<'r>, ModuleContext<'r>) { let request = || ModuleRequest::new(request_method, request_host, request_scheme, request_path); ( preparation_context(request(), stored, ec_context, services), - preparation_context(request(), false, ec_context, services), + preparation_context(request(), None, ec_context, services), ) } @@ -5994,12 +6003,15 @@ pub async fn handle_publisher_request( let prepared = Arc::new(Prepared::default()); if route == ResponseRoute::Stream { + let stored = template_cache_key + .is_some() + .then(|| StoredDocumentServices::of(services)); let (fetch_context, serve_context) = preparation_contexts( &request_method, request_host, request_scheme, &request_path, - template_cache_key.is_some(), + stored.as_ref(), ec_context, services, ); @@ -11839,6 +11851,100 @@ mod tests { } } + /// The client IP of the reader [`services_for_a_reader`] carries. + const READER_IP: &str = "203.0.113.9"; + /// The `User-Agent` that reader sends. + const READER_AGENT: &str = "Mozilla/5.0 ReaderAgent/1.0"; + /// The TLS JA4 fingerprint of that reader's connection. + const READER_JA4: &str = "t13d1516h2_8daaf6152771_02713d6af862"; + + /// The host's client signals for that reader. + #[derive(Debug)] + struct ReaderSignals; + + impl crate::evidence::HostSignals for ReaderSignals { + fn ja4(&self) -> Option<&str> { + Some(READER_JA4) + } + + fn h2(&self) -> Option<&str> { + Some("1:65536;4:6291456|15663105|0|m,a,s,p") + } + } + + /// [`services`], carrying the connection of one reader, being the + /// client IP, the TLS details and the host's client signals. + fn services_for_a_reader( + http_client: Arc, + cache: Arc, + ) -> RuntimeServices { + RuntimeServices::builder() + .config_store(Arc::new(NoopConfigStore)) + .secret_store(Arc::new(NoopSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(Arc::new(StubBackend)) + .http_client(http_client) + .geo(Arc::new(NoopGeo)) + .client_info(ClientInfo { + client_ip: Some(READER_IP.parse().expect("should parse the reader's IP")), + tls_protocol: Some("TLSv1.3".to_owned()), + tls_ja4: Some(READER_JA4.to_owned()), + ..ClientInfo::default() + }) + .host_signals(Arc::new(ReaderSignals)) + .template_cache(cache) + .build() + } + + #[tokio::test] + async fn a_fetch_preparation_of_a_stored_page_reads_nothing_of_the_reader() { + use crate::integrations::registry_test_support::middleware_fixture as fixture; + + for (mode, stored) in [("esi", true), ("inline", false)] { + let stub = Arc::new(StubHttpClient::new()); + let cache = Arc::new(MemoryTemplateCache::default()); + let services = services_for_a_reader(Arc::clone(&stub), Arc::clone(&cache)); + let settings = Arc::new(settings_with_fetch_entry( + mode, + "reader", + None, + &[fixture::PREPARING], + )); + let mut request = navigation_request(); + request + .headers_mut() + .insert(header::USER_AGENT, HeaderValue::from_static(READER_AGENT)); + queue_shareable_html(&stub); + + let page = page_of(run(&settings, &services, request).await).await; + + assert_eq!( + stored_cache_keys(&cache).len(), + usize::from(stored), + "should store the {mode} page only where it may be stored" + ); + if stored { + assert!( + page.contains(&fixture::reader_marker(fixture::NOTHING_OF_THE_READER)), + "should prepare a page stored for every reader with nothing of the \ + reader whose request fetched it: {page}" + ); + for value in [READER_IP, READER_AGENT, READER_JA4] { + assert!( + !page.contains(value), + "should keep {value} out of the stored page: {page}" + ); + } + } else { + assert!( + page.contains(&format!("client IP {READER_IP}")), + "should prepare a page that is not stored with the reader, or the \ + stored page proves nothing: {page}" + ); + } + } + } + /// One `[[serve]]` entry naming `names` for `path`. fn serve_entry(path: Option<&str>, names: &[&str]) -> crate::middleware::PhaseEntries { crate::middleware::PhaseEntries::new(vec![crate::middleware::PhaseEntry { @@ -22119,10 +22225,16 @@ mod tests { ) .with_module_for_test(Arc::new(crate::ec::tests::CanonicalizingModule)); let services = noop_services(); + let stored_services = StoredDocumentServices::of(&services); let method = Method::GET; let handed = |stored: bool| { let request = ModuleRequest::new(&method, "publisher.example", "https", "/article"); - let context = preparation_context(request, stored, &ec_context, &services); + let context = preparation_context( + request, + stored.then_some(&stored_services), + &ec_context, + &services, + ); let call = context.call("testing.preparation", PermissionSet::none()); ( <&PermissionState>::from_module_context(&call).is_ok(), @@ -22138,7 +22250,8 @@ mod tests { assert_eq!( handed(true), (false, false), - "should hand a document that may be stored neither the reader's permissions nor Edge Cookie" + "should hand a document that may be stored neither the reader's permissions nor \ + Edge Cookie" ); } @@ -22152,6 +22265,7 @@ mod tests { ) .with_module_for_test(Arc::new(crate::ec::tests::CanonicalizingModule)); let services = noop_services(); + let stored_services = StoredDocumentServices::of(&services); let method = Method::GET; let handed = |context: &ModuleContext<'_>| { let call = context.call("testing.preparation", PermissionSet::none()); @@ -22167,7 +22281,7 @@ mod tests { "publisher.example", "https", "/article", - stored, + stored.then_some(&stored_services), &ec_context, &services, ); From b299ac0a134b68b220eb8cd40847e2d740b99bec Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 17:04:42 +0100 Subject: [PATCH 07/11] A refusal is logged as a warning and answers with a client or server error A middleware's refusal ended the request with no log line, and core wrote whatever status the refusal carried, a 200 or a 301 included, although a refusal gives no document. MiddlewareChain::prepare now passes the first refusal through Refusal::checked, which logs it once as a warning naming the middleware, the phase and the status, and answers a status that is not a client or server error with 500 Internal Server Error, saying so in the same line. The message is not logged. The middleware tests add a_refusal_is_logged_once_as_a_warning_naming_the_middleware and a_refusal_answers_with_a_client_or_server_error_alone, and the end-to-end refusal test now checks for the one warning. The tests read the log through test_support::logs, which keeps what a test logs on its own thread. --- crates/trusted-server-core/src/middleware.rs | 38 ++++++- .../src/middleware/tests.rs | 103 ++++++++++++++++++ crates/trusted-server-core/src/publisher.rs | 12 +- .../trusted-server-core/src/test_support.rs | 50 +++++++++ 4 files changed, 196 insertions(+), 7 deletions(-) diff --git a/crates/trusted-server-core/src/middleware.rs b/crates/trusted-server-core/src/middleware.rs index 35fe3544f4..70ebe667ee 100644 --- a/crates/trusted-server-core/src/middleware.rs +++ b/crates/trusted-server-core/src/middleware.rs @@ -33,8 +33,9 @@ //! A middleware whose decision needs the request's services, or has to wait //! on a store or the network, prepares. Before the document is processed it //! is called in the request's handler, and what it answers is kept for its -//! `create` under its name. It may refuse the request instead, which is then -//! answered with the status it gives and no document, and nothing is stored. +//! `create` under its phase and name. It may refuse the request instead, +//! which is then logged and answered with the status it gives and no +//! document, and nothing is stored. use std::any::Any; use std::collections::HashMap; @@ -390,7 +391,8 @@ fn selectors_of_text(handlers: &[Box]) -> Vec { /// [`Middleware::prepare`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Refusal { - /// The status the request is answered with. + /// The status the request is answered with, a client or server error. + /// Any other is answered as `500 Internal Server Error`. pub status: StatusCode, /// What `Retry-After` says, for a status that asks the caller back. pub retry_after: Option, @@ -409,6 +411,28 @@ impl Refusal { message: message.into(), } } + + /// The refusal as the request is answered with it, logged once as a + /// warning naming `middleware`. A status that is not a client or server + /// error becomes `500 Internal Server Error`, because a refusal gives no + /// document. + fn checked(mut self, middleware: &str, phase: MiddlewarePhase) -> Self { + if self.status.is_client_error() || self.status.is_server_error() { + log::warn!( + "The middleware `{middleware}` refused the request in the {phase} phase with {}", + self.status + ); + } else { + log::warn!( + "The middleware `{middleware}` refused the request in the {phase} phase with {}, \ + which a refusal may not use, so it is answered with {}", + self.status, + StatusCode::INTERNAL_SERVER_ERROR + ); + self.status = StatusCode::INTERNAL_SERVER_ERROR; + } + self + } } /// What a middleware prepared for one document, see @@ -804,7 +828,9 @@ impl MiddlewareChain { /// /// # Errors /// - /// The first refusal, which ends the request. + /// The first refusal, which ends the request. It is logged once, as a + /// warning naming the middleware, and one whose status is not a client + /// or server error is answered as `500 Internal Server Error`. pub async fn prepare( &self, module_context: &ModuleContext<'_>, @@ -814,7 +840,9 @@ impl MiddlewareChain { let id = middleware.middleware_id(); let call = module_context.call(id, middleware.required_permissions()); if let Some(preparation) = middleware.prepare(call, context) { - let value = preparation.await?; + let value = preparation + .await + .map_err(|refusal| refusal.checked(id, context.phase))?; context.prepared.insert(context.phase, id, value); } } diff --git a/crates/trusted-server-core/src/middleware/tests.rs b/crates/trusted-server-core/src/middleware/tests.rs index 73930460bf..27bacde91b 100644 --- a/crates/trusted-server-core/src/middleware/tests.rs +++ b/crates/trusted-server-core/src/middleware/tests.rs @@ -1933,6 +1933,109 @@ async fn the_first_refusal_ends_the_preparation() { ); } +/// Refuses every request with the status it holds. +struct RefusesWith(StatusCode); + +impl Middleware for RefusesWith { + fn middleware_id(&self) -> &'static str { + "example.refuses-with" + } + + fn phases(&self) -> &[MiddlewarePhase] { + &[MiddlewarePhase::Fetch] + } + + fn prepare<'a>( + &'a self, + _call: ModuleCall<'a>, + _context: &'a MiddlewareContext<'a>, + ) -> Option> { + let status = self.0; + Some(Box::pin(async move { + Err(Refusal { + status, + retry_after: None, + message: "refused".to_owned(), + }) + })) + } + + fn create(&self, _context: &MiddlewareContext<'_>) -> MiddlewareAction { + MiddlewareAction::pass() + } +} + +/// The refusal a fetch chain of `first` and then [`Prepares`] answers, and +/// what it logs at warning level or above. +async fn refusal_and_log(first: Arc) -> (Refusal, Vec) { + let chain = MiddlewareChain::new( + MiddlewarePhase::Fetch, + HTML_MEDIA_TYPE, + vec![first, Arc::new(Prepares)], + ); + let document_state = IntegrationDocumentState::default(); + let prepared = Prepared::default(); + let context = MiddlewareContext { + prepared: &prepared, + ..test_support::context(MiddlewarePhase::Fetch, &document_state) + }; + + let (refused, logged) = + crate::test_support::logs::logged_by(chain.prepare(ModuleContext::empty(), &context)).await; + (refused.expect_err("should refuse"), logged) +} + +#[tokio::test] +async fn a_refusal_is_logged_once_as_a_warning_naming_the_middleware() { + let (refusal, logged) = refusal_and_log(Arc::new(Refuses)).await; + + assert_eq!(refusal.status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(logged.len(), 1, "should log the refusal once: {logged:?}"); + assert!( + logged[0].starts_with("WARN ") + && logged[0].contains("`example.refuses`") + && logged[0].contains("fetch phase") + && logged[0].contains("503 Service Unavailable"), + "should warn, naming the middleware, the phase and the status: {}", + logged[0] + ); +} + +#[tokio::test] +async fn a_refusal_answers_with_a_client_or_server_error_alone() { + let fault = StatusCode::INTERNAL_SERVER_ERROR; + for (given, answered) in [ + (StatusCode::CONTINUE, fault), + (StatusCode::OK, fault), + (StatusCode::NO_CONTENT, fault), + (StatusCode::MOVED_PERMANENTLY, fault), + (StatusCode::NOT_MODIFIED, fault), + (StatusCode::NOT_FOUND, StatusCode::NOT_FOUND), + (StatusCode::TOO_MANY_REQUESTS, StatusCode::TOO_MANY_REQUESTS), + (StatusCode::BAD_GATEWAY, StatusCode::BAD_GATEWAY), + ] { + let (refusal, logged) = refusal_and_log(Arc::new(RefusesWith(given))).await; + + assert_eq!( + refusal.status, answered, + "should answer a refusal with {given} as {answered}" + ); + assert_eq!( + logged.len(), + 1, + "should log the refusal with {given} once: {logged:?}" + ); + assert!( + logged[0].starts_with("WARN ") + && logged[0].contains("`example.refuses-with`") + && logged[0].contains(&given.to_string()) + && logged[0].contains(&answered.to_string()), + "should warn, naming the middleware, the status given and the status answered: {}", + logged[0] + ); + } +} + #[test] fn an_html_entry_with_a_path_makes_nothing() { let entries = PhaseEntries::new(vec![PhaseEntry { diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 66b73d1b31..b381027027 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -22134,7 +22134,7 @@ mod tests { use crate::integrations::registry_test_support::middleware_fixture as fixture; use crate::middleware::HTML_MEDIA_TYPE; - let proxied = proxy( + let (proxied, logged) = crate::test_support::logs::logged_by(proxy( vec![PhaseEntry { media_type: HTML_MEDIA_TYPE.to_owned(), path: None, @@ -22143,11 +22143,19 @@ mod tests { Method::GET, "/article", 200, - ) + )) .await; let PublisherResponse::Buffered(response) = proxied.response else { panic!("a refusal is answered whole, with no document to stream"); }; + let refusals: Vec<&String> = logged + .iter() + .filter(|line| line.contains(fixture::REFUSING)) + .collect(); + assert!( + refusals.len() == 1 && refusals[0].starts_with("WARN "), + "should warn of the refusal once, naming the middleware: {logged:?}" + ); let header_value = |name: header::HeaderName| { response .headers() diff --git a/crates/trusted-server-core/src/test_support.rs b/crates/trusted-server-core/src/test_support.rs index 9fea391024..fd24968b0f 100644 --- a/crates/trusted-server-core/src/test_support.rs +++ b/crates/trusted-server-core/src/test_support.rs @@ -197,6 +197,56 @@ pub mod fixtures { pub const PUBLISHER_PAGE_HTML: &str = include_str!("html_processor.test.html"); } +/// What a test logs on its own thread, for a test of a log line. +/// +/// The first use installs a logger for the whole test binary that keeps the +/// records of a thread while it captures and drops every other record. +#[cfg(test)] +pub(crate) mod logs { + use std::cell::RefCell; + use std::future::Future; + use std::sync::Once; + + thread_local! { + static CAPTURED: RefCell>> = const { RefCell::new(None) }; + } + + struct ThreadLogger; + + impl log::Log for ThreadLogger { + fn enabled(&self, _metadata: &log::Metadata<'_>) -> bool { + CAPTURED.with(|captured| captured.borrow().is_some()) + } + + fn log(&self, record: &log::Record<'_>) { + CAPTURED.with(|captured| { + if let Some(lines) = captured.borrow_mut().as_mut() { + lines.push(format!("{} {}", record.level(), record.args())); + } + }); + } + + fn flush(&self) {} + } + + static LOGGER: ThreadLogger = ThreadLogger; + static INSTALL: Once = Once::new(); + + /// What `future` answers, and each line it logs on this thread at + /// warning level or above, as the level and then the message, such as + /// `WARN ...`. A future that runs on another thread logs nothing here. + pub(crate) async fn logged_by(future: F) -> (F::Output, Vec) { + INSTALL.call_once(|| { + log::set_logger(&LOGGER).expect("should be the only logger the tests install"); + log::set_max_level(log::LevelFilter::Warn); + }); + CAPTURED.with(|captured| *captured.borrow_mut() = Some(Vec::new())); + let output = future.await; + let lines = CAPTURED.with(|captured| captured.borrow_mut().take().unwrap_or_default()); + (output, lines) + } +} + /// The operator-facing settings template, for tests that check what it /// documents. #[cfg(any(test, feature = "test-utils"))] From b2865585167f62e2d1bdd3b3bfdb5c82d2bf2904 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 17:04:48 +0100 Subject: [PATCH 08/11] The guide and the changelog say what a stored document's preparation gets The integration guide and the changelog entry say that the fetch phase of a document that may be stored is prepared with no client IP, TLS details or client signals, and that core logs a refusal as a warning naming the middleware and answers a status that is not a client or server error with 500. Both also said that a preparation's answer is kept under the middleware's name, where it is kept under the phase and the name. --- CHANGELOG.md | 2 +- docs/guide/integration-guide.md | 21 ++++++++++++--------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 872bec7a40..2744c5fa24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,7 +83,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- A middleware may prepare, and may refuse. `Middleware::prepare` runs in the request's handler before the document is processed, as the middleware's module with the request's services, and what it answers is handed to `create` under the middleware's name, so a decision that needs a store or the network is made there while `create` stays synchronous. A refusal answers the request with its status and `Retry-After`, with no document, and nothing is stored. +- A middleware may prepare, and may refuse. `Middleware::prepare` runs in the request's handler before the document is processed, as the middleware's module with the request's services, and what it answers is kept under the phase and the middleware's name for `create` in that phase, so a decision that needs a store or the network is made there while `create` stays synchronous. The fetch phase of a document that may be stored is prepared with no client IP, TLS details or client signals in the services. A refusal answers the request with its status and `Retry-After`, with no document, and nothing is stored. Core logs a refusal as a warning naming the middleware, and answers a status that is not a client or server error with `500`. - A file the origin does not have is made by the middleware a `[[fetch]]` entry with a `path`, for a media type other than HTML, names for it. When the origin answers `404 Not Found` for a path under it, the middleware runs on an empty document of the entry's media type and its output is the file, so `ads.txt` is made from the sellers lines alone where the publisher has none. With no such entry the origin's answer stands. - `[ads-txt] modules = ["sellers"]` adds the lines `[ads-txt.sellers] lines` gives to the publisher's `ads.txt`, from `crates/ads-txt/sellers`. The module is a middleware for `text/plain`, named in a `[[fetch]]` entry on the file's path, which passes the origin's file through and writes the lines after its last one, so the publisher's file stays authoritative, and a publisher whose origin has no file gets one made of the lines. Each line is checked as the settings load as a data record or a variable of the ads.txt grammar, and a selected module no entry places is refused. - A `[[fetch]]` or `[[serve]]` entry may cover a media type other than `text/html`: another `text/` type except `text/x-component`, `application/javascript`, `application/json` or `image/svg+xml`, written as a bare `type/subtype` in lower case. A middleware says which media types it works on with `handles_media_type`, HTML alone unless it says otherwise, and an entry naming one under a media type it does not handle is refused when the registry is built. On a media type other than HTML a middleware's stream processor alone runs on the body, after core has moved the origin's addresses, in the fetch phase and then, for a response that is not stored, the serve phase, and an action that decides about HTML there is refused by name. diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index 29b1a539f8..b21132c8a5 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -249,8 +249,8 @@ A middleware whose decision needs the request's services, or has to wait on the key-value store or the network, answers `prepare` with a future. Core runs it in the request's handler, before the document is processed, as the module the middleware belongs to and with the permissions it declares in -`required_permissions`. What the future answers is kept under the -middleware's name in the context's `prepared`, and `create`, which stays +`required_permissions`. What the future answers is kept under the phase and +the middleware's name in the context's `prepared`, and `create`, which stays synchronous, takes it from there as the type it prepared. A middleware that prepares nothing leaves `prepare` at its default and is asked `create` as before. @@ -285,15 +285,18 @@ stored or served from the store, so a middleware that cannot make the document it is responsible for fails closed rather than letting an empty or unchanged one through. `Refusal::service_unavailable` is the one for a source that cannot answer now. The preparations of one document run in the -order the entry names the middleware, and the first refusal ends them. +order the entry names the middleware, and the first refusal ends them. Core +logs a refusal as a warning naming the middleware, and answers a status that +is not a client or server error with `500`. On a document that may be stored the fetch phase's preparations are handed -the request's services and nothing of the reader, because what they answer -is stored with the document. The serve phase is prepared with the reader's -state on every document, the one fetched for this reader as much as a copy -read from the store, so what a serve middleware finds prepared never depends -on whether its reader was the first. On a document that is not stored both -phases are prepared with the reader's state. +the request's services with no client IP, TLS details or client signals, and +nothing else of the reader, because what they answer is stored with the +document. The serve phase is prepared with the reader's state on every +document, the one fetched for this reader as much as a copy read from the +store, so what a serve middleware finds prepared never depends on whether its +reader was the first. On a document that is not stored both phases are +prepared with the reader's state. ## Proxy implementation rules From 028f7b5c10503d59519c00eb890d211ea7a5acb7 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 20:43:56 +0100 Subject: [PATCH 09/11] The preparation fixture's reader marker has a name of its own The preparing middleware's marker, saying what its preparation could read of the reader, was added to the middleware fixture under the name the serve reader middleware's marker already had, so the core tests did not compile. It is now `prepared_reader_marker`, with its two calls. --- crates/trusted-server-core/src/integrations/registry.rs | 4 ++-- crates/trusted-server-core/src/publisher.rs | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index fb6559762b..092ac36985 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -3000,7 +3000,7 @@ pub(crate) mod test_support { /// The marker the preparing middleware writes after /// [`prepared_marker`], saying what its preparation could read of the /// reader. - pub(crate) fn reader_marker(seen: &str) -> String { + pub(crate) fn prepared_reader_marker(seen: &str) -> String { format!("") } @@ -3087,7 +3087,7 @@ pub(crate) mod test_support { ) -> Option> { Some(Box::pin(async move { let marker = prepared_marker(call.module(), context.request_host) - + &reader_marker(&reader_seen(&call)); + + &prepared_reader_marker(&reader_seen(&call)); Ok(Box::new(marker) as Box) })) } diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index eded6fc50a..2b6bde52c4 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -12088,7 +12088,9 @@ mod tests { ); if stored { assert!( - page.contains(&fixture::reader_marker(fixture::NOTHING_OF_THE_READER)), + page.contains(&fixture::prepared_reader_marker( + fixture::NOTHING_OF_THE_READER + )), "should prepare a page stored for every reader with nothing of the \ reader whose request fetched it: {page}" ); From d3af1884d01221c00907121881d8a87ebd9a2e4c Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 22:19:30 +0100 Subject: [PATCH 10/11] The startup warning's tests read their lines through the shared test logger A test binary holds one logger. Core's test support installs one for the whole binary in `test_support::logs`, and the tests of the startup warning that an adapter sends no telemetry installed a second, so whichever was installed second failed its test. The warning's tests now read their lines through `test_support::logs::logged_by`, as the other tests of a log line do, and each line is its level and then its message. --- .../src/auction/telemetry.rs | 67 +++++-------------- 1 file changed, 16 insertions(+), 51 deletions(-) diff --git a/crates/trusted-server-core/src/auction/telemetry.rs b/crates/trusted-server-core/src/auction/telemetry.rs index e97ccb687c..7afc6ffab1 100644 --- a/crates/trusted-server-core/src/auction/telemetry.rs +++ b/crates/trusted-server-core/src/auction/telemetry.rs @@ -1266,45 +1266,6 @@ mod tests { ); } - thread_local! { - /// The lines logged on this thread while a test is capturing them. - static CAPTURED_LOG: std::cell::RefCell>> = - const { std::cell::RefCell::new(None) }; - } - - /// A logger that keeps the lines logged on a thread that asked for them, - /// so a test reads its own lines and none of another test's. - struct ThreadLog; - - impl log::Log for ThreadLog { - fn enabled(&self, _metadata: &log::Metadata<'_>) -> bool { - true - } - - fn log(&self, record: &log::Record<'_>) { - CAPTURED_LOG.with(|captured| { - if let Some(lines) = captured.borrow_mut().as_mut() { - lines.push((record.level(), record.args().to_string())); - } - }); - } - - fn flush(&self) {} - } - - /// The lines at warning level or above that `run` logs on this thread. - fn warnings_logged_by(run: impl FnOnce()) -> Vec<(log::Level, String)> { - static LOGGER: ThreadLog = ThreadLog; - static INSTALL: std::sync::Once = std::sync::Once::new(); - INSTALL.call_once(|| { - log::set_logger(&LOGGER).expect("no other test should install a logger"); - log::set_max_level(log::LevelFilter::Warn); - }); - CAPTURED_LOG.with(|captured| *captured.borrow_mut() = Some(Vec::new())); - run(); - CAPTURED_LOG.with(|captured| captured.borrow_mut().take().unwrap_or_default()) - } - /// Settings whose `[analytics]` selects the module the example documents. fn settings_selecting_analytics() -> Settings { Settings::from_toml(&format!( @@ -1329,36 +1290,39 @@ mod tests { } } - #[test] - fn an_adapter_that_sends_nothing_warns_once_when_analytics_selects_a_module() { + #[tokio::test] + async fn an_adapter_that_sends_nothing_warns_once_when_analytics_selects_a_module() { let settings = settings_selecting_analytics(); let logged = AtomicBool::new(false); - let lines = warnings_logged_by(|| { + let ((), lines) = crate::test_support::logs::logged_by(async { warn_once_when_analytics_is_unsent(&settings, None, "Example", &logged); warn_once_when_analytics_is_unsent(&settings, None, "Example", &logged); - }); + }) + .await; assert_eq!(lines.len(), 1, "should log one line, got {lines:?}"); - assert_eq!(lines[0].0, log::Level::Warn, "should log a warning"); assert!( + lines[0].starts_with("WARN "), + "should log a warning, got {:?}", lines[0] - .1 - .contains("the Example adapter sends no auction telemetry"), + ); + assert!( + lines[0].contains("the Example adapter sends no auction telemetry"), "should name the adapter, got {:?}", - lines[0].1 + lines[0] ); } - #[test] - fn no_warning_when_analytics_selects_nothing_or_a_supplied_sink_sends() { + #[tokio::test] + async fn no_warning_when_analytics_selects_nothing_or_a_supplied_sink_sends() { let selecting_nothing = crate::test_support::tests::create_test_settings(); let selecting = settings_selecting_analytics(); let sending = crate::platform::test_support::noop_services_with_telemetry_sink( std::sync::Arc::new(SendingSink), ); - let lines = warnings_logged_by(|| { + let ((), lines) = crate::test_support::logs::logged_by(async { warn_once_when_analytics_is_unsent( &selecting_nothing, None, @@ -1371,7 +1335,8 @@ mod tests { "Example", &AtomicBool::new(false), ); - }); + }) + .await; assert!(lines.is_empty(), "should log nothing, got {lines:?}"); } From 19c3549f1a7b514f2c1f86af67a8ee421681d713 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:28:57 +0000 Subject: [PATCH 11/11] The reader's services in the preparation tests carry the sample policy --- crates/trusted-server-core/src/publisher.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index f43d6bcd62..cc4ca17a75 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -12092,7 +12092,7 @@ mod tests { http_client: Arc, cache: Arc, ) -> RuntimeServices { - RuntimeServices::builder() + crate::platform::test_support::test_services_builder() .config_store(Arc::new(NoopConfigStore)) .secret_store(Arc::new(NoopSecretStore)) .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore))