From 11706a22691ebb3bb935e2e3783f59bf0f791be1 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 10 Oct 2026 22:40:44 +0100 Subject: [PATCH 1/9] A file the origin does not have is made by the middleware named for its path When the origin answers `404 Not Found` for a path a `[[fetch]]` entry with a path names middleware for, core runs that middleware on an empty document of the entry's media type and its output is the file, stored and served as a fetched one is. A GET and a HEAD are answered alike. With no such entry, on any other answer from the origin, or where only the entry covering every path applies, the origin's answer stands, because a page the origin does not have is the origin's answer. `PhaseEntries::creating` finds the entry. The sellers crate says its file is made from the lines alone where the publisher has none, which its middleware already does on an empty document. --- crates/ads-txt/sellers/Cargo.toml | 2 +- crates/ads-txt/sellers/src/lib.rs | 5 +- crates/trusted-server-core/src/middleware.rs | 15 ++ .../src/middleware/tests.rs | 33 +++ crates/trusted-server-core/src/publisher.rs | 238 ++++++++++++++++++ 5 files changed, 290 insertions(+), 3 deletions(-) diff --git a/crates/ads-txt/sellers/Cargo.toml b/crates/ads-txt/sellers/Cargo.toml index aa4cdf188a..90d1db728b 100644 --- a/crates/ads-txt/sellers/Cargo.toml +++ b/crates/ads-txt/sellers/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "trusted-server-ads-txt-sellers" -description = "Adds a publisher's sellers to the origin's ads.txt as it passes through, from the lines the settings give." +description = "Adds a publisher's sellers to the origin's ads.txt from the lines the settings give, or makes the file from them where the origin has none." authors = { workspace = true } edition = { workspace = true } license = { workspace = true } diff --git a/crates/ads-txt/sellers/src/lib.rs b/crates/ads-txt/sellers/src/lib.rs index a01ba90441..c9c25678de 100644 --- a/crates/ads-txt/sellers/src/lib.rs +++ b/crates/ads-txt/sellers/src/lib.rs @@ -5,8 +5,9 @@ //! this module's middleware passes the file through and writes the lines //! `[ads-txt.sellers] lines` gives at its end, so a seller the deployment //! introduces is declared beside the publisher's own. A publisher whose -//! origin has no file gets the origin's answer, because the module adds to -//! the publisher's file and never invents one. +//! origin has no file gets one made of the lines alone, because core runs +//! the middleware on an empty document where the origin answers `404` for +//! the path. //! //! Each line is checked as the settings load against the file's grammar, //! because every buyer's crawler reads the file and a typo there costs the diff --git a/crates/trusted-server-core/src/middleware.rs b/crates/trusted-server-core/src/middleware.rs index 6c46bef516..21a3f8f861 100644 --- a/crates/trusted-server-core/src/middleware.rs +++ b/crates/trusted-server-core/src/middleware.rs @@ -442,6 +442,21 @@ impl PhaseEntries { names } + /// The entry that makes the document the origin does not have at + /// `path`, being the first with a path prefix covering it that names a + /// middleware. An entry covering every path makes none, because a page + /// the origin does not have is the origin's answer. + #[must_use] + pub fn creating(&self, path: &str) -> Option<&PhaseEntry> { + self.0.iter().find(|entry| { + entry + .path + .as_deref() + .is_some_and(|prefix| path.starts_with(prefix)) + && !entry.middleware.is_empty() + }) + } + /// Checks each entry's media type, path and names, and that no entry is /// hidden behind an earlier one, or covers the same pages as one. /// diff --git a/crates/trusted-server-core/src/middleware/tests.rs b/crates/trusted-server-core/src/middleware/tests.rs index 2f33d32e68..d496b7a418 100644 --- a/crates/trusted-server-core/src/middleware/tests.rs +++ b/crates/trusted-server-core/src/middleware/tests.rs @@ -1498,3 +1498,36 @@ fn deploy_validation_refuses_a_misshapen_entry() { "should name the entry: {error}" ); } + +#[test] +fn the_creating_entry_is_the_first_with_a_path_covering_the_request_that_names_a_middleware() { + let entries = PhaseEntries::new(vec![ + PhaseEntry { + media_type: "text/plain".to_owned(), + path: Some("/ads.txt".to_owned()), + middleware: Vec::new(), + }, + PhaseEntry { + media_type: "text/plain".to_owned(), + path: Some("/ads".to_owned()), + middleware: vec!["example.sellers".to_owned()], + }, + PhaseEntry { + media_type: HTML_MEDIA_TYPE.to_owned(), + path: None, + middleware: vec!["example.page".to_owned()], + }, + ]); + + assert_eq!( + entries + .creating("/ads.txt") + .map(|entry| entry.middleware.as_slice()), + Some(&["example.sellers".to_owned()][..]), + "should pass the entry naming no middleware and take the first that names one" + ); + assert!( + entries.creating("/robots.txt").is_none(), + "should make nothing where no entry with a path covers the request, whatever covers every path" + ); +} diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 7c5a0eded9..4b97eac044 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -1904,6 +1904,23 @@ pub(crate) fn classify_response_route( ResponseRoute::Stream } +/// An empty document of `media_type`, which the middleware a `[[fetch]]` +/// entry names for a path the origin has nothing at is run on. +fn empty_document(media_type: &str) -> Result, Report> { + let content_type = if media_type.starts_with("text/") { + format!("{media_type}; charset=utf-8") + } else { + media_type.to_owned() + }; + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, content_type) + .body(EdgeBody::empty()) + .change_context(TrustedServerError::Proxy { + message: format!("failed to build an empty {media_type} document"), + }) +} + fn response_cache_control_is_private_or_no_store(response: &Response) -> bool { cache_control_headers_are_private_or_no_store(response.headers()) } @@ -5546,6 +5563,22 @@ pub async fn handle_publisher_request( response.headers().len() ); + // A file the origin does not have is made by the middleware the + // `[[fetch]]` entry for its path names, run on an empty document of the + // entry's media type, as `ads.txt` is made from the sellers lines. With + // no such entry the origin's answer stands. + if response.status() == StatusCode::NOT_FOUND + && (request_method == Method::GET || request_method == Method::HEAD) + && let Some(entry) = settings.fetch.creating(&request_path) + { + log::debug!( + "Making {} for {request_path}, which the origin does not have, with {}", + entry.media_type, + entry.middleware.join(", ") + ); + response = empty_document(&entry.media_type)?; + } + if should_run_ad_stack && response.status() == StatusCode::NOT_MODIFIED { if let Some(dispatched) = dispatched_auction.take() { emit_abandoned_auction( @@ -20897,6 +20930,211 @@ mod tests { ); } + /// What the publisher path did with one request, with what it takes + /// to stream the answer. + struct Proxied { + settings: Settings, + registry: IntegrationRegistry, + services: crate::platform::RuntimeServices, + orchestrator: AuctionOrchestrator, + response: PublisherResponse, + } + + /// A request for `path` through the publisher path, with the middleware + /// 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); + 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(), + vec![("content-type", "text/html; charset=utf-8")], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let consent = crate::consent::ConsentContext { + jurisdiction: crate::consent::jurisdiction::Jurisdiction::NonRegulated, + ..Default::default() + }; + let mut ec_context = EcContext::new_for_test_with_ip(None, consent, None); + let orchestrator = AuctionOrchestrator::new( + crate::auction::test_support::legacy_auction_config(&settings), + ); + let req = HttpRequest::builder() + .method(method) + .uri(format!("https://publisher.example{path}")) + .header(header::HOST, "publisher.example") + .body(EdgeBody::empty()) + .expect("should build request"); + let response = handle_publisher_request( + AppContext { + settings: &settings, + integration_registry: ®istry, + }, + &services, + None, + &mut ec_context, + AuctionDispatch { + orchestrator: &orchestrator, + slots: &[], + registry: None, + }, + req, + EdgeCacheHeader::SMaxageFallback, + ) + .await + .expect("should proxy the request"); + Proxied { + settings, + registry, + services, + orchestrator, + response, + } + } + + /// One entry naming the fixture's text middleware, for `path` or for + /// every path. + fn text_entry_for(path: Option<&str>) -> Vec { + use crate::integrations::registry_test_support::middleware_fixture as fixture; + + vec![PhaseEntry { + media_type: fixture::TEXT_MEDIA_TYPE.to_owned(), + path: path.map(str::to_owned), + middleware: vec![fixture::TEXT.to_owned()], + }] + } + + /// The response of a proxied request, however it is to be sent. + fn response_of(response: PublisherResponse) -> Response { + match response { + PublisherResponse::Buffered(response) + | PublisherResponse::Stream { response, .. } + | PublisherResponse::PassThrough { response, .. } => response, + PublisherResponse::AssembleTemplate { .. } => { + panic!("a template is not an answer to these requests") + } + } + } + + fn content_type_of(response: &Response) -> Option<&str> { + response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + } + + #[tokio::test] + async fn a_file_the_origin_does_not_have_is_made_by_the_middleware_named_for_its_path() { + use crate::integrations::registry_test_support::middleware_fixture as fixture; + + let proxied = proxy( + text_entry_for(Some("/ads.txt")), + Method::GET, + "/ads.txt", + 404, + ) + .await; + let PublisherResponse::Stream { + response, + body, + params, + } = proxied.response + else { + panic!("a made file should stream through the entry's middleware"); + }; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + content_type_of(&response), + Some("text/plain; charset=utf-8"), + "should be a document of the entry's media type" + ); + 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 made file"); + assert_eq!( + String::from_utf8(output).expect("text"), + fixture::TEXT_LINE, + "should be what the middleware writes on an empty document" + ); + } + + #[tokio::test] + async fn a_head_request_for_a_made_file_gets_the_same_answer() { + let proxied = proxy( + text_entry_for(Some("/ads.txt")), + Method::HEAD, + "/ads.txt", + 404, + ) + .await; + let response = response_of(proxied.response); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + content_type_of(&response), + Some("text/plain; charset=utf-8"), + "should answer a HEAD as the GET is answered" + ); + } + + #[tokio::test] + async fn an_entry_covering_every_path_makes_no_missing_page() { + let proxied = proxy(text_entry_for(None), Method::GET, "/missing.txt", 404).await; + + assert_eq!( + response_of(proxied.response).status(), + StatusCode::NOT_FOUND, + "should keep the origin's answer, because a page the origin does not have is its answer" + ); + } + + #[tokio::test] + async fn a_missing_file_outside_the_entry_path_keeps_the_origin_answer() { + let proxied = proxy( + text_entry_for(Some("/ads.txt")), + Method::GET, + "/robots.txt", + 404, + ) + .await; + + assert_eq!( + response_of(proxied.response).status(), + StatusCode::NOT_FOUND + ); + } + + #[tokio::test] + async fn an_origin_error_on_the_entry_path_keeps_the_origin_answer() { + let proxied = proxy( + text_entry_for(Some("/ads.txt")), + Method::GET, + "/ads.txt", + 500, + ) + .await; + + assert_eq!( + response_of(proxied.response).status(), + StatusCode::INTERNAL_SERVER_ERROR, + "should make a file only where the origin has none" + ); + } + #[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 From 303e923049231bfee93e00aec9b49e298249d318 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 10 Oct 2026 22:40:45 +0100 Subject: [PATCH 2/9] The guides say a file the origin does not have is made by its middleware The configuration guide says it with the entries for other media types and in the ads.txt section, the integration guide tells a middleware it may be run on an empty document, and the changelog records the rule. --- CHANGELOG.md | 3 ++- docs/guide/configuration.md | 13 +++++++++++-- docs/guide/integration-guide.md | 6 ++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efe6a7114e..f3d44b2a9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,7 +79,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `[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 the origin's answer. 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 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. - The InMobi Choice consent prompt as a module, `[cmp] module = "inmobi"` with `[cmp.inmobi] script_url`, from `crates/cmp/inmobi`. Where a `[[fetch]]` entry names `cmp.inmobi`, its middleware writes the IAB TCF stub, InMobi's GPP stub and the deferred Choice loader into the head, in that order, and the two stubs' behavior is tested with Node's test runner, which CI now runs for every module's `tests/*.test.mjs`. The script URL is refused unless it is `https` on an InMobi Choice host with no user name or password, and a selected prompt that no entry places is refused. 51Degrees maintain the crate until InMobi adopt it. - `[robots-txt]` serves a publisher's `/robots.txt` from the settings. `modules` names what makes the file, in order. `refuse_all` and `allow_all` are Trusted Server's own, and any other name is a module that contributes rules through the new `RobotsTxtContributor` extension point, declared with `with_robots_txt_contributor`. Trusted Server writes the file, so a contributor cannot produce a malformed one, a refusal cannot be loosened, and a path in `always_allow` stays open to every crawler refused the whole site. With `refuse_all` selected every response an adapter finalizes carries `X-Robots-Tag: noindex, nofollow`. A module's answer is held in the key-value store until its refresh is due, a module that cannot answer has its last answer served, and with none held the answer is `503` and never an empty file. Without the section `/robots.txt` reaches the publisher's origin as before. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index eb3bf6fc19..2654f7e785 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1098,8 +1098,9 @@ runs where a `[[fetch]]` entry for that media type on the file's path names it. It passes the origin's file through and writes the lines after its last one, on a line of their own, so a seller the deployment introduces is declared beside the publisher's own. A publisher whose origin has no file -gets the origin's answer, because the module adds to the publisher's file -and never invents one. +gets one made of the lines alone, because an origin's `404 Not Found` on a +path a `[[fetch]]` entry names middleware for runs that middleware on an +empty document of the entry's media type. **Refused when the settings load**: a line that is not a data record or a variable, naming which part is wrong, a comment, an empty list, and a @@ -1802,6 +1803,14 @@ about HTML, and a middleware that makes one there is refused by name. The fetch entry's middleware run once on what is stored, and the serve entry's on each reader's copy, as on a page. +**A file the origin does not have.** When the origin answers `404 Not +Found` for a path a `[[fetch]]` entry with a `path` names middleware for, +that 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, or on any other answer from the +origin, the origin's answer stands, and an entry without a path makes +nothing, because a page the origin does not have is the origin's answer. + ```toml [[fetch]] media_type = "text/plain" diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index 7a49363ec6..cf57fc81b6 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -208,6 +208,12 @@ last chunk marked. Head markup and the element and text handlers are decisions about HTML, and an action that makes one on another media type is refused by name when the chain is planned. +A middleware named in an entry with a path also runs where the origin +answers `404 Not Found` for that path, on an empty document of the entry's +media type, and what it writes is the file. A middleware that adds to a file +therefore writes the same on an empty one, with nothing before its first +line. + ```rust struct Sellers; From 514f8fe769d826c542c32dc8ec5dc240a801382f Mon Sep 17 00:00:00 2001 From: James Rogers Date: Mon, 12 Oct 2026 15:18:50 +0100 Subject: [PATCH 3/9] An HTML entry makes no missing file and does not shadow the entry for it PhaseEntries::creating is now making and skips text/html entries, so a page the origin does not have stays the origin's answer and an HTML entry ahead of a text/plain one no longer makes the file as HTML. The HEAD test now checks the answer has no body. Comments and the guides say the rule once. --- CHANGELOG.md | 4 +- crates/trusted-server-core/src/middleware.rs | 15 ++-- .../src/middleware/tests.rs | 44 ++++++++++- crates/trusted-server-core/src/publisher.rs | 79 ++++++++++++++++--- docs/guide/configuration.md | 18 +++-- docs/guide/integration-guide.md | 10 +-- 6 files changed, 134 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81c28ee0e6..d9ebaccab0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,8 +79,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- 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 end of the publisher's `ads.txt`, from `crates/ads-txt/sellers`, where a `[[fetch]]` entry for `text/plain` names it. Each line is checked against the ads.txt grammar as the settings load, and a selected module no entry places is refused. +- 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. - The InMobi Choice consent prompt as a module, `[cmp] module = "inmobi"` with `[cmp.inmobi] script_url`, from `crates/cmp/inmobi`. Where a `[[fetch]]` entry names `cmp.inmobi`, its middleware writes the IAB TCF stub, InMobi's GPP stub and the deferred Choice loader into the head, in that order, and the two stubs' behavior is tested with Node's test runner, which CI now runs for every module's `tests/*.test.mjs`. The script URL is refused unless it is `https` on an InMobi Choice host with no user name or password, and a selected prompt that no entry places is refused. 51Degrees maintain the crate until InMobi adopt it. - `[robots-txt]` serves a publisher's `/robots.txt` from the settings. `modules` names what makes the file, in order. `refuse_all` and `allow_all` are Trusted Server's own, and any other name is a module that contributes rules through the new `RobotsTxtContributor` extension point, declared with `with_robots_txt_contributor`. Trusted Server writes the file, so a contributor cannot produce a malformed one, a refusal cannot be loosened, and a path in `always_allow` stays open to every crawler refused the whole site. With `refuse_all` selected every response an adapter finalizes carries `X-Robots-Tag: noindex, nofollow`. A module's answer is held in the key-value store until its refresh is due, a module that cannot answer has its last answer served, and with none held the answer is `503` and never an empty file. Without the section `/robots.txt` reaches the publisher's origin as before. diff --git a/crates/trusted-server-core/src/middleware.rs b/crates/trusted-server-core/src/middleware.rs index a73e61dc99..42fc32be21 100644 --- a/crates/trusted-server-core/src/middleware.rs +++ b/crates/trusted-server-core/src/middleware.rs @@ -443,15 +443,16 @@ impl PhaseEntries { /// The entry that makes the document the origin does not have at /// `path`, being the first with a path prefix covering it that names a - /// middleware. An entry covering every path makes none, because a page - /// the origin does not have is the origin's answer. + /// middleware, for a media type core streams other than HTML. #[must_use] - pub fn creating(&self, path: &str) -> Option<&PhaseEntry> { + pub fn making(&self, path: &str) -> Option<&PhaseEntry> { self.0.iter().find(|entry| { - entry - .path - .as_deref() - .is_some_and(|prefix| path.starts_with(prefix)) + entry.media_type != HTML_MEDIA_TYPE + && is_dispatched_media_type(&entry.media_type) + && entry + .path + .as_deref() + .is_some_and(|prefix| path.starts_with(prefix)) && !entry.middleware.is_empty() }) } diff --git a/crates/trusted-server-core/src/middleware/tests.rs b/crates/trusted-server-core/src/middleware/tests.rs index e6f38f0bf8..9eceeff961 100644 --- a/crates/trusted-server-core/src/middleware/tests.rs +++ b/crates/trusted-server-core/src/middleware/tests.rs @@ -1518,7 +1518,7 @@ fn deploy_validation_refuses_a_misshapen_entry() { } #[test] -fn the_creating_entry_is_the_first_with_a_path_covering_the_request_that_names_a_middleware() { +fn the_making_entry_is_the_first_with_a_path_covering_the_request_that_names_a_middleware() { let entries = PhaseEntries::new(vec![ PhaseEntry { media_type: "text/plain".to_owned(), @@ -1539,13 +1539,51 @@ fn the_creating_entry_is_the_first_with_a_path_covering_the_request_that_names_a assert_eq!( entries - .creating("/ads.txt") + .making("/ads.txt") .map(|entry| entry.middleware.as_slice()), Some(&["example.sellers".to_owned()][..]), "should pass the entry naming no middleware and take the first that names one" ); assert!( - entries.creating("/robots.txt").is_none(), + entries.making("/robots.txt").is_none(), "should make nothing where no entry with a path covers the request, whatever covers every path" ); } + +#[test] +fn an_html_entry_with_a_path_makes_nothing() { + let entries = PhaseEntries::new(vec![PhaseEntry { + media_type: HTML_MEDIA_TYPE.to_owned(), + path: Some("/news/".to_owned()), + middleware: vec!["example.page".to_owned()], + }]); + + assert!( + entries.making("/news/missing").is_none(), + "should leave a page the origin does not have as the origin's answer" + ); +} + +#[test] +fn an_html_entry_ahead_does_not_shadow_the_entry_that_makes_the_file() { + let entries = PhaseEntries::new(vec![ + PhaseEntry { + media_type: HTML_MEDIA_TYPE.to_owned(), + path: Some("/".to_owned()), + middleware: vec!["example.page".to_owned()], + }, + PhaseEntry { + media_type: "text/plain".to_owned(), + path: Some("/ads.txt".to_owned()), + middleware: vec!["example.sellers".to_owned()], + }, + ]); + + assert_eq!( + entries + .making("/ads.txt") + .map(|entry| entry.media_type.as_str()), + Some("text/plain"), + "should make the file from the text/plain entry, not as HTML" + ); +} diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index abb6611ebe..280fbd994e 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -5530,13 +5530,10 @@ pub async fn handle_publisher_request( response.headers().len() ); - // A file the origin does not have is made by the middleware the - // `[[fetch]]` entry for its path names, run on an empty document of the - // entry's media type, as `ads.txt` is made from the sellers lines. With - // no such entry the origin's answer stands. + // A file the origin does not have is made, see `PhaseEntries::making`. if response.status() == StatusCode::NOT_FOUND && (request_method == Method::GET || request_method == Method::HEAD) - && let Some(entry) = settings.fetch.creating(&request_path) + && let Some(entry) = settings.fetch.making(&request_path) { log::debug!( "Making {} for {request_path}, which the origin does not have, with {}", @@ -20941,8 +20938,7 @@ mod tests { ); } - /// What the publisher path did with one request, with what it takes - /// to stream the answer. + /// What the publisher path answered one request with. struct Proxied { settings: Settings, registry: IntegrationRegistry, @@ -20951,9 +20947,7 @@ mod tests { response: PublisherResponse, } - /// A request for `path` through the publisher path, with the middleware - /// fixture in the entries given and the origin answering `status` with - /// a page. + /// A request through the publisher path, the origin answering `status`. async fn proxy(fetch: Vec, method: Method, path: &str, status: u16) -> Proxied { let settings = settings_running_the_middleware_fixture(fetch); let registry = IntegrationRegistry::new(&settings).expect("should build a registry"); @@ -21092,7 +21086,16 @@ mod tests { 404, ) .await; - let response = response_of(proxied.response); + let response = buffer_publisher_response_async( + proxied.response, + &Method::HEAD, + &proxied.settings, + &proxied.registry, + &proxied.orchestrator, + &proxied.services, + ) + .await + .expect("should buffer the HEAD answer"); assert_eq!(response.status(), StatusCode::OK); assert_eq!( @@ -21100,6 +21103,60 @@ mod tests { Some("text/plain; charset=utf-8"), "should answer a HEAD as the GET is answered" ); + assert!( + response + .into_body() + .into_bytes() + .expect("should hold the HEAD answer in memory") + .is_empty(), + "should answer a HEAD with no body" + ); + } + + #[tokio::test] + async fn an_html_entry_with_a_path_makes_no_missing_page() { + 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: Some("/news/".to_owned()), + middleware: vec![fixture::HEAD.to_owned()], + }], + Method::GET, + "/news/missing", + 404, + ) + .await; + + assert_eq!( + response_of(proxied.response).status(), + StatusCode::NOT_FOUND, + "should keep the origin's answer for a page it does not have" + ); + } + + #[tokio::test] + async fn an_html_entry_ahead_does_not_shadow_the_entry_for_a_missing_file() { + use crate::integrations::registry_test_support::middleware_fixture as fixture; + use crate::middleware::HTML_MEDIA_TYPE; + + let mut fetch = vec![PhaseEntry { + media_type: HTML_MEDIA_TYPE.to_owned(), + path: Some("/".to_owned()), + middleware: vec![fixture::HEAD.to_owned()], + }]; + fetch.extend(text_entry_for(Some("/ads.txt"))); + let proxied = proxy(fetch, Method::GET, "/ads.txt", 404).await; + let response = response_of(proxied.response); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + content_type_of(&response), + Some("text/plain; charset=utf-8"), + "should make the file from the text/plain entry, not as HTML" + ); } #[tokio::test] diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index a5c784729d..a8bdc9a16b 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1095,8 +1095,9 @@ it passes through untouched. **How the file is made.** Where a `[[fetch]]` entry for `text/plain` names the module, it writes the lines after the origin's last one, and where the -origin has no file the file is made of the lines alone. The entry's `path` is -a prefix, so `/ads.txt` also covers `/ads.txt.bak`. +origin has no file the file is made of the lines alone, see A file the origin +does not have below. The entry's `path` is a prefix, so `/ads.txt` also +covers `/ads.txt.bak`. **Refused when the settings load**: a line that is not a data record or a variable, naming which part is wrong, a comment, an empty list, and a @@ -1800,12 +1801,13 @@ template cache stores HTML alone, so the fetch entry's middleware and then the serve entry's run on each response as it is fetched. **A file the origin does not have.** When the origin answers `404 Not -Found` for a path a `[[fetch]]` entry with a `path` names middleware for, -that 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, or on any other answer from the -origin, the origin's answer stands, and an entry without a path makes -nothing, because a page the origin does not have is the origin's answer. +Found` for a path under the `path` of a `[[fetch]]` entry for a media type +other than HTML, that entry's middleware runs on an empty document of its +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, or on any +other answer from the origin, the origin's answer stands, and an entry for +HTML or without a path makes nothing, because a page the origin does not +have is the origin's answer. ```toml [[fetch]] diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index 38ca8da98c..b7cc23e21f 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -208,11 +208,11 @@ last chunk marked. Head markup and the element and text handlers are decisions about HTML, and an action that makes one on another media type is refused by name when the chain is planned. -A middleware named in an entry with a path also runs where the origin -answers `404 Not Found` for that path, on an empty document of the entry's -media type, and what it writes is the file. A middleware that adds to a file -therefore writes the same on an empty one, with nothing before its first -line. +A middleware named in an entry with a path, for a media type other than +HTML, also runs where the origin answers `404 Not Found` for a path under +it, on an empty document of the entry's media type, and what it writes is +the file. A middleware that adds to a file therefore writes the same on an +empty one, with nothing before its first line. ```rust struct Sellers; From 82c1a196eefaf620f4f5b4be13c6a320c2455c42 Mon Sep 17 00:00:00 2001 From: James Rogers Date: Mon, 12 Oct 2026 22:44:56 +0100 Subject: [PATCH 4/9] The entry with the longest covering path makes a missing file A missing file was made by the first non-HTML entry whose path covered the request, so an entry for text/css on / written ahead of the text/plain entry for /ads.txt made an empty stylesheet in place of the sellers file. The entry with the longest covering path now makes it, the first written winning between two of the same length. --- crates/trusted-server-core/src/middleware.rs | 32 +++++++++++------- .../src/middleware/tests.rs | 33 ++++++++++++++++++- docs/guide/configuration.md | 17 +++++----- 3 files changed, 62 insertions(+), 20 deletions(-) diff --git a/crates/trusted-server-core/src/middleware.rs b/crates/trusted-server-core/src/middleware.rs index 42fc32be21..c42c5a09d9 100644 --- a/crates/trusted-server-core/src/middleware.rs +++ b/crates/trusted-server-core/src/middleware.rs @@ -442,19 +442,29 @@ impl PhaseEntries { } /// The entry that makes the document the origin does not have at - /// `path`, being the first with a path prefix covering it that names a - /// middleware, for a media type core streams other than HTML. + /// `path`, being the one with the longest path prefix covering it that + /// names a middleware, for a media type core streams other than HTML. The + /// first written wins between two of the same length. #[must_use] pub fn making(&self, path: &str) -> Option<&PhaseEntry> { - self.0.iter().find(|entry| { - entry.media_type != HTML_MEDIA_TYPE - && is_dispatched_media_type(&entry.media_type) - && entry - .path - .as_deref() - .is_some_and(|prefix| path.starts_with(prefix)) - && !entry.middleware.is_empty() - }) + self.0 + .iter() + .filter(|entry| { + entry.media_type != HTML_MEDIA_TYPE + && is_dispatched_media_type(&entry.media_type) + && entry + .path + .as_deref() + .is_some_and(|prefix| path.starts_with(prefix)) + && !entry.middleware.is_empty() + }) + .fold(None, |best: Option<&PhaseEntry>, entry| { + let length = |entry: &PhaseEntry| entry.path.as_deref().map_or(0, str::len); + match best { + Some(best) if length(best) >= length(entry) => Some(best), + _ => Some(entry), + } + }) } /// Checks each entry's media type, path and names, and that no entry is diff --git a/crates/trusted-server-core/src/middleware/tests.rs b/crates/trusted-server-core/src/middleware/tests.rs index 9eceeff961..451f9ea4af 100644 --- a/crates/trusted-server-core/src/middleware/tests.rs +++ b/crates/trusted-server-core/src/middleware/tests.rs @@ -1518,7 +1518,7 @@ fn deploy_validation_refuses_a_misshapen_entry() { } #[test] -fn the_making_entry_is_the_first_with_a_path_covering_the_request_that_names_a_middleware() { +fn the_making_entry_has_a_path_covering_the_request_and_names_a_middleware() { let entries = PhaseEntries::new(vec![ PhaseEntry { media_type: "text/plain".to_owned(), @@ -1564,6 +1564,37 @@ fn an_html_entry_with_a_path_makes_nothing() { ); } +#[test] +fn the_entry_with_the_longest_covering_path_makes_the_file() { + let entries = PhaseEntries::new(vec![ + PhaseEntry { + media_type: "text/css".to_owned(), + path: Some("/".to_owned()), + middleware: vec!["example.styles".to_owned()], + }, + PhaseEntry { + media_type: "text/plain".to_owned(), + path: Some("/ads.txt".to_owned()), + middleware: vec!["example.sellers".to_owned()], + }, + ]); + + assert_eq!( + entries + .making("/ads.txt") + .map(|entry| entry.media_type.as_str()), + Some("text/plain"), + "should make the file from the entry for its own path, not one written ahead for every path" + ); + assert_eq!( + entries + .making("/site.css") + .map(|entry| entry.media_type.as_str()), + Some("text/css"), + "should still make a file only the shorter path covers" + ); +} + #[test] fn an_html_entry_ahead_does_not_shadow_the_entry_that_makes_the_file() { let entries = PhaseEntries::new(vec![ diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index a8bdc9a16b..e195a4f8ee 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1800,14 +1800,15 @@ about HTML, and a middleware that makes one there is refused by name. The template cache stores HTML alone, so the fetch entry's middleware and then the serve entry's run on each response as it is fetched. -**A file the origin does not have.** When the origin answers `404 Not -Found` for a path under the `path` of a `[[fetch]]` entry for a media type -other than HTML, that entry's middleware runs on an empty document of its -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, or on any -other answer from the origin, the origin's answer stands, and an entry for -HTML or without a path makes nothing, because a page the origin does not -have is the origin's answer. +**A file the origin does not have.** When the origin answers `404 Not Found` +for a path under the `path` of a `[[fetch]]` entry for a media type other +than HTML, that entry's middleware runs on an empty document of its media +type and its output is the file, so `ads.txt` is made from the sellers lines +alone where the publisher has none. Where several such entries cover the +path, the one with the longest `path` makes the file. With no such entry, or +on any other answer from the origin, the origin's answer stands, and an +entry for HTML or without a path makes nothing, because a page the origin +does not have is the origin's answer. ```toml [[fetch]] From 035773baec03bdbbb5b8d6f0764862d10b5462ae Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 17:00:13 +0100 Subject: [PATCH 5/9] A missing file is made at its entry's own path alone A file was made for any path under an entry's path, so an entry at / for a media type other than HTML answered every page the origin does not have with an empty 200, and an entry at /ads.txt made /ads.txt.bak as well. An entry now makes a file at its own path alone, never at a longer path it covers and never for a folder written with its closing slash, so a page the origin does not have keeps its 404. Where entries for two media types name the path, the first written makes the file. --- CHANGELOG.md | 2 +- crates/trusted-server-core/src/middleware.rs | 35 +++++------- .../src/middleware/tests.rs | 55 ++++++++++++++++--- crates/trusted-server-core/src/publisher.rs | 27 +++++++++ docs/guide/configuration.md | 20 ++++--- docs/guide/integration-guide.md | 6 +- 6 files changed, 103 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb43c5c2b5..81f7039a44 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 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. +- 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 that path itself, which names a file and not a folder, 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. - The InMobi Choice consent prompt as a module, `[cmp] module = "inmobi"` with `[cmp.inmobi] script_url`, from `crates/cmp/inmobi`. Where a `[[fetch]]` entry names `cmp.inmobi`, its middleware writes the IAB TCF stub, InMobi's GPP stub and the deferred Choice loader into the head, in that order, and the two stubs' behavior is tested with Node's test runner, which CI now runs for every module's `tests/*.test.mjs`. The script URL is refused unless it is `https` on an InMobi Choice host with no user name or password, and a selected prompt that no entry places is refused. 51Degrees maintain the crate until InMobi adopt it. diff --git a/crates/trusted-server-core/src/middleware.rs b/crates/trusted-server-core/src/middleware.rs index c75fdcc596..9c51b7e726 100644 --- a/crates/trusted-server-core/src/middleware.rs +++ b/crates/trusted-server-core/src/middleware.rs @@ -468,29 +468,22 @@ impl PhaseEntries { } /// The entry that makes the document the origin does not have at - /// `path`, being the one with the longest path prefix covering it that - /// names a middleware, for a media type core streams other than HTML. The - /// first written wins between two of the same length. + /// `path`, being the first written, for a media type core streams other + /// than HTML, whose `path` is `path` itself and that names a middleware. + /// A longer path the entry's `path` begins, and a folder, written with its + /// closing slash, get nothing made, so a page the origin does not have + /// keeps the origin's answer. #[must_use] pub fn making(&self, path: &str) -> Option<&PhaseEntry> { - self.0 - .iter() - .filter(|entry| { - entry.media_type != HTML_MEDIA_TYPE - && is_dispatched_media_type(&entry.media_type) - && entry - .path - .as_deref() - .is_some_and(|prefix| path.starts_with(prefix)) - && !entry.middleware.is_empty() - }) - .fold(None, |best: Option<&PhaseEntry>, entry| { - let length = |entry: &PhaseEntry| entry.path.as_deref().map_or(0, str::len); - match best { - Some(best) if length(best) >= length(entry) => Some(best), - _ => Some(entry), - } - }) + if path.ends_with('/') { + return None; + } + self.0.iter().find(|entry| { + entry.media_type != HTML_MEDIA_TYPE + && is_dispatched_media_type(&entry.media_type) + && entry.path.as_deref() == Some(path) + && !entry.middleware.is_empty() + }) } /// Checks each entry's media type, path and names, and that no entry is diff --git a/crates/trusted-server-core/src/middleware/tests.rs b/crates/trusted-server-core/src/middleware/tests.rs index bd9b19660a..fe521e5d45 100644 --- a/crates/trusted-server-core/src/middleware/tests.rs +++ b/crates/trusted-server-core/src/middleware/tests.rs @@ -1658,7 +1658,7 @@ fn deploy_validation_refuses_a_misshapen_entry() { } #[test] -fn the_making_entry_has_a_path_covering_the_request_and_names_a_middleware() { +fn the_making_entry_is_for_the_request_path_and_names_a_middleware() { let entries = PhaseEntries::new(vec![ PhaseEntry { media_type: "text/plain".to_owned(), @@ -1667,7 +1667,7 @@ fn the_making_entry_has_a_path_covering_the_request_and_names_a_middleware() { }, PhaseEntry { media_type: "text/plain".to_owned(), - path: Some("/ads".to_owned()), + path: Some("/ads.txt".to_owned()), middleware: vec!["example.sellers".to_owned()], }, PhaseEntry { @@ -1686,7 +1686,7 @@ fn the_making_entry_has_a_path_covering_the_request_and_names_a_middleware() { ); assert!( entries.making("/robots.txt").is_none(), - "should make nothing where no entry with a path covers the request, whatever covers every path" + "should make nothing where no entry is for the request path, whatever covers every path" ); } @@ -1694,7 +1694,7 @@ fn the_making_entry_has_a_path_covering_the_request_and_names_a_middleware() { fn an_html_entry_with_a_path_makes_nothing() { let entries = PhaseEntries::new(vec![PhaseEntry { media_type: HTML_MEDIA_TYPE.to_owned(), - path: Some("/news/".to_owned()), + path: Some("/news/missing".to_owned()), middleware: vec!["example.page".to_owned()], }]); @@ -1705,7 +1705,7 @@ fn an_html_entry_with_a_path_makes_nothing() { } #[test] -fn the_entry_with_the_longest_covering_path_makes_the_file() { +fn a_file_is_made_at_the_entry_path_alone() { let entries = PhaseEntries::new(vec![ PhaseEntry { media_type: "text/css".to_owned(), @@ -1717,6 +1717,11 @@ fn the_entry_with_the_longest_covering_path_makes_the_file() { path: Some("/ads.txt".to_owned()), middleware: vec!["example.sellers".to_owned()], }, + PhaseEntry { + media_type: "text/plain".to_owned(), + path: Some("/feeds/".to_owned()), + middleware: vec!["example.feeds".to_owned()], + }, ]); assert_eq!( @@ -1724,14 +1729,48 @@ fn the_entry_with_the_longest_covering_path_makes_the_file() { .making("/ads.txt") .map(|entry| entry.media_type.as_str()), Some("text/plain"), - "should make the file from the entry for its own path, not one written ahead for every path" + "should make the file from the entry for its own path" ); + for path in [ + "/site.css", + "/missing-page", + "/ads.txt.bak", + "/feeds/latest", + ] { + assert!( + entries.making(path).is_none(), + "should make nothing at {path}, which an entry's path only begins" + ); + } + for folder in ["/", "/feeds/"] { + assert!( + entries.making(folder).is_none(), + "should make nothing for the folder {folder}" + ); + } +} + +#[test] +fn the_first_entry_written_for_the_path_makes_the_file() { + let entries = PhaseEntries::new(vec![ + PhaseEntry { + media_type: "text/css".to_owned(), + path: Some("/ads.txt".to_owned()), + middleware: vec!["example.styles".to_owned()], + }, + PhaseEntry { + media_type: "text/plain".to_owned(), + path: Some("/ads.txt".to_owned()), + middleware: vec!["example.sellers".to_owned()], + }, + ]); + assert_eq!( entries - .making("/site.css") + .making("/ads.txt") .map(|entry| entry.media_type.as_str()), Some("text/css"), - "should still make a file only the shorter path covers" + "should take the entry written first among those for the path" ); } diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 809a544be6..dbca07a324 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -21662,6 +21662,33 @@ mod tests { ); } + #[tokio::test] + async fn a_missing_page_under_an_entry_path_keeps_the_origin_answer() { + for (entry_path, path) in [("/", "/missing-page"), ("/ads.txt", "/ads.txt.bak")] { + let proxied = proxy(text_entry_for(Some(entry_path)), Method::GET, path, 404).await; + + assert_eq!( + response_of(proxied.response).status(), + StatusCode::NOT_FOUND, + "should keep the origin's answer for {path}, which the entry for {entry_path} \ + covers but does not name" + ); + } + } + + #[tokio::test] + async fn an_entry_for_a_folder_makes_no_missing_page() { + for folder in ["/", "/feeds/"] { + let proxied = proxy(text_entry_for(Some(folder)), Method::GET, folder, 404).await; + + assert_eq!( + response_of(proxied.response).status(), + StatusCode::NOT_FOUND, + "should keep the origin's answer for the folder {folder}" + ); + } + } + #[tokio::test] async fn a_missing_file_outside_the_entry_path_keeps_the_origin_answer() { let proxied = proxy( diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 5cac16b9a6..df33aa0978 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1113,7 +1113,8 @@ it passes through untouched. the module, it writes the lines after the origin's last one, and where the origin has no file the file is made of the lines alone, see A file the origin does not have below. The entry's `path` is a prefix, so `/ads.txt` also -covers `/ads.txt.bak`. +covers `/ads.txt.bak`, but only `/ads.txt` itself is made where the origin +has none. **Refused when the settings load**: a line that is not a data record or a variable, naming which part is wrong, a comment, an empty list, and a @@ -1819,14 +1820,15 @@ template cache stores HTML alone, so the fetch entry's middleware and then the serve entry's run on each response as it is fetched. **A file the origin does not have.** When the origin answers `404 Not Found` -for a path under the `path` of a `[[fetch]]` entry for a media type other -than HTML, that entry's middleware runs on an empty document of its media -type and its output is the file, so `ads.txt` is made from the sellers lines -alone where the publisher has none. Where several such entries cover the -path, the one with the longest `path` makes the file. With no such entry, or -on any other answer from the origin, the origin's answer stands, and an -entry for HTML or without a path makes nothing, because a page the origin -does not have is the origin's answer. +for the `path` of a `[[fetch]]` entry for a media type other than HTML, +that entry's middleware runs on an empty document of its media type and its +output is the file, so `ads.txt` is made from the sellers lines alone where +the publisher has none. The file is made at that path itself and never at a +longer path the entry covers, and where two such entries name the path the +first written makes it. With no such entry, or on any other answer from the +origin, the origin's answer stands, and an entry for HTML, without a path or +for a folder makes nothing, because a page the origin does not have is the +origin's answer. ```toml [[fetch]] diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index b7cc23e21f..c24c4cd71a 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -209,9 +209,9 @@ decisions about HTML, and an action that makes one on another media type is refused by name when the chain is planned. A middleware named in an entry with a path, for a media type other than -HTML, also runs where the origin answers `404 Not Found` for a path under -it, on an empty document of the entry's media type, and what it writes is -the file. A middleware that adds to a file therefore writes the same on an +HTML, also runs where the origin answers `404 Not Found` for that path +itself, on an empty document of the entry's media type, and what it writes +is the file. A middleware that adds to a file therefore writes the same on an empty one, with nothing before its first line. ```rust From ab44c073fc9723506b90c17a8aa7c4edadda98e5 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 17:00:15 +0100 Subject: [PATCH 6/9] A request that names no host keeps the origin's 404 Core runs no middleware for a request that names no host, so a file made for one went out as an empty 200. Core now makes no file for such a request and the origin's answer stands. --- CHANGELOG.md | 2 +- crates/trusted-server-core/src/publisher.rs | 27 +++++++++++++++++++++ docs/guide/configuration.md | 8 +++--- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81f7039a44..6faa40074b 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 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 that path itself, which names a file and not a folder, 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. +- 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 that path itself, which names a file and not a folder, 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, or for a request that names no host, 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. - The InMobi Choice consent prompt as a module, `[cmp] module = "inmobi"` with `[cmp.inmobi] script_url`, from `crates/cmp/inmobi`. Where a `[[fetch]]` entry names `cmp.inmobi`, its middleware writes the IAB TCF stub, InMobi's GPP stub and the deferred Choice loader into the head, in that order, and the two stubs' behavior is tested with Node's test runner, which CI now runs for every module's `tests/*.test.mjs`. The script URL is refused unless it is `https` on an InMobi Choice host with no user name or password, and a selected prompt that no entry places is refused. 51Degrees maintain the crate until InMobi adopt it. diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index dbca07a324..e7cfac523f 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -5608,8 +5608,11 @@ pub async fn handle_publisher_request( ); // A file the origin does not have is made, see `PhaseEntries::making`. + // Core runs no middleware for a request that names no host, so there the + // made file would go out empty and the origin's answer stands instead. if response.status() == StatusCode::NOT_FOUND && (request_method == Method::GET || request_method == Method::HEAD) + && !request_host.is_empty() && let Some(entry) = settings.fetch.making(&request_path) { log::debug!( @@ -21705,6 +21708,30 @@ mod tests { ); } + #[tokio::test] + async fn a_request_that_names_no_host_keeps_the_origin_answer() { + let settings = settings_running_the_middleware_fixture(text_entry_for(Some("/ads.txt"))); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 404, + b"the origin's page".to_vec(), + vec![("content-type", "text/html; charset=utf-8")], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let request = build_request(Method::GET, "/ads.txt"); + + let response = run_publisher_proxy(&settings, &services, request).await; + + assert_eq!( + response_of(response).status(), + StatusCode::NOT_FOUND, + "should keep the origin's answer, because core runs no middleware for a request \ + that names no host and the made file would go out empty" + ); + } + #[tokio::test] async fn an_origin_error_on_the_entry_path_keeps_the_origin_answer() { let proxied = proxy( diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index df33aa0978..b1d4d755c9 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1825,10 +1825,10 @@ that entry's middleware runs on an empty document of its media type and its output is the file, so `ads.txt` is made from the sellers lines alone where the publisher has none. The file is made at that path itself and never at a longer path the entry covers, and where two such entries name the path the -first written makes it. With no such entry, or on any other answer from the -origin, the origin's answer stands, and an entry for HTML, without a path or -for a folder makes nothing, because a page the origin does not have is the -origin's answer. +first written makes it. With no such entry, on any other answer from the +origin, or for a request that names no host, the origin's answer stands, and +an entry for HTML, without a path or for a folder makes nothing, because a +page the origin does not have is the origin's answer. ```toml [[fetch]] From 75276f4cd936c9991a62872086e77758d5c044bd Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 17:00:17 +0100 Subject: [PATCH 7/9] The sellers module is refused unless an entry on /ads.txt names it The check accepted any text/plain entry that names the module, so an entry with no path wrote the lines into every text/plain response and made no ads.txt, and an entry for another path wrote them into that file. A selected module is now refused unless a [[fetch]] entry for text/plain with path /ads.txt names it, which is the entry that makes the file where the origin has none. --- CHANGELOG.md | 2 +- crates/ads-txt/sellers/src/lib.rs | 52 +++++++++++++++++++++++++------ docs/guide/configuration.md | 3 +- 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6faa40074b..6baad59078 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,7 +84,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - 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 that path itself, which names a file and not a folder, 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, or for a request that names no host, 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. +- `[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 that no entry for `text/plain` on `/ads.txt` 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. - The InMobi Choice consent prompt as a module, `[cmp] module = "inmobi"` with `[cmp.inmobi] script_url`, from `crates/cmp/inmobi`. Where a `[[fetch]]` entry names `cmp.inmobi`, its middleware writes the IAB TCF stub, InMobi's GPP stub and the deferred Choice loader into the head, in that order, and the two stubs' behavior is tested with Node's test runner, which CI now runs for every module's `tests/*.test.mjs`. The script URL is refused unless it is `https` on an InMobi Choice host with no user name or password, and a selected prompt that no entry places is refused. 51Degrees maintain the crate until InMobi adopt it. - `[robots-txt]` serves a publisher's `/robots.txt` from the settings. `modules` names what makes the file, in order. `refuse_all` and `allow_all` are Trusted Server's own, and any other name is a module that contributes rules through the new `RobotsTxtContributor` extension point, declared with `with_robots_txt_contributor`. Trusted Server writes the file, so a contributor cannot produce a malformed one, a refusal cannot be loosened, and a path in `always_allow` stays open to every crawler refused the whole site. With `refuse_all` selected every response an adapter finalizes carries `X-Robots-Tag: noindex, nofollow`. A module's answer is held in the key-value store until its refresh is due, a module that cannot answer has its last answer served, and with none held the answer is `503` and never an empty file. Without the section `/robots.txt` reaches the publisher's origin as before. diff --git a/crates/ads-txt/sellers/src/lib.rs b/crates/ads-txt/sellers/src/lib.rs index d70b652ccb..6d6437384e 100644 --- a/crates/ads-txt/sellers/src/lib.rs +++ b/crates/ads-txt/sellers/src/lib.rs @@ -29,6 +29,10 @@ pub const MODULE: &str = "ads-txt.sellers"; /// works on. pub const MEDIA_TYPE: &str = "text/plain"; +/// The path of the file, which the entry placing the middleware names, so +/// that the file is also made where the origin has none. +const PATH: &str = "/ads.txt"; + /// The variables the specification names, each written as `NAME=value`. const VARIABLES: [&str; 5] = [ "CONTACT", @@ -139,21 +143,25 @@ fn validate_lines(config: &SellersConfig) -> Result<(), Report Result<(), Report> { let placed = settings.fetch.entries().iter().any(|entry| { - entry.media_type == MEDIA_TYPE && entry.middleware.iter().any(|name| name == MODULE) + entry.media_type == MEDIA_TYPE + && entry.path.as_deref() == Some(PATH) + && entry.middleware.iter().any(|name| name == MODULE) }); if placed { return Ok(()); } Err(Report::new(TrustedServerError::Settings { message: format!( - "[ads-txt] selects `sellers`, but no [[fetch]] entry for `{MEDIA_TYPE}` names \ - `{MODULE}`, so the lines would never be written. Add an entry with media_type = \ - `{MEDIA_TYPE}`, path = `/ads.txt` and the middleware. The path is a prefix, so \ - the entry covers every path that starts with `/ads.txt`" + "[ads-txt] selects `sellers`, but no [[fetch]] entry for `{MEDIA_TYPE}` with path \ + = `{PATH}` names `{MODULE}`, so the lines would never be written into the file or \ + make it where the origin has none. Add an entry with media_type = \ + `{MEDIA_TYPE}`, path = `{PATH}` and the middleware. The path is a prefix, so the \ + entry also covers every path that starts with `{PATH}`" ), })) } @@ -393,13 +401,39 @@ mod tests { let refused = validate(&settings).expect_err("should refuse a module no entry places"); assert!( - format!("{refused:?}") - .contains("no [[fetch]] entry for `text/plain` names `ads-txt.sellers`"), + format!("{refused:?}").contains( + "no [[fetch]] entry for `text/plain` with path = `/ads.txt` names \ + `ads-txt.sellers`" + ), "{refused:?}" ); assert!(register(&settings).is_err()); } + #[test] + fn a_document_placing_the_module_off_the_file_s_path_is_refused() { + for path in [None, Some("/robots.txt"), Some("/ads"), Some("/")] { + let path_line = path.map_or_else(String::new, |path| format!("path = \"{path}\"\n")); + let settings = page_settings(&format!( + "[ads-txt]\nmodules = [\"sellers\"]\n\n[ads-txt.sellers]\nlines = \ + [\"{RECORD}\"]\n\n[[fetch]]\nmedia_type = \"text/plain\"\n{path_line}\ + middleware = [\"ads-txt.sellers\"]\n" + )); + + let Err(refused) = validate(&settings) else { + panic!("should refuse the module placed at {path:?}, where no file is made"); + }; + assert!( + format!("{refused:?}").contains("with path = `/ads.txt` names `ads-txt.sellers`"), + "should ask for the entry on `/ads.txt`: {refused:?}" + ); + assert!( + register(&settings).is_err(), + "should not register the module placed at {path:?}" + ); + } + } + #[test] fn a_line_that_is_not_a_record_or_a_variable_is_refused_for_its_reason() { let cases = [ diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index b1d4d755c9..def07d8252 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1119,7 +1119,8 @@ has none. **Refused when the settings load**: a line that is not a data record or a variable, naming which part is wrong, a comment, an empty list, and a document that selects the module but names it in no `[[fetch]]` entry for -`text/plain`, where the lines would never be written. +`text/plain` with `path = "/ads.txt"`, where the lines would never be +written into the file or make it. **Example**: From 9df3325b6fcd10c7b5cc47b0b8102a0568c59042 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 17:00:20 +0100 Subject: [PATCH 8/9] Tests for the methods, headers and streaming finalizer of a made file A POST, PUT, PATCH, DELETE or OPTIONS keeps the origin's 404. A made file carries its Content-Type alone and none of the headers the origin sent with its 404. A made file goes through publisher_response_into_streaming_response, which the Fastly adapter finishes the answer with, and carries what the middleware writes. --- crates/trusted-server-core/src/publisher.rs | 108 ++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index e7cfac523f..2fef36fe48 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -21732,6 +21732,114 @@ mod tests { ); } + #[tokio::test] + async fn a_method_other_than_get_or_head_keeps_the_origin_answer() { + for method in [ + Method::POST, + Method::PUT, + Method::PATCH, + Method::DELETE, + Method::OPTIONS, + ] { + let proxied = proxy( + text_entry_for(Some("/ads.txt")), + method.clone(), + "/ads.txt", + 404, + ) + .await; + + assert_eq!( + response_of(proxied.response).status(), + StatusCode::NOT_FOUND, + "should keep the origin's answer to a {method}" + ); + } + } + + #[tokio::test] + async fn a_made_file_carries_none_of_the_headers_of_the_origin_s_404() { + let settings = settings_running_the_middleware_fixture(text_entry_for(Some("/ads.txt"))); + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 404, + b"the origin's page".to_vec(), + vec![ + ("content-type", "text/html; charset=utf-8"), + ("set-cookie", "session=origin; Path=/"), + ("cache-control", "public, max-age=600"), + ("etag", "\"origin-404\""), + ("x-origin", "not found"), + ], + ); + let services = build_services_with_http_client( + Arc::clone(&stub) as Arc + ); + let request = HttpRequest::builder() + .method(Method::GET) + .uri("https://publisher.example/ads.txt") + .header(header::HOST, "publisher.example") + .body(EdgeBody::empty()) + .expect("should build request"); + + let response = response_of(run_publisher_proxy(&settings, &services, request).await); + + assert_eq!(response.status(), StatusCode::OK); + let names: Vec<&str> = response + .headers() + .keys() + .map(http::HeaderName::as_str) + .collect(); + assert_eq!( + names, + [header::CONTENT_TYPE.as_str()], + "should carry the Content-Type the file is made with and nothing the origin sent" + ); + } + + #[tokio::test] + async fn a_made_file_streams_through_the_finalizer_fastly_uses() { + use crate::integrations::registry_test_support::middleware_fixture as fixture; + + let proxied = proxy( + text_entry_for(Some("/ads.txt")), + Method::GET, + "/ads.txt", + 404, + ) + .await; + let response = publisher_response_into_streaming_response( + proxied.response, + &Method::GET, + Arc::new(proxied.settings), + &proxied.registry, + Arc::new(proxied.orchestrator), + proxied.services, + ) + .await + .expect("should finalize the made file"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + content_type_of(&response), + Some("text/plain; charset=utf-8") + ); + let mut body = Vec::new(); + match response.into_body() { + EdgeBody::Stream(mut stream) => { + while let Some(chunk) = futures::StreamExt::next(&mut stream).await { + body.extend_from_slice(&chunk.expect("should read the made file")); + } + } + other => body.extend_from_slice(&other.into_bytes().expect("should hold the file")), + } + assert_eq!( + String::from_utf8(body).expect("text"), + fixture::TEXT_LINE, + "should be what the middleware writes on an empty document" + ); + } + #[tokio::test] async fn an_origin_error_on_the_entry_path_keeps_the_origin_answer() { let proxied = proxy( From 4a461b752346c42b09b6553e84a32d183bdd3316 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 17:00:22 +0100 Subject: [PATCH 9/9] An Axum route test makes ads.txt from the stock sellers module Through the adapter's router with the stock modules and a stub origin that answers 404 with Set-Cookie and Cache-Control, a GET for /ads.txt gets 200, text/plain; charset=utf-8, neither header and the sellers lines alone. The route tests' settings take a test's own sections through test_settings_with. --- .../tests/routes.rs | 92 ++++++++++++++++++- 1 file changed, 89 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index 4e80a788b3..8008034892 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -19,7 +19,12 @@ const LEGACY_ADMIN_DENY_METHODS: &[&str] = /// `get_settings()` rejects by design, which would turn every route into a /// startup error page (and its route table into the fallback-only set). fn test_settings() -> trusted_server_core::settings::Settings { - trusted_server_core::settings::Settings::from_toml( + test_settings_with("") +} + +/// The route tests' settings with `extra` appended. +fn test_settings_with(extra: &str) -> trusted_server_core::settings::Settings { + trusted_server_core::settings::Settings::from_toml(&format!( r#" [publisher] domain = "test-publisher.example.com" @@ -35,8 +40,9 @@ fn test_settings() -> trusted_server_core::settings::Settings { [geo] assume_single_jurisdiction = true - "#, - ) + {extra} + "# + )) .expect("should parse route test settings") } @@ -845,3 +851,83 @@ async fn health_carries_no_version() { "should write no version on /health, which the service_version middleware writes on a page" ); } + +/// A publisher whose origin has no `ads.txt` gets one made of the sellers +/// lines, through this adapter's router and the stock sellers module. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn the_sellers_module_makes_an_ads_txt_the_origin_does_not_have() { + use std::sync::Arc; + + use trusted_server_core::platform::PlatformHttpClient; + use trusted_server_core::platform::test_support::{ + StubHttpClient, build_services_with_http_client, + }; + + const SELLERS: &str = r#" + [ads-txt] + modules = ["sellers"] + + [ads-txt.sellers] + lines = ["ssp.example, 12345, DIRECT, f08c47fec0942fa0"] + + [[fetch]] + media_type = "text/plain" + path = "/ads.txt" + middleware = ["ads-txt.sellers"] + "#; + + let origin = Arc::new(StubHttpClient::new()); + origin.push_response_with_headers( + 404, + b"Not found".to_vec(), + vec![ + ("content-type", "text/html; charset=utf-8"), + ("set-cookie", "session=origin; Path=/"), + ("cache-control", "public, max-age=600"), + ], + ); + let router = TrustedServerApp::routes_with_settings_and_services( + test_settings_with(SELLERS), + build_services_with_http_client(Arc::clone(&origin) as Arc), + ) + .expect("should build the router with the sellers module"); + let request = edgezero_core::http::request_builder() + .method("GET") + .uri("https://test-publisher.example.com/ads.txt") + .header("host", "test-publisher.example.com") + .body(edgezero_core::body::Body::empty()) + .expect("should build the ads.txt request"); + + let response = router + .oneshot(request) + .await + .expect("should answer the ads.txt request"); + + assert_eq!( + response.status(), + 200, + "should make the file the origin does not have" + ); + assert_eq!( + response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()), + Some("text/plain; charset=utf-8") + ); + assert!( + response.headers().get("set-cookie").is_none() + && response.headers().get("cache-control").is_none(), + "should carry none of the headers the origin sent with its 404: {:?}", + response.headers() + ); + let body = response + .into_body() + .into_bytes() + .expect("should buffer the made file"); + assert_eq!( + body.as_ref(), + b"ssp.example, 12345, DIRECT, f08c47fec0942fa0\n", + "should be the sellers lines alone" + ); +}