From 448f41df40b56af3598bf481d521dd7e8a90f1c1 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 9 Oct 2026 08:48:15 +0100 Subject: [PATCH 01/18] A Fastly sandbox keeps an application for each app-config key A sandbox that serves several requests kept the one application it built first and handed it to every request after. That is right while a service reads one app-config blob. The commit after this one lets a service read a blob for each host, which needs an application for each, and an application built from one publisher's blob must never answer a request for another's. The sandbox's payload is now `RetainedApps`, which keeps each application under the key of the blob it was built from and finds one by that key and by nothing else. It retains only a successful build and counts every attempt, as the framework's one slot did. The framework hands its payload out by shared reference and offers no way to replace it, so the holder is made once and its list sits behind a `RefCell`. It keeps the eight applications served most recently, because all of them share the sandbox's one memory bound. The build count a sandbox reports, in the counters probe, the response headers and the retirement line, is the holder's. A test now holds the count a response carries to the builds made, which nothing did before. Nothing changes for a service that reads one blob. It builds one application under one key. --- .../trusted-server-adapter-fastly/src/lib.rs | 60 +-- .../src/sandbox.rs | 390 +++++++++++++++--- 2 files changed, 361 insertions(+), 89 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/lib.rs b/crates/trusted-server-adapter-fastly/src/lib.rs index 61cd2bde41..9f23a8af51 100644 --- a/crates/trusted-server-adapter-fastly/src/lib.rs +++ b/crates/trusted-server-adapter-fastly/src/lib.rs @@ -252,7 +252,7 @@ fn sandbox_metrics_response(sandbox: &Sandbox, ordinal: u64) -> FastlyResponse { let body = serde_json::json!({ "instance": instance_id(), "ordinal": ordinal, - "builds": sandbox.initialization_attempts(), + "builds": crate::sandbox::build_attempts(sandbox), }); FastlyResponse::from_status(fastly::http::StatusCode::OK) @@ -416,36 +416,36 @@ fn edgezero_main(mut req: FastlyRequest, sandbox: &mut Sandbox, ordinal: u64, re } }; - // Build lazily, once per sandbox. Reached only past the health, JA4, and - // counters short-circuits, so none of those pays for construction. + // Build lazily, once per sandbox for each app-config key it is asked for. + // Reached only past the health, JA4, and counters short-circuits, so none + // of those pays for construction. // - // `initialize` builds only when the sandbox is empty, retains only - // success, and returns the error unchanged. A failed build hands back its - // error router as the error payload: that serves this request and is then - // dropped, so a transient config-store failure cannot pin the sandbox into - // permanent error mode, and the next callback retries construction. - let failed_build = sandbox - .initialize(|| { - let (app, state) = TrustedServerApp::build_app_with_state(&runtime_stores); - match state { - Some(state) => Ok(RetainedApp { app, state }), - None => Err(app), - } - }) - .err(); - - let (app, app_state): (&edgezero_core::app::App, Option>) = - match (failed_build.as_ref(), sandbox.state()) { - (Some(app), _) => (app, None), - (None, Some(retained)) => (&retained.app, Some(Arc::clone(&retained.state))), - (None, None) => { - log::error!("no application available after initialization"); - FastlyResponse::from_status(fastly::http::StatusCode::INTERNAL_SERVER_ERROR) - .with_body_text_plain("Internal Server Error") - .send_to_client(); - return; - } - }; + // `get_or_build` builds only when the sandbox holds no application for + // this key, retains only success, and returns the error unchanged. A + // failed build hands back its error router as the error payload: that + // serves this request and is then dropped, so a transient config-store + // failure cannot pin the sandbox into permanent error mode, and the next + // callback retries construction. + crate::sandbox::hold_applications(sandbox); + let Some(apps) = sandbox.state() else { + log::error!("no application holder available after initialization"); + FastlyResponse::from_status(fastly::http::StatusCode::INTERNAL_SERVER_ERROR) + .with_body_text_plain("Internal Server Error") + .send_to_client(); + return; + }; + let built = apps.get_or_build(&runtime_stores.config_key, || { + let (app, state) = TrustedServerApp::build_app_with_state(&runtime_stores); + match state { + Some(state) => Ok(RetainedApp { app, state }), + None => Err(app), + } + }); + + let (app, app_state): (&edgezero_core::app::App, Option>) = match &built { + Ok(retained) => (&retained.app, Some(Arc::clone(&retained.state))), + Err(error_app) => (error_app, None), + }; let settings_snapshot = app_state.as_ref().map(|state| Arc::clone(&state.settings)); let counters = diff --git a/crates/trusted-server-adapter-fastly/src/sandbox.rs b/crates/trusted-server-adapter-fastly/src/sandbox.rs index c0db165299..9233d97b89 100644 --- a/crates/trusted-server-adapter-fastly/src/sandbox.rs +++ b/crates/trusted-server-adapter-fastly/src/sandbox.rs @@ -6,8 +6,11 @@ //! per-sandbox bookkeeping the entry point carries across requests. //! //! [`Sandbox`] owns the state the entry point carries across requests: the -//! logger guard, the measurement counters, and the retained application. +//! logger guard, the measurement counters, and the retained applications. +use std::cell::{Cell, RefCell}; +use std::convert::Infallible; +use std::rc::Rc; use std::sync::Arc; use std::time::Duration; @@ -55,21 +58,112 @@ pub(crate) const INSTANCE_ID_UNAVAILABLE: &str = "unavailable"; /// Per-sandbox state, owned by `edgezero_adapter_fastly::lifecycle::Sandbox`. /// -/// The framework owns lazy successful-only retention, the callback count, the -/// initialization-attempt count, and the one-time setup guard. This alias -/// names the application-specific payload it retains. -pub(crate) type Sandbox = edgezero_adapter_fastly::lifecycle::Sandbox; +/// The framework owns the callback count and the one-time setup guard, and +/// holds the payload this alias names. The payload owns retention, because +/// the framework's slot holds one value and a sandbox can be asked for more +/// than one application. +pub(crate) type Sandbox = edgezero_adapter_fastly::lifecycle::Sandbox; -/// The application state a retained sandbox carries. +/// How many applications a sandbox keeps. +/// +/// Each holds a publisher's settings, registry and routes, and all of them +/// share the sandbox's one memory bound, so the least recently served is +/// dropped past this many. +pub(crate) const MAX_RETAINED_APPS: usize = 8; + +/// The application state a retained sandbox carries for one app-config blob. /// /// Only ever reachable after a successful build: a failed build returns the -/// error router as the `initialize` error instead, so it is served for the +/// error router as the build's error instead, so it is served for the /// current request and dropped rather than retained. pub(crate) struct RetainedApp { pub(crate) app: App, pub(crate) state: Arc, } +/// The applications a sandbox has built, each under the key of the app-config +/// blob it was built from. +/// +/// A service that serves one publisher builds one. A service whose `__KEY` +/// selector names a blob for each host is asked for several, and an +/// application built from one publisher's blob must never answer a request +/// for another's, so an application is found by its key and by nothing else. +/// +/// The framework hands its payload out by shared reference, so the list sits +/// behind a [`RefCell`]. One callback runs to completion before the next +/// begins and no borrow is held across a build, so the cell is never borrowed +/// twice. +#[derive(Default)] +pub(crate) struct RetainedApps { + /// Most recently served first. + apps: RefCell)>>, + build_attempts: Cell, +} + +impl RetainedApps { + /// The application built from the blob at `config_key`, building it when + /// the sandbox holds none. + /// + /// Retains only success. A failed build hands its error back unchanged, + /// which serves this request and is dropped, so a transient config-store + /// failure cannot pin the sandbox into error mode and the next request + /// for the key builds again. + /// + /// # Errors + /// + /// Returns the error `build` returned. + pub(crate) fn get_or_build( + &self, + config_key: &str, + build: impl FnOnce() -> Result, + ) -> Result, E> { + if let Some(app) = self.served(config_key) { + return Ok(app); + } + + self.build_attempts + .set(self.build_attempts.get().saturating_add(1)); + let app = Rc::new(build()?); + + let mut apps = self.apps.borrow_mut(); + apps.insert(0, (config_key.to_owned(), Rc::clone(&app))); + apps.truncate(MAX_RETAINED_APPS); + Ok(app) + } + + /// The retained application for `config_key`, marked as the most recently + /// served. + fn served(&self, config_key: &str) -> Option> { + let mut apps = self.apps.borrow_mut(); + let position = apps.iter().position(|(key, _)| key == config_key)?; + let entry = apps.remove(position); + let app = Rc::clone(&entry.1); + apps.insert(0, entry); + Some(app) + } + + /// Builds attempted, failed ones included. + pub(crate) fn build_attempts(&self) -> u64 { + self.build_attempts.get() + } + + /// How many applications are retained. + #[cfg(test)] + pub(crate) fn retained(&self) -> usize { + self.apps.borrow().len() + } +} + +/// Makes the holder of `sandbox`'s applications, once. +pub(crate) fn hold_applications(sandbox: &mut Sandbox) { + let Ok(()) = sandbox.initialize(|| Ok::<_, Infallible>(RetainedApps::default())); +} + +/// Application builds `sandbox` has attempted, failed ones included. +pub(crate) fn build_attempts(sandbox: &Sandbox) -> u64 { + sandbox.state().map_or(0, RetainedApps::build_attempts) +} + /// Startup diagnostics held until a logger exists. /// /// Limit resolution runs in `main`, before any logger is installed, so its @@ -99,16 +193,17 @@ impl StartupDiagnostics { } } -/// Snapshots of the framework's counters, taken around one callback. +/// Snapshots of the sandbox's counters, taken around one callback. /// /// `serve_custom` owns the [`Sandbox`] and drops it when serving ends, so the -/// retirement line cannot read it afterwards. These are snapshots of -/// `EdgeZero`'s counters taken while the sandbox is still borrowed; nothing here -/// increments anything. +/// retirement line cannot read it afterwards. These are snapshots of the +/// callback count `EdgeZero` keeps and the build count [`RetainedApps`] keeps, +/// taken while the sandbox is still borrowed. Nothing here increments +/// anything. /// /// The two are read at different points on purpose. The framework increments /// its callback count *before* invoking the callback, so `requests` is correct -/// on entry. Initialization happens *during* the callback, so `attempts` must +/// on entry. An application is built *during* the callback, so `attempts` must /// be read on the way out. The count itself is never lost — it lives in the /// sandbox until serving ends — but a snapshot taken on entry is stale by the /// time the final callback finishes, so a build it performed goes unreported. @@ -129,7 +224,7 @@ impl RetirementCounters { ) -> R { self.requests = sandbox.requests(); let outcome = callback(sandbox); - self.attempts = sandbox.initialization_attempts(); + self.attempts = build_attempts(sandbox); outcome } @@ -138,7 +233,7 @@ impl RetirementCounters { self.requests } - /// Initialization attempts observed on exit from the last callback. + /// Build attempts observed on exit from the last callback. pub(crate) fn attempts(&self) -> u64 { self.attempts } @@ -170,7 +265,7 @@ impl SandboxCounters { .filter(|settings| metrics_enabled(settings)) .map(|_| Self { ordinal, - builds: sandbox.initialization_attempts(), + builds: build_attempts(sandbox), request_id: request_id.to_owned(), }) } @@ -739,6 +834,27 @@ mod tests { ); } + /// A sandbox holding its applications, as the entry point makes it. + fn holding_sandbox() -> Sandbox { + let mut sandbox = Sandbox::default(); + hold_applications(&mut sandbox); + sandbox + } + + /// Hands back the application for `key`, which must already be retained. + fn retained_for(apps: &RetainedApps, key: &str) -> Rc { + apps.get_or_build(key, || -> Result { + panic!("`{key}` is retained and must not be rebuilt") + }) + .expect("a retained application should be reused") + } + + /// Builds and retains an application for `key`. + fn build_for(apps: &RetainedApps, key: &str) -> Rc { + apps.get_or_build(key, || Ok::<_, &str>(test_retained())) + .expect("the build should succeed") + } + #[test] fn a_sandbox_starts_with_no_retained_application() { let sandbox = Sandbox::default(); @@ -748,19 +864,54 @@ mod tests { "construction must be lazy so the health probe never pays for it" ); assert_eq!( - sandbox.initialization_attempts(), + build_attempts(&sandbox), 0, "a sandbox that has served nothing should report no build attempts" ); } + #[test] + fn holding_applications_builds_none() { + let mut sandbox = holding_sandbox(); + + assert_eq!( + sandbox + .state() + .expect("should hold its applications") + .retained(), + 0, + "should hold no application before one is asked for" + ); + assert_eq!( + build_attempts(&sandbox), + 0, + "making the holder should not count as a build" + ); + + // Asking again must keep what is held, or every request would start + // from an empty holder and nothing would ever be reused. + build_for( + sandbox.state().expect("should hold its applications"), + "trusted_server_config", + ); + hold_applications(&mut sandbox); + assert_eq!( + sandbox + .state() + .expect("should hold its applications") + .retained(), + 1, + "holding again should keep the applications already built" + ); + } + #[test] fn a_retained_application_is_reused_without_rebuilding() { - let mut sandbox = Sandbox::default(); + let apps = RetainedApps::default(); let builds = Cell::new(0_u32); - sandbox - .initialize(|| { + let first = apps + .get_or_build("trusted_server_config", || { builds.set(builds.get() + 1); Ok::<_, &str>(test_retained()) }) @@ -768,12 +919,16 @@ mod tests { assert_eq!(builds.get(), 1, "the first request should build"); for attempt in 2..=4_u32 { - sandbox - .initialize(|| -> Result { + let again = apps + .get_or_build("trusted_server_config", || -> Result { builds.set(builds.get() + 1); panic!("request {attempt} must not rebuild a retained application") }) .expect("a retained application should be reused"); + assert!( + Rc::ptr_eq(&first, &again), + "request {attempt} should be handed the application built first" + ); } assert_eq!( @@ -781,21 +936,17 @@ mod tests { 1, "four requests should cost exactly one build; that is the whole point" ); - assert_eq!( - sandbox.initialization_attempts(), - 1, - "the framework counter should agree" - ); + assert_eq!(apps.build_attempts(), 1, "the build counter should agree"); } #[test] fn a_failed_build_is_retried_and_a_later_success_is_retained() { - let mut sandbox = Sandbox::default(); + let apps = RetainedApps::default(); let builds = Cell::new(0_u32); // Request 1: the build fails. The error payload stands in for the // error router `build_app_with_state` returns when state is `None`. - let failed = sandbox.initialize(|| { + let failed = apps.get_or_build("trusted_server_config", || { builds.set(builds.get() + 1); Err::("error router") }); @@ -804,31 +955,23 @@ mod tests { Some("error router"), "a failed build must be handed back for this request only" ); - assert!( - sandbox.state().is_none(), - "a failed build must not be retained" - ); + assert_eq!(apps.retained(), 0, "a failed build must not be retained"); // Request 2: the retry succeeds. Only reachable because the failure // was not retained. - sandbox - .initialize(|| { - builds.set(builds.get() + 1); - Ok::<_, &str>(test_retained()) - }) - .expect("the retry should succeed"); - assert!( - sandbox.state().is_some(), + apps.get_or_build("trusted_server_config", || { + builds.set(builds.get() + 1); + Ok::<_, &str>(test_retained()) + }) + .expect("the retry should succeed"); + assert_eq!( + apps.retained(), + 1, "the recovered application should be retained" ); - // Request 3: recovery is durable — no further build. - sandbox - .initialize(|| -> Result { - builds.set(builds.get() + 1); - panic!("a recovered application must not be rebuilt") - }) - .expect("request 3 should reuse the recovered app"); + // Request 3: recovery is durable, so no further build. + retained_for(&apps, "trusted_server_config"); assert_eq!( builds.get(), @@ -836,12 +979,129 @@ mod tests { "one failed build plus one successful build, then reuse" ); assert_eq!( - sandbox.initialization_attempts(), + apps.build_attempts(), 2, "both attempts should be counted so a retry loop stays visible" ); } + #[test] + fn an_application_answers_only_for_the_key_it_was_built_from() { + let apps = RetainedApps::default(); + + let first = build_for(&apps, "a.example"); + let second = build_for(&apps, "b.example"); + + assert!( + !Rc::ptr_eq(&first, &second), + "a second publisher must be built an application of its own" + ); + assert_eq!(apps.build_attempts(), 2, "two blobs should cost two builds"); + + for (key, built) in [ + ("b.example", &second), + ("a.example", &first), + ("a.example", &first), + ("b.example", &second), + ] { + assert!( + Rc::ptr_eq(&retained_for(&apps, key), built), + "`{key}` must be answered by the application built from its own blob" + ); + } + assert_eq!( + apps.build_attempts(), + 2, + "serving both in any order should build nothing more" + ); + } + + #[test] + fn a_failed_build_for_one_key_leaves_another_s_application_retained() { + let apps = RetainedApps::default(); + let first = build_for(&apps, "a.example"); + + let failed = apps.get_or_build("b.example", || Err::("error router")); + assert_eq!( + failed.err(), + Some("error router"), + "the second publisher's failed build should be handed back" + ); + + assert!( + Rc::ptr_eq(&retained_for(&apps, "a.example"), &first), + "the first publisher's application should still be retained" + ); + assert_eq!( + apps.retained(), + 1, + "the failed build should not have been retained under either key" + ); + } + + #[test] + fn the_least_recently_served_application_is_dropped_past_the_bound() { + let apps = RetainedApps::default(); + let key = |index: usize| format!("{index}.example"); + + for index in 0..MAX_RETAINED_APPS { + build_for(&apps, &key(index)); + } + assert_eq!( + apps.retained(), + MAX_RETAINED_APPS, + "should keep every application up to the bound" + ); + + // Serving the oldest makes the second oldest the one to drop. + retained_for(&apps, &key(0)); + build_for(&apps, "one-more.example"); + + assert_eq!( + apps.retained(), + MAX_RETAINED_APPS, + "should never keep more than the bound" + ); + retained_for(&apps, &key(0)); + retained_for(&apps, "one-more.example"); + for index in 2..MAX_RETAINED_APPS { + retained_for(&apps, &key(index)); + } + + let rebuilt = Cell::new(false); + apps.get_or_build(&key(1), || { + rebuilt.set(true); + Ok::<_, &str>(test_retained()) + }) + .expect("the dropped application should build again"); + assert!( + rebuilt.get(), + "the least recently served application should have been dropped" + ); + } + + #[test] + fn captured_counters_report_the_builds_the_sandbox_attempted() { + let sandbox = holding_sandbox(); + let mut settings = Settings::default(); + settings.debug.sandbox_metrics_enabled = true; + + let apps = sandbox.state().expect("should hold its applications"); + build_for(apps, "a.example"); + build_for(apps, "b.example"); + retained_for(apps, "a.example"); + + assert_eq!( + SandboxCounters::capture(&sandbox, 3, "req-3", Some(&settings)), + Some(SandboxCounters { + ordinal: 3, + builds: 2, + request_id: "req-3".to_owned(), + }), + "should report a build for each blob and none for a reuse" + ); + } + /// Drives the real reporting path: the same `observe` that `serve_loop` /// wraps every callback in. A build performed by the FINAL callback must /// appear in the retirement snapshot; reading the attempt count on entry @@ -856,15 +1116,17 @@ mod tests { assert_eq!( counters.attempts(), 0, - "a callback that never initializes should report no attempts" + "a callback that never builds should report no attempts" ); // Callback 2 succeeds. The build happens DURING this callback, so it // only shows up if the count is read on the way out. counters.observe(&mut sandbox, |sandbox| { - sandbox - .initialize(|| Ok::<_, &str>(test_retained())) - .expect("the build should succeed"); + hold_applications(sandbox); + build_for( + sandbox.state().expect("should hold its applications"), + "trusted_server_config", + ); }); assert_eq!( counters.attempts(), @@ -878,11 +1140,17 @@ mod tests { let mut sandbox = Sandbox::default(); let mut counters = RetirementCounters::default(); - // The application state is not retained, but the attempt itself stays - // in `initialization_attempts()` until the sandbox is dropped. What a - // stale snapshot loses is the report, not the count. + // The application is not retained, but the attempt itself stays in + // the holder's count until the sandbox is dropped. What a stale + // snapshot loses is the report, not the count. counters.observe(&mut sandbox, |sandbox| { - let failed = sandbox.initialize(|| Err::("error router")); + hold_applications(sandbox); + let failed = sandbox + .state() + .expect("should hold its applications") + .get_or_build("trusted_server_config", || { + Err::("error router") + }); assert_eq!( failed.err(), Some("error router"), @@ -895,8 +1163,12 @@ mod tests { 1, "a failed build on the final callback must still be reported" ); - assert!( - sandbox.state().is_none(), + assert_eq!( + sandbox + .state() + .expect("should hold its applications") + .retained(), + 0, "a failed build must not be retained" ); } @@ -957,7 +1229,7 @@ mod tests { ); } - // Request ordinals and build attempts are counted by + // Request ordinals are counted by // `edgezero_adapter_fastly::lifecycle::Sandbox` itself, incremented in its // private per-callback hook. They are exercised through `serve_custom` / // `run_custom` at runtime and covered by the framework's own tests, so From e8023755363371f0a8bab7ecc1b12e57a844f9a6 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 9 Oct 2026 09:25:52 +0100 Subject: [PATCH 02/18] A `__KEY` selector with `{host}` names an app-config blob for each host One Fastly service can serve several publishers, each from an application config of its own. A `__KEY` selector that contains `{host}` is a template. The entry point puts the request's host in the placeholder's place, lowercased and without a port or a trailing dot, before anything reads settings, so the debug probes, the application build and the finalizers all read the blob of the publisher the request is for. A host with no blob is answered `421 Misdirected Request`, and the answer is not to be stored. It is never answered from the logical store ID or from another host's blob, because a shared fallback is how a host nobody configured comes to be served another publisher's site. Only letters, digits, hyphens and dots make a host name, so a `Host` header cannot be made into the logical store ID or the key of a blob's chunk, each of which carries an underscore. A lookup that fails is answered `500`, because a request is not served while it is unknown which blob is its own. The placeholder goes in the selector that already chooses the key, and not in a switch of its own, so that a staged version stays apart from production. A staged deploy copies a service's runtime entries into a store of its own and points only the key selector at `_staging`. A separate switch would be copied with the rest, and a staged version would then read the production blobs. The selector is replaced, so a staged version reads the one staging blob. A selector without the placeholder is the key as it stands and nothing is looked up, so a service that reads one blob is unchanged. --- .../trusted-server-adapter-fastly/src/app.rs | 6 + .../src/config_selection.rs | 280 ++++++++++++++++++ .../trusted-server-adapter-fastly/src/lib.rs | 162 +++++++++- 3 files changed, 447 insertions(+), 1 deletion(-) create mode 100644 crates/trusted-server-adapter-fastly/src/config_selection.rs diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 5e4aa5efa6..92cc4ae56b 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -169,6 +169,12 @@ use crate::platform::{ // AppState // --------------------------------------------------------------------------- +/// The stores a service reads and the key of its app-config blob. +/// +/// [`Self::from_env`] takes the key from the service's `__KEY` selector as it +/// is written. A selector that names a blob for each host is resolved for the +/// request before anything is built, by `stores_for_request` in the entry +/// point. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct RuntimeStoreConfig { pub(crate) config_store_name: StoreName, diff --git a/crates/trusted-server-adapter-fastly/src/config_selection.rs b/crates/trusted-server-adapter-fastly/src/config_selection.rs new file mode 100644 index 0000000000..9d9011b8d3 --- /dev/null +++ b/crates/trusted-server-adapter-fastly/src/config_selection.rs @@ -0,0 +1,280 @@ +//! Which app-config blob serves a request. +//! +//! A service reads one blob, at the key its `__KEY` selector names or at the +//! logical store ID. A selector that carries [`HOST_PLACEHOLDER`] names a blob +//! for each host instead. The placeholder is replaced by the host a request +//! names, so one service serves several publishers, each from a blob of its +//! own. +//! +//! A host that has no blob is refused. It is never answered from another key, +//! because a shared fallback is how a host nobody configured comes to be +//! served another publisher's site. + +use core::fmt::Display; + +/// The part of a `__KEY` selector that a request's host replaces. +pub(crate) const HOST_PLACEHOLDER: &str = "{host}"; + +/// The longest name DNS carries. +const MAX_HOST_LEN: usize = 253; + +/// Why a request is not served. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Refusal { + /// The request names no host, or a host the store holds no blob for. + NoConfigForHost, + /// Whether the store holds the host's blob could not be read. + StoreUnavailable(String), +} + +/// The host as a blob is keyed by it, or `None` when `host` is not a host +/// name. +/// +/// Lowercased, with a port and one trailing dot removed, because +/// `Publisher.Example:443` and `publisher.example.` name one site. Only +/// letters, digits and hyphens pass, in labels joined by single dots. A +/// request header therefore cannot be made into the key of anything but a +/// host's blob: the logical store ID and the keys of a blob's chunks each +/// carry an underscore, which no host does. +pub(crate) fn normalized_host(host: &str) -> Option { + let without_port = match host.rsplit_once(':') { + Some((name, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => name, + _ => host, + }; + let name = without_port.strip_suffix('.').unwrap_or(without_port); + let is_host_name = !name.is_empty() + && name.len() <= MAX_HOST_LEN + && name.split('.').all(|label| { + !label.is_empty() + && label + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-') + }); + is_host_name.then(|| name.to_ascii_lowercase()) +} + +/// Resolves the key of the blob that serves a request. +/// +/// `selector` is the service's `__KEY` selector, or the logical store ID +/// where none is set. A selector without the placeholder is the key itself +/// and nothing is looked up, which is every service that serves one +/// publisher. Otherwise the key is the selector with the request's host in +/// the placeholder's place, and `exists` is asked whether the store holds it. +/// +/// # Errors +/// +/// Returns [`Refusal::NoConfigForHost`] when the request names no host name +/// or the store holds no blob at the host's key. Returns +/// [`Refusal::StoreUnavailable`] when `exists` fails, because a request is +/// not served while it is unknown which blob is its own. +pub(crate) fn resolve_key( + selector: &str, + host: Option<&str>, + exists: impl FnOnce(&str) -> Result, +) -> Result { + if !selector.contains(HOST_PLACEHOLDER) { + return Ok(selector.to_owned()); + } + let key = host + .and_then(normalized_host) + .map(|host| selector.replace(HOST_PLACEHOLDER, &host)) + .ok_or(Refusal::NoConfigForHost)?; + match exists(&key) { + Ok(true) => Ok(key), + Ok(false) => Err(Refusal::NoConfigForHost), + Err(error) => Err(Refusal::StoreUnavailable(error.to_string())), + } +} + +/// Whether the config store linked as `store_name` holds an entry at `key`. +/// +/// Reads the entry at the key alone and not the chunks a large blob is split +/// into, because the question is only whether the host has a blob. +/// +/// # Errors +/// +/// Returns a message naming the store when it cannot be opened or the lookup +/// fails. Uses [`fastly::ConfigStore::try_get`], because `get` panics on a +/// lookup error. +pub(crate) fn blob_exists(store_name: &str, key: &str) -> Result { + let store = fastly::ConfigStore::try_open(store_name) + .map_err(|error| format!("failed to open config store `{store_name}`: {error}"))?; + store + .try_get(key) + .map(|value| value.is_some()) + .map_err(|error| { + format!("failed to look up `{key}` in config store `{store_name}`: {error}") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Stands in for a store lookup that must not happen. + fn no_lookup(key: &str) -> Result { + panic!("the store must not be asked about `{key}`") + } + + /// A store holding exactly `keys`. + fn holding<'a>(keys: &'a [&'a str]) -> impl FnOnce(&str) -> Result + 'a { + move |key| Ok(keys.contains(&key)) + } + + #[test] + fn a_selector_without_the_placeholder_is_the_key_and_nothing_is_looked_up() { + for selector in ["trusted_server_config", "trusted_server_config_staging"] { + assert_eq!( + resolve_key(selector, Some("publisher.example"), no_lookup), + Ok(selector.to_owned()), + "a service that serves one publisher should read its one key" + ); + } + assert_eq!( + resolve_key("trusted_server_config", None, no_lookup), + Ok("trusted_server_config".to_owned()), + "one key should serve a request that names no host" + ); + } + + #[test] + fn the_host_takes_the_placeholder_s_place() { + for (selector, key) in [ + ("{host}", "publisher.example"), + ("{host}_staging", "publisher.example_staging"), + ("sites.{host}", "sites.publisher.example"), + ] { + assert_eq!( + resolve_key(selector, Some("publisher.example"), holding(&[key])), + Ok(key.to_owned()), + "should key the blob by the host under `{selector}`" + ); + } + } + + #[test] + fn each_host_reads_a_key_of_its_own() { + let stored = ["a.example", "b.example"]; + + assert_eq!( + resolve_key("{host}", Some("a.example"), holding(&stored)), + Ok("a.example".to_owned()), + "should read the first publisher's blob for its host" + ); + assert_eq!( + resolve_key("{host}", Some("b.example"), holding(&stored)), + Ok("b.example".to_owned()), + "should read the second publisher's blob for its host" + ); + } + + #[test] + fn a_host_is_keyed_without_case_port_or_trailing_dot() { + for host in [ + "publisher.example", + "Publisher.EXAMPLE", + "publisher.example:443", + "publisher.example.", + "PUBLISHER.example.:8443", + ] { + assert_eq!( + normalized_host(host).as_deref(), + Some("publisher.example"), + "`{host}` should name the same site" + ); + } + assert_eq!( + normalized_host("127.0.0.1:7676").as_deref(), + Some("127.0.0.1"), + "should key a local address without its port" + ); + assert_eq!( + normalized_host("xn--bcher-kva.example").as_deref(), + Some("xn--bcher-kva.example"), + "should keep an internationalized name as it is sent" + ); + } + + #[test] + fn two_names_of_one_publisher_are_two_keys() { + assert_ne!( + normalized_host("www.publisher.example"), + normalized_host("publisher.example"), + "a publisher decides what each of its names serves" + ); + } + + #[test] + fn what_is_not_a_host_name_is_refused_without_a_lookup() { + let too_long = "a".repeat(MAX_HOST_LEN + 1); + for host in [ + "", + ".", + "publisher..example", + ".publisher.example", + "publisher.example..", + "publisher.example:", + "publisher.example:80:80", + " publisher.example", + "publisher.example/path", + "[::1]", + "[::1]:7676", + "publisher_example.com", + "trusted_server_config", + "publisher.example.__edgezero_chunks.abc.0", + "{host}", + "b\u{fc}cher.example", + too_long.as_str(), + ] { + assert_eq!( + resolve_key("{host}", Some(host), no_lookup), + Err(Refusal::NoConfigForHost), + "`{host}` is not a host name and should be refused" + ); + } + assert_eq!( + resolve_key("{host}", None, no_lookup), + Err(Refusal::NoConfigForHost), + "a request that names no host should be refused" + ); + } + + #[test] + fn the_longest_host_name_is_keyed() { + let label = "a".repeat(63); + let host = format!("{label}.{label}.{label}.{}", "a".repeat(61)); + assert_eq!(host.len(), MAX_HOST_LEN, "should build the longest name"); + + assert_eq!( + normalized_host(&host), + Some(host.clone()), + "should key a name of the greatest length DNS carries" + ); + } + + #[test] + fn a_host_with_no_blob_is_refused_and_not_served_from_another_key() { + // The store holds a blob at the logical store ID and one for another + // publisher. Neither may answer for a host that has none. + let stored = ["trusted_server_config", "a.example"]; + + assert_eq!( + resolve_key("{host}", Some("unknown.example"), holding(&stored)), + Err(Refusal::NoConfigForHost), + "a host with no blob should be refused" + ); + } + + #[test] + fn a_failed_lookup_refuses_and_does_not_serve() { + let refused = resolve_key("{host}", Some("publisher.example"), |_key| { + Err::("lookup failed") + }); + + assert_eq!( + refused, + Err(Refusal::StoreUnavailable("lookup failed".to_owned())), + "should not serve while the host's blob is unknown" + ); + } +} diff --git a/crates/trusted-server-adapter-fastly/src/lib.rs b/crates/trusted-server-adapter-fastly/src/lib.rs index 9f23a8af51..e485d90f01 100644 --- a/crates/trusted-server-adapter-fastly/src/lib.rs +++ b/crates/trusted-server-adapter-fastly/src/lib.rs @@ -46,6 +46,7 @@ use trusted_server_device_fastly::{FastlyDeviceModule, FastlyHostSignals}; mod app; mod backend; mod compat; +mod config_selection; mod ec_kv; mod esi_assembly; mod logging; @@ -86,6 +87,65 @@ fn open_trusted_server_config_store(store_name: &str) -> Result Result { + let host = req.get_header_str(header::HOST.as_str()); + let resolved = config_selection::resolve_key(&stores.config_key, host, |key| { + config_selection::blob_exists(stores.config_store_name.as_ref(), key) + }); + match resolved { + Ok(config_key) => Ok(RuntimeStoreConfig { + config_key, + ..stores + }), + Err(refusal) => { + let named = host.unwrap_or_default(); + match &refusal { + config_selection::Refusal::NoConfigForHost => log::warn!( + "no app config for host {named:?} under key selector `{}`", + stores.config_key + ), + config_selection::Refusal::StoreUnavailable(reason) => { + log::error!("cannot tell whether host {named:?} has an app config: {reason}"); + } + } + Err(refusal) + } + } +} + +/// The answer to a request no app-config blob serves. +/// +/// A host with no blob is answered `421 Misdirected Request`, because this +/// service is not configured to answer for it. The answer is not to be +/// stored, so a host is served as soon as its blob is pushed. A store that +/// could not say whether the host has a blob is answered `500`. +fn refusal_response(refusal: &config_selection::Refusal) -> FastlyResponse { + match refusal { + config_selection::Refusal::NoConfigForHost => { + FastlyResponse::from_status(fastly::http::StatusCode::MISDIRECTED_REQUEST) + .with_header("cache-control", "private, no-store") + .with_body_text_plain("Misdirected Request") + } + config_selection::Refusal::StoreUnavailable(_) => { + FastlyResponse::from_status(fastly::http::StatusCode::INTERNAL_SERVER_ERROR) + .with_body_text_plain("Internal Server Error") + } + } +} + fn health_response(req: &FastlyRequest) -> Option { if req.get_method() == FastlyMethod::GET && req.get_path() == "/health" { return Some(FastlyResponse::from_status(200).with_body_text_plain("ok")); @@ -359,7 +419,16 @@ fn heap_mib() -> String { /// Handles a request through the `EdgeZero` router path. fn edgezero_main(mut req: FastlyRequest, sandbox: &mut Sandbox, ordinal: u64, request_id: &str) { let runtime_env = runtime_env_config(TrustedServerApp::stores()); - let runtime_stores = RuntimeStoreConfig::from_env(&runtime_env); + // Settled before anything reads settings, so every path below reads the + // blob of the publisher this request is for. + let runtime_stores = match stores_for_request(RuntimeStoreConfig::from_env(&runtime_env), &req) + { + Ok(stores) => stores, + Err(refusal) => { + refusal_response(&refusal).send_to_client(); + return; + } + }; // Short-circuit the sandbox counters probe before app construction. It must // not build the application: polling it would otherwise increment the very @@ -1203,6 +1272,97 @@ mod tests { ); } + /// The stores a service with no runtime overrides resolves, with + /// `selector` as its `__KEY` selector. + fn stores_with_selector(selector: &str) -> RuntimeStoreConfig { + RuntimeStoreConfig { + config_key: selector.to_owned(), + ..RuntimeStoreConfig::from_env(&edgezero_core::env_config::EnvConfig::default()) + } + } + + /// A request carrying `host` as its `Host` header, or none. + fn request_for_host(host: Option<&str>) -> FastlyRequest { + let mut req = FastlyRequest::get("https://example.com/article"); + req.remove_header(header::HOST.as_str()); + if let Some(host) = host { + req.set_header(header::HOST.as_str(), host); + } + req + } + + #[test] + fn a_service_with_one_blob_reads_its_key_whatever_the_host() { + for selector in ["trusted_server_config", "trusted_server_config_staging"] { + let stores = stores_with_selector(selector); + + for host in [None, Some("publisher.example"), Some("not a host")] { + let resolved = stores_for_request(stores.clone(), &request_for_host(host)) + .expect("should serve from the one blob"); + + assert_eq!( + resolved, stores, + "should read `{selector}` for host {host:?}" + ); + } + } + } + + #[test] + fn a_host_selector_refuses_what_is_not_a_host_name() { + for host in [ + None, + Some(""), + Some("trusted_server_config"), + Some("publisher.example.__edgezero_chunks.abc.0"), + Some("[::1]:7676"), + ] { + let refusal = + stores_for_request(stores_with_selector("{host}"), &request_for_host(host)) + .expect_err("should refuse a request that names no host name"); + + assert_eq!( + refusal, + config_selection::Refusal::NoConfigForHost, + "should refuse host {host:?} as one with no app config" + ); + } + } + + #[test] + fn a_host_with_no_app_config_is_answered_421_and_the_answer_is_not_stored() { + let mut response = refusal_response(&config_selection::Refusal::NoConfigForHost); + + assert_eq!( + response.get_status(), + fastly::http::StatusCode::MISDIRECTED_REQUEST, + "should say this service does not answer for the host" + ); + assert_eq!( + response.get_header_str("cache-control"), + Some("private, no-store"), + "a refusal should not be stored, so the host is served once it has a blob" + ); + assert_eq!( + response.take_body_str(), + "Misdirected Request", + "should say only that the request was misdirected" + ); + } + + #[test] + fn an_unreadable_store_is_answered_500() { + let response = refusal_response(&config_selection::Refusal::StoreUnavailable( + "lookup failed".to_owned(), + )); + + assert_eq!( + response.get_status(), + fastly::http::StatusCode::INTERNAL_SERVER_ERROR, + "should not answer 421 while the host's blob is unknown" + ); + } + #[test] fn take_finalize_sentinel_strips_sentinel() { let mut response = HttpResponse::new(EdgeBody::empty()); From 0e4f153267fe7f2e5cfa2b5c765fe02e86a7ea1c Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 9 Oct 2026 09:28:00 +0100 Subject: [PATCH 03/18] A harness serves two publishers from one local Viceroy instance `scripts/config-by-host-local-test.sh` pushes an app-config blob under each of two hosts and one at the logical store ID, each with a stub origin of its own, and runs the adapter under Viceroy. Without the placeholder every host reads the one blob. With it each host is answered from its own origin, in any letter case and with a port or a trailing dot, and a host with no blob, the logical store ID sent as a host and a longer name that ends in a publisher's host are each answered 421 with no publisher's page. Each response carries the sandbox's counters. In the default mode the fourth response is the first request of its sandbox, which has built one application. In `reuse` mode, built with the reusable sandbox, it is the fourth request of its sandbox, which has built two. Two publishers then shared a sandbox and never an application, which is the fault a unit test cannot show because it sits in the entry point. CI runs both modes in the Fastly job. --- .github/workflows/test.yml | 6 + scripts/README.md | 1 + scripts/config-by-host-local-test.sh | 348 +++++++++++++++++++++++++++ 3 files changed, 355 insertions(+) create mode 100755 scripts/config-by-host-local-test.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5a86ba0ae3..f4237f7937 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -71,6 +71,12 @@ jobs: - name: Run cache purge harness run: BID_DELAY=3 ./scripts/template-cache-local-test.sh purge + - name: Run config by host harness + run: ./scripts/config-by-host-local-test.sh + + - name: Run config by host harness (reusable-sandbox feature) + run: ./scripts/config-by-host-local-test.sh reuse + test-axum: name: cargo test (axum native, ${{ matrix.os }}) runs-on: ${{ matrix.os }} diff --git a/scripts/README.md b/scripts/README.md index ad4ecf9262..9b5c72ec86 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -16,6 +16,7 @@ their documented orchestration handles a probe deliberately. | `smoke-cloudflare.sh` | Pinned Wrangler, `cargo`, `curl`, `jq`, and Python | Uses isolated Wrangler manifests, KV state, ports, and logs; stops owned processes and removes its workspace. | | `smoke-spin.sh` | Spin CLI, `cargo`, `curl`, and Python | Uses an isolated Spin manifest and SQLite KV store; stops owned processes and removes its workspace. | | `smoke-common.sh` | Sourced by the four smoke scripts | Defines bounded port, process, config, secret, and response assertions. Do not invoke it as a standalone smoke. | +| `config-by-host-local-test.sh` | Viceroy, `curl`, Python; optional mode, ports and artifacts | Builds local artifacts, runs three stub origins and Viceroy from a temporary directory, stops owned servers, and removes the directory. | | `template-cache-local-test.sh` | Viceroy, Node, OpenSSL, `curl`, `lsof`; optional ports and mode | Builds local artifacts, generates certificates and fixtures in a temporary directory, stops owned servers, and removes the directory. | | `test-cli.sh` | Rustup and the host toolchain; optional host triple | May install the selected Rust target, then runs native CLI and browser-audit tests. Cargo artifacts persist. | diff --git a/scripts/config-by-host-local-test.sh b/scripts/config-by-host-local-test.sh new file mode 100755 index 0000000000..dcdbe870ac --- /dev/null +++ b/scripts/config-by-host-local-test.sh @@ -0,0 +1,348 @@ +#!/usr/bin/env bash +# +# Local harness for a service that reads one app-config blob for each host. +# +# Runs Trusted Server under Viceroy against three stub origins, one for each +# of two publishers and one behind the blob at the logical store ID, and +# asserts that every host is answered from its own blob and from no other. +# Everything it needs is generated into a temp directory; your +# `trusted-server.toml` is never read and your tracked `fastly.toml` is never +# modified. +# +# Usage: +# ./scripts/config-by-host-local-test.sh # a fresh sandbox for each request +# ./scripts/config-by-host-local-test.sh reuse # one sandbox serves both publishers +# +# `reuse` builds with the reusable sandbox and sets its four bounds. Viceroy +# hands a waiting sandbox the next request, so the first requests of a run are +# served by one sandbox. +# +# Environment: +# WASM_BINARY_PATH a built adapter, in place of building one +# TS_BIN a built `ts`, in place of building one +# ORIGIN_PORT the first of three consecutive stub-origin ports +# TS_PORT the port Viceroy listens on + +set -euo pipefail + +MODE="${1:-single}" +case "$MODE" in + single | reuse) ;; + *) + echo "Unknown mode '$MODE'. Use one of: single, reuse." >&2 + exit 1 + ;; +esac + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK="$(mktemp -d)" +ORIGIN_PORT="${ORIGIN_PORT:-9199}" +TS_PORT="${TS_PORT:-7799}" +PORT_A="$ORIGIN_PORT" +PORT_B="$((ORIGIN_PORT + 1))" +PORT_SHARED="$((ORIGIN_PORT + 2))" +HOST_A="a.publisher.example" +HOST_B="b.publisher.example" +HOST_TRIPLE="$(rustc -vV | sed -n 's/^host: //p')" +# Viceroy reports this service id, which scopes every runtime-env key. +SERVICE_ID="0000000000000000000000" +KEY_SELECTOR="EDGEZERO__SERVICES__${SERVICE_ID}__STORES__CONFIG__TRUSTED_SERVER_CONFIG__KEY" +REQUEST_TIMEOUT_SECONDS="${REQUEST_TIMEOUT_SECONDS:-30}" + +PASS=0 +FAIL=0 +VICEROY_PID="" +ORIGIN_PID="" + +info() { printf '\n\033[1m==> %s\033[0m\n' "$*"; } +ok() { printf ' \033[32mPASS\033[0m %s\n' "$*"; PASS=$((PASS + 1)); } +bad() { printf ' \033[31mFAIL\033[0m %s\n' "$*"; FAIL=$((FAIL + 1)); } + +check() { # check + if [ "$2" = "$3" ]; then ok "$1 ($2)"; else bad "$1: got '$2', want '$3'"; fi +} + +stop_viceroy() { + if [ -n "$VICEROY_PID" ]; then + kill "$VICEROY_PID" 2>/dev/null || true + wait "$VICEROY_PID" 2>/dev/null || true + VICEROY_PID="" + fi +} + +cleanup() { + local status=$? + stop_viceroy + if [ -n "$ORIGIN_PID" ]; then + # macOS may launch the framework Python process as a child of the shim. + pkill -TERM -P "$ORIGIN_PID" 2>/dev/null || true + kill "$ORIGIN_PID" 2>/dev/null || true + wait "$ORIGIN_PID" 2>/dev/null || true + fi + rm -rf "$WORK" + exit $status +} +trap cleanup EXIT INT TERM + +for tool in viceroy curl; do + command -v "$tool" >/dev/null || { + echo "$tool not found. Viceroy installs with: cargo install viceroy --version 0.17.0 --locked" >&2 + exit 1 + } +done +PYTHON="$(command -v python3 || command -v python || true)" +[ -n "$PYTHON" ] || { + echo "python3 not found. The harness runs its stub origins with it." >&2 + exit 1 +} + +# A port already in use means requests would go to something else entirely, +# most likely a leftover run whose config would read as a result. +for port in "$PORT_A" "$PORT_B" "$PORT_SHARED" "$TS_PORT"; do + if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then + echo "Port $port is already in use. Stop the process, or set" >&2 + echo "ORIGIN_PORT / TS_PORT to something free." >&2 + exit 1 + fi +done + +info "Building (debug wasm + ts CLI, mode: $MODE)" +WASM="${WASM_BINARY_PATH:-}" +if [ -z "$WASM" ]; then + if [ "$MODE" = "reuse" ]; then + cargo build --package trusted-server-adapter-fastly --target wasm32-wasip1 \ + --features reusable-sandbox >/dev/null + else + cargo build --package trusted-server-adapter-fastly --target wasm32-wasip1 >/dev/null + fi + # Copied aside, because a later build with other features writes the same path. + cp "$REPO_ROOT/target/wasm32-wasip1/debug/trusted-server-adapter-fastly.wasm" "$WORK/app.wasm" + WASM="$WORK/app.wasm" +fi +TS="${TS_BIN:-}" +if [ -z "$TS" ]; then + cargo build -p trusted-server-cli --target "$HOST_TRIPLE" >/dev/null + TS="$REPO_ROOT/target/$HOST_TRIPLE/debug/ts" +fi + +info "Starting stub origins on :$PORT_A, :$PORT_B and :$PORT_SHARED" +cat > "$WORK/origins.py" <<'PYEOF' +"""Three stub publisher origins, each answering with a marker of its own. + +A page is private and not to be stored, so no cache stands between a request +and the blob that serves it, and a reused sandbox reports its counters on it. +""" +import sys +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +def handler(name): + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_GET(self): + body = ( + "\n%s\n" + "

ORIGIN_%s_SENTINEL

\n" % (name, name) + ).encode() + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Cache-Control", "private, no-store") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, fmt, *args): + print("origin %s: " % name + fmt % args, flush=True) + + return Handler + + +servers = [ + ThreadingHTTPServer(("127.0.0.1", int(port)), handler(name)) + for name, port in (pair.split("=") for pair in sys.argv[1:]) +] +for server in servers[1:]: + threading.Thread(target=server.serve_forever, daemon=True).start() +servers[0].serve_forever() +PYEOF +"$PYTHON" "$WORK/origins.py" "A=$PORT_A" "B=$PORT_B" "SHARED=$PORT_SHARED" \ + > "$WORK/origins.log" 2>&1 & +ORIGIN_PID=$! +for port in "$PORT_A" "$PORT_B" "$PORT_SHARED"; do + for _ in $(seq 1 40); do + curl -s -o /dev/null "http://127.0.0.1:$port/" && break + sleep 0.25 + done +done + +info "Seeding an isolated config store (tracked fastly.toml remains untouched)" +# `config push --local` edits the adapter manifest, so it is given a complete +# temporary project. The crates link keeps manifest path validation pointed at +# this checkout without copying the workspace. +cp "$REPO_ROOT/edgezero.toml" "$WORK/edgezero.toml" +cp "$REPO_ROOT/fastly.toml" "$WORK/fastly.toml" +ln -s "$REPO_ROOT/crates" "$WORK/crates" +cat >> "$WORK/fastly.toml" <<'SECRETSEOF' + +[[local_server.secret_stores.ts_secrets]] +key = "publisher_proxy_secret" +data = "fictional-local-publisher-proxy-secret-value" + +[[local_server.secret_stores.ts_secrets]] +key = "ec_passphrase" +data = "fictional-local-ec-passphrase-secret-value" + +[[local_server.secret_stores.ts_secrets]] +key = "handler_password" +data = "fictional-local-handler-password-secret-value" +SECRETSEOF + +"$TS" config init --app-config "$WORK/app.toml" >/dev/null +# Every response then says which sandbox served it and how many applications +# that sandbox had built. +printf '\n[debug]\nsandbox_metrics_enabled = true\n' >> "$WORK/app.toml" + +push_blob() { # push_blob [--key ] + local domain="$1" port="$2" + shift 2 + ( + cd "$WORK" + TRUSTED_SERVER__PUBLISHER__DOMAIN="$domain" \ + TRUSTED_SERVER__PUBLISHER__COOKIE_DOMAIN=".$domain" \ + TRUSTED_SERVER__PUBLISHER__ORIGIN_URL="http://127.0.0.1:$port" \ + "$TS" config push --adapter fastly --local \ + --manifest "$WORK/edgezero.toml" --app-config "$WORK/app.toml" \ + --no-diff --yes "$@" >/dev/null + ) +} +# One blob at the logical store ID and one under each publisher's host. +push_blob "shared.example" "$PORT_SHARED" +push_blob "$HOST_A" "$PORT_A" --key "$HOST_A" +push_blob "$HOST_B" "$PORT_B" --key "$HOST_B" + +set_runtime_env() { # set_runtime_env ... + "$PYTHON" - "$WORK/fastly.toml" "$@" <<'PYEOF' +import sys + +manifest, *entries = sys.argv[1:] +header = "[local_server.config_stores.edgezero_runtime_env.contents]" +lines = open(manifest).read().split("\n") +at = [index for index, line in enumerate(lines) if line.strip() == header] +if len(at) != 1: + raise SystemExit("expected one runtime-env table in the local manifest") +for entry in reversed(entries): + key, value = entry.split("=", 1) + lines.insert(at[0] + 1, '%s = "%s"' % (key, value)) +open(manifest, "w").write("\n".join(lines)) +PYEOF +} + +start_viceroy() { # start_viceroy + VICEROY_LOG="$WORK/$1.log" + # Deliberately not wrapped in a subshell: `$!` would then be the subshell's + # pid, so cleanup would kill the wrapper and orphan Viceroy. + RUST_LOG=info viceroy serve -C "$WORK/fastly.toml" \ + --addr "127.0.0.1:$TS_PORT" "$WASM" > "$VICEROY_LOG" 2>&1 & + VICEROY_PID=$! + for _ in $(seq 1 40); do + grep -q "Listening on" "$VICEROY_LOG" 2>/dev/null && break + sleep 0.5 + done + if ! grep -q "Listening on" "$VICEROY_LOG" 2>/dev/null; then + echo "Trusted Server failed to start. Last log lines:" >&2 + tail -20 "$VICEROY_LOG" >&2 + exit 1 + fi +} + +REQUESTS=0 +fetch() { # fetch [path], sets STATUS, BODY and HEADERS + REQUESTS=$((REQUESTS + 1)) + BODY="$WORK/response-$REQUESTS.body" + HEADERS="$WORK/response-$REQUESTS.headers" + STATUS="$(curl -sS --max-time "$REQUEST_TIMEOUT_SECONDS" -D "$HEADERS" -o "$BODY" \ + -w '%{http_code}' -H "Host: $1" "http://127.0.0.1:$TS_PORT${2:-/}" || true)" +} + +served_by() { # served_by + local found="" + fetch "$2" + for name in A B SHARED; do + if grep -qF "ORIGIN_${name}_SENTINEL" "$BODY"; then found="$found$name"; fi + done + check "$1" "$STATUS $found" "200 $3" +} + +counter() { # counter , from the last response + tr -d '\r' < "$HEADERS" | + awk -v name="x-ts-sandbox-$1:" 'tolower($1) == name { print $2 }' +} + +refused() { # refused + fetch "$2" + check "$1" "$STATUS" "421" + if grep -qE "ORIGIN_(A|B|SHARED)_SENTINEL" "$BODY"; then + bad "$1: the refusal carries a publisher's page" + else + ok "$1: no publisher's page is served" + fi +} + +info "Without a host selector the service reads its one blob" +start_viceroy one-blob +served_by "a publisher's host reads the blob at the logical store ID" "$HOST_A" SHARED +served_by "any other host reads it too" "unknown.example" SHARED +stop_viceroy + +info "With '{host}' as the key selector each host reads its own blob" +ENTRIES=("$KEY_SELECTOR={host}") +if [ "$MODE" = "reuse" ]; then + SANDBOX="EDGEZERO__SERVICES__${SERVICE_ID}__TS__SANDBOX" + ENTRIES+=( + "${SANDBOX}__MAX_REQUESTS=50" + "${SANDBOX}__MAX_LIFETIME_MS=120000" + "${SANDBOX}__TIMEOUT_MS=10000" + "${SANDBOX}__MAX_MEMORY_MIB=512" + ) +fi +set_runtime_env "${ENTRIES[@]}" +start_viceroy by-host + +served_by "publisher A's host reads publisher A's blob" "$HOST_A" A +served_by "publisher B's host reads publisher B's blob" "$HOST_B" B +served_by "publisher A again, after B" "$HOST_A" A +served_by "publisher B again, after A" "$HOST_B" B +# The fourth response says how it was served. A sandbox on its fourth request +# that has built exactly two applications shows the two publishers shared a +# sandbox and never an application. Viceroy reports one instance id for every +# sandbox, so the request's place in its sandbox is what tells them apart. +if [ "$MODE" = "reuse" ]; then + check "one sandbox served all four requests and built an application for each publisher" \ + "$(counter ordinal) $(counter builds)" "4 2" +else + check "a fresh sandbox served the request and built its one application" \ + "$(counter ordinal) $(counter builds)" "1 1" +fi +served_by "a host in capitals with a port reads the same blob" "A.Publisher.Example:8443" A +served_by "a host with a trailing dot reads the same blob" "$HOST_B." B + +refused "a host with no blob is refused" "unknown.example" +refused "the logical store ID is not a host" "trusted_server_config" +refused "another publisher's key with a suffix is not that publisher" "$HOST_A.evil.example" +fetch "unknown.example" /health +check "the health probe answers for any host" "$STATUS" "200" +if grep -qF "no app config for host" "$VICEROY_LOG"; then + ok "a refusal is logged with the host it refused" +else + bad "a refusal is not logged" +fi +stop_viceroy + +printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" +if [ "$FAIL" -ne 0 ]; then + echo "Viceroy log of the last run:" >&2 + tail -40 "$VICEROY_LOG" >&2 + exit 1 +fi From 64750a4dff4eab20eb23ab0e293f21fda7344f43 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 9 Oct 2026 09:28:20 +0100 Subject: [PATCH 04/18] The guides say how one service serves several publishers The configuration guide's section on the Fastly runtime config store gains "One blob for each host", with the push for each host, the selector entry, a table of what each request reads and is answered, and what the publishers on one service share. The Fastly guide points to it, and the changelog records the addition. --- CHANGELOG.md | 1 + docs/guide/configuration.md | 57 +++++++++++++++++++++++++++++++++++++ docs/guide/fastly.md | 4 +++ 3 files changed, 62 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0277463cd4..748ce13e66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- One Fastly service can serve several publishers. A `__KEY` selector that contains `{host}` names an app-config blob for each host, pushed with `ts config push --key `, and each request reads the blob under its own host. A host with no blob is answered `421 Misdirected Request`, never from the logical store ID or from another host's blob, and a sandbox that serves several requests keeps an application for each blob, found only by that blob's key. A selector without the placeholder reads one blob as before. `scripts/config-by-host-local-test.sh` runs it under Viceroy. - `ts audit generate` writes the `[[fetch]]` and `[[serve]]` entries that run the page changes of the modules it selects on every HTML page, and `trusted-server.example.toml` documents the entries with every middleware the stock modules supply. `trusted-server-modules` lists those middleware with `middleware()` and `middleware_for(...)`. - Page changes as middleware. A module registers a change it makes to a page with `.with_middleware(...)`, and the ordered `[[fetch]]` and `[[serve]]` entries of the settings say which pages each one runs on and in what order. A fetch middleware runs on the page as the origin sent it and what it writes is stored with a shared template. A serve middleware runs on each reader's copy, whether the page came from the store or from the origin, and nothing it writes is stored. - A builder registers its module from the compiled auction plan with `with_plan_registration` and checks its settings against the plan with `with_plan_validator`, so a module whose page support follows what the plan selects is not named in `trusted-server-core`. Prebid and APS register this way. A renderer descriptor carries its builder's statement of which payload key holds the identifier the renderer picks its bid by, made with `BidRenderer::picking_bid_by`, and the page's `hb_adid` is read through that statement. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 594d5b6074..e60d368a99 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -3264,6 +3264,63 @@ service-scoped `__KEY` entry in `edgezero_runtime_env`. Changing that entry affe the active service immediately. Do not point the production selector at a staging key; staged deployments need their own runtime-env store. +### One blob for each host + +One Fastly service can serve several publishers, each from an application +config of its own. Write `{host}` in the `__KEY` selector and the runtime +replaces it with the host of each request before it reads any settings, so +every host reads the blob stored under its own name. + +Push a blob for each host the service answers for, then set the selector. + +```bash +ts config push --adapter fastly --key www.publisher-a.example --app-config publisher-a.toml +ts config push --adapter fastly --key www.publisher-b.example --app-config publisher-b.toml + +fastly config-store-entry update --upsert \ + --store-id \ + --key "EDGEZERO__SERVICES__${FASTLY_SERVICE_ID}__STORES__CONFIG__TRUSTED_SERVER_CONFIG__KEY" \ + --value '{host}' +``` + +The selector takes effect on the active service as soon as it is written, as +any entry of its `edgezero_runtime_env` store does, so the blobs come first. + +| A request for | Reads the blob at | Is answered | +| ----------------------------------------------------------- | ------------------------- | -------------------------------- | +| `www.publisher-a.example` | `www.publisher-a.example` | From publisher A's config | +| `WWW.Publisher-A.example:443` or `www.publisher-a.example.` | `www.publisher-a.example` | From publisher A's config | +| `www.publisher-b.example` | `www.publisher-b.example` | From publisher B's config | +| A host with no blob | None | `421 Misdirected Request` | +| A `Host` that is not a host name, or no `Host` | None | `421 Misdirected Request` | +| `/health`, for any host | None | `200`, before any config is read | + +A host with no blob is never answered from the logical store ID or from +another host's blob, because a shared fallback is how a host nobody configured +comes to be served another publisher's site. A host name is letters, digits +and hyphens in labels joined by single dots. Nothing else is looked up, so a +request cannot name the logical store ID or a part of a stored blob, each of +which carries an underscore. + +- A publisher with several names has a blob under each. `www.publisher.example` + and `publisher.example` are two keys, and what each serves is for the + publisher to decide. +- The selector may carry text around the placeholder, such as `{host}_staging`. + Each key is then pushed in full, with `--key www.publisher-a.example_staging`. +- Every blob resolves its secret references in the one secret store the service + maps, so each publisher's blob names keys of its own. +- Request signing keys belong to the service, so every publisher on it signs + with the same keys. +- `ts deploy --staging` points a staged version's selector at + `_staging`, which carries no placeholder. A staged version + therefore reads that one blob for every host and never a production blob. +- Only the Fastly adapter reads the placeholder. The Axum, Cloudflare and Spin + adapters read one application config. + +`./scripts/config-by-host-local-test.sh` serves two publishers from one local +Viceroy instance. It checks that each host is answered from its own blob and +that a host with none is refused. + ### Local development The repository's Viceroy configuration uses the default logical app-config name diff --git a/docs/guide/fastly.md b/docs/guide/fastly.md index aa1f402d4b..a4675c126a 100644 --- a/docs/guide/fastly.md +++ b/docs/guide/fastly.md @@ -309,6 +309,10 @@ for the initial provisioning and linking sequence, subsequent CLI pushes, and precautions when changing a live mapping. A process-environment override alone does not configure the Fastly runtime. +One service can serve several publishers, each from an application config of +its own that the request's host chooses. See +[One blob for each host](/guide/configuration#one-blob-for-each-host). + Create the separate request-signing store when that feature is enabled: ```bash From 0166d0200e5e0fee695f92b5e3b24837ea9764e9 Mon Sep 17 00:00:00 2001 From: James Rogers Date: Mon, 12 Oct 2026 14:53:42 +0100 Subject: [PATCH 05/18] A host name with a label that starts or ends in a hyphen is refused The host selector refuses a label that starts or ends with a hyphen, with a test. The guide says each key is pushed in lower case and that an IPv6 literal host is refused. The thing a host selects is called an app-config blob throughout, including the refusal log line the harness greps. Comments that restated a test, the guide or another comment are cut. --- .../src/config_selection.rs | 37 ++++++++++++++----- .../trusted-server-adapter-fastly/src/lib.rs | 24 +++++------- .../src/sandbox.rs | 13 +------ docs/guide/configuration.md | 22 +++++++---- docs/guide/fastly.md | 4 +- scripts/config-by-host-local-test.sh | 6 +-- 6 files changed, 57 insertions(+), 49 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/config_selection.rs b/crates/trusted-server-adapter-fastly/src/config_selection.rs index 9d9011b8d3..b3dfa2050e 100644 --- a/crates/trusted-server-adapter-fastly/src/config_selection.rs +++ b/crates/trusted-server-adapter-fastly/src/config_selection.rs @@ -6,9 +6,7 @@ //! names, so one service serves several publishers, each from a blob of its //! own. //! -//! A host that has no blob is refused. It is never answered from another key, -//! because a shared fallback is how a host nobody configured comes to be -//! served another publisher's site. +//! A host that has no blob is refused. It is never answered from another key. use core::fmt::Display; @@ -30,12 +28,9 @@ pub(crate) enum Refusal { /// The host as a blob is keyed by it, or `None` when `host` is not a host /// name. /// -/// Lowercased, with a port and one trailing dot removed, because -/// `Publisher.Example:443` and `publisher.example.` name one site. Only -/// letters, digits and hyphens pass, in labels joined by single dots. A -/// request header therefore cannot be made into the key of anything but a -/// host's blob: the logical store ID and the keys of a blob's chunks each -/// carry an underscore, which no host does. +/// Lowercased, with a port and one trailing dot removed. Only letters, digits +/// and hyphens pass, in labels joined by single dots, and no label starts or +/// ends with a hyphen. pub(crate) fn normalized_host(host: &str) -> Option { let without_port = match host.rsplit_once(':') { Some((name, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => name, @@ -46,6 +41,8 @@ pub(crate) fn normalized_host(host: &str) -> Option { && name.len() <= MAX_HOST_LEN && name.split('.').all(|label| { !label.is_empty() + && !label.starts_with('-') + && !label.ends_with('-') && label .bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'-') @@ -239,6 +236,28 @@ mod tests { ); } + #[test] + fn a_label_that_starts_or_ends_with_a_hyphen_is_refused() { + for host in [ + "-a.example", + "a-.example", + "publisher.-example", + "publisher.example-", + "-", + ] { + assert_eq!( + resolve_key("{host}", Some(host), no_lookup), + Err(Refusal::NoConfigForHost), + "`{host}` is not a host name and should be refused" + ); + } + assert_eq!( + normalized_host("pub-lisher.example").as_deref(), + Some("pub-lisher.example"), + "a hyphen inside a label should pass" + ); + } + #[test] fn the_longest_host_name_is_keyed() { let label = "a".repeat(63); diff --git a/crates/trusted-server-adapter-fastly/src/lib.rs b/crates/trusted-server-adapter-fastly/src/lib.rs index e485d90f01..7bad59cddc 100644 --- a/crates/trusted-server-adapter-fastly/src/lib.rs +++ b/crates/trusted-server-adapter-fastly/src/lib.rs @@ -114,11 +114,13 @@ fn stores_for_request( let named = host.unwrap_or_default(); match &refusal { config_selection::Refusal::NoConfigForHost => log::warn!( - "no app config for host {named:?} under key selector `{}`", + "no app-config blob for host {named:?} under key selector `{}`", stores.config_key ), config_selection::Refusal::StoreUnavailable(reason) => { - log::error!("cannot tell whether host {named:?} has an app config: {reason}"); + log::error!( + "cannot tell whether host {named:?} has an app-config blob: {reason}" + ); } } Err(refusal) @@ -128,10 +130,9 @@ fn stores_for_request( /// The answer to a request no app-config blob serves. /// -/// A host with no blob is answered `421 Misdirected Request`, because this -/// service is not configured to answer for it. The answer is not to be -/// stored, so a host is served as soon as its blob is pushed. A store that -/// could not say whether the host has a blob is answered `500`. +/// A host with no blob is answered `421 Misdirected Request`. The answer is +/// not to be stored, so a host is served as soon as its blob is pushed. A +/// store that could not say whether the host has a blob is answered `500`. fn refusal_response(refusal: &config_selection::Refusal) -> FastlyResponse { match refusal { config_selection::Refusal::NoConfigForHost => { @@ -488,13 +489,6 @@ fn edgezero_main(mut req: FastlyRequest, sandbox: &mut Sandbox, ordinal: u64, re // Build lazily, once per sandbox for each app-config key it is asked for. // Reached only past the health, JA4, and counters short-circuits, so none // of those pays for construction. - // - // `get_or_build` builds only when the sandbox holds no application for - // this key, retains only success, and returns the error unchanged. A - // failed build hands back its error router as the error payload: that - // serves this request and is then dropped, so a transient config-store - // failure cannot pin the sandbox into permanent error mode, and the next - // callback retries construction. crate::sandbox::hold_applications(sandbox); let Some(apps) = sandbox.state() else { log::error!("no application holder available after initialization"); @@ -1324,13 +1318,13 @@ mod tests { assert_eq!( refusal, config_selection::Refusal::NoConfigForHost, - "should refuse host {host:?} as one with no app config" + "should refuse host {host:?} as one with no app-config blob" ); } } #[test] - fn a_host_with_no_app_config_is_answered_421_and_the_answer_is_not_stored() { + fn a_host_with_no_app_config_blob_is_answered_421_and_the_answer_is_not_stored() { let mut response = refusal_response(&config_selection::Refusal::NoConfigForHost); assert_eq!( diff --git a/crates/trusted-server-adapter-fastly/src/sandbox.rs b/crates/trusted-server-adapter-fastly/src/sandbox.rs index 9233d97b89..fc6963e9fb 100644 --- a/crates/trusted-server-adapter-fastly/src/sandbox.rs +++ b/crates/trusted-server-adapter-fastly/src/sandbox.rs @@ -59,9 +59,7 @@ pub(crate) const INSTANCE_ID_UNAVAILABLE: &str = "unavailable"; /// Per-sandbox state, owned by `edgezero_adapter_fastly::lifecycle::Sandbox`. /// /// The framework owns the callback count and the one-time setup guard, and -/// holds the payload this alias names. The payload owns retention, because -/// the framework's slot holds one value and a sandbox can be asked for more -/// than one application. +/// holds the payload this alias names. The payload owns retention. pub(crate) type Sandbox = edgezero_adapter_fastly::lifecycle::Sandbox; /// How many applications a sandbox keeps. @@ -84,15 +82,8 @@ pub(crate) struct RetainedApp { /// The applications a sandbox has built, each under the key of the app-config /// blob it was built from. /// -/// A service that serves one publisher builds one. A service whose `__KEY` -/// selector names a blob for each host is asked for several, and an -/// application built from one publisher's blob must never answer a request -/// for another's, so an application is found by its key and by nothing else. -/// /// The framework hands its payload out by shared reference, so the list sits -/// behind a [`RefCell`]. One callback runs to completion before the next -/// begins and no borrow is held across a build, so the cell is never borrowed -/// twice. +/// behind a [`RefCell`]. #[derive(Default)] pub(crate) struct RetainedApps { /// Most recently served first. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index e60d368a99..a4a2fc10dc 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -3266,12 +3266,16 @@ staging key; staged deployments need their own runtime-env store. ### One blob for each host -One Fastly service can serve several publishers, each from an application -config of its own. Write `{host}` in the `__KEY` selector and the runtime -replaces it with the host of each request before it reads any settings, so -every host reads the blob stored under its own name. +One Fastly service can serve several publishers, each from an app-config blob +of its own. Write `{host}` in the `__KEY` selector and the runtime replaces it +with the host of each request before it reads any settings, so every host +reads the blob stored under its own name. Push a blob for each host the service answers for, then set the selector. +Push each key in lower case. The runtime lowercases the request's host and +`--key` stores the key as typed, so a blob pushed under +`WWW.Publisher-A.example` is never read and every request for that host is +answered `421`. ```bash ts config push --adapter fastly --key www.publisher-a.example --app-config publisher-a.toml @@ -3298,9 +3302,11 @@ any entry of its `edgezero_runtime_env` store does, so the blobs come first. A host with no blob is never answered from the logical store ID or from another host's blob, because a shared fallback is how a host nobody configured comes to be served another publisher's site. A host name is letters, digits -and hyphens in labels joined by single dots. Nothing else is looked up, so a -request cannot name the logical store ID or a part of a stored blob, each of -which carries an underscore. +and hyphens in labels joined by single dots, and no label starts or ends with +a hyphen. An IPv4 address such as `127.0.0.1` passes, and an IPv6 literal such +as `[::1]` is refused. Nothing else is looked up, so a request cannot name the +logical store ID or a part of a stored blob, each of which carries an +underscore. - A publisher with several names has a blob under each. `www.publisher.example` and `publisher.example` are two keys, and what each serves is for the @@ -3315,7 +3321,7 @@ which carries an underscore. `_staging`, which carries no placeholder. A staged version therefore reads that one blob for every host and never a production blob. - Only the Fastly adapter reads the placeholder. The Axum, Cloudflare and Spin - adapters read one application config. + adapters read one app-config blob. `./scripts/config-by-host-local-test.sh` serves two publishers from one local Viceroy instance. It checks that each host is answered from its own blob and diff --git a/docs/guide/fastly.md b/docs/guide/fastly.md index a4675c126a..e896a8d4d8 100644 --- a/docs/guide/fastly.md +++ b/docs/guide/fastly.md @@ -309,8 +309,8 @@ for the initial provisioning and linking sequence, subsequent CLI pushes, and precautions when changing a live mapping. A process-environment override alone does not configure the Fastly runtime. -One service can serve several publishers, each from an application config of -its own that the request's host chooses. See +One service can serve several publishers, each from an app-config blob of its +own that the request's host chooses. See [One blob for each host](/guide/configuration#one-blob-for-each-host). Create the separate request-signing store when that feature is enabled: diff --git a/scripts/config-by-host-local-test.sh b/scripts/config-by-host-local-test.sh index dcdbe870ac..a19c2be896 100755 --- a/scripts/config-by-host-local-test.sh +++ b/scripts/config-by-host-local-test.sh @@ -13,9 +13,7 @@ # ./scripts/config-by-host-local-test.sh # a fresh sandbox for each request # ./scripts/config-by-host-local-test.sh reuse # one sandbox serves both publishers # -# `reuse` builds with the reusable sandbox and sets its four bounds. Viceroy -# hands a waiting sandbox the next request, so the first requests of a run are -# served by one sandbox. +# `reuse` builds with the reusable sandbox and sets its four bounds. # # Environment: # WASM_BINARY_PATH a built adapter, in place of building one @@ -333,7 +331,7 @@ refused "the logical store ID is not a host" "trusted_server_config" refused "another publisher's key with a suffix is not that publisher" "$HOST_A.evil.example" fetch "unknown.example" /health check "the health probe answers for any host" "$STATUS" "200" -if grep -qF "no app config for host" "$VICEROY_LOG"; then +if grep -qF "no app-config blob for host" "$VICEROY_LOG"; then ok "a refusal is logged with the host it refused" else bad "a refusal is not logged" From 5c6729dfb213783fdba9b83ebdb6b94d0852b088 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 07:11:15 +0100 Subject: [PATCH 06/18] Read the Host header in the Fastly adapter without a panic on bytes that are not UTF-8 The selection and the module request read Host with get_header_str, which panics on a value that is not UTF-8. Both now read it with get_header and take it as text only when its bytes are valid UTF-8, so such a Host counts as no host. A selector that carries {host} then answers 421 with no lookup, and a module is shown no host. A selector without {host} no longer reads Host at all. --- .../trusted-server-adapter-fastly/src/lib.rs | 100 ++++++++++++++++-- 1 file changed, 93 insertions(+), 7 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/lib.rs b/crates/trusted-server-adapter-fastly/src/lib.rs index 83e70acb5b..5b5b5a539d 100644 --- a/crates/trusted-server-adapter-fastly/src/lib.rs +++ b/crates/trusted-server-adapter-fastly/src/lib.rs @@ -90,8 +90,8 @@ fn open_trusted_server_config_store(store_name: &str) -> Result Result { - let host = req.get_header_str(header::HOST.as_str()); + let host = if stores.config_key.contains(config_selection::HOST_PLACEHOLDER) { + request_host(req) + } else { + None + }; let resolved = config_selection::resolve_key(&stores.config_key, host, |key| { config_selection::blob_exists(stores.config_store_name.as_ref(), key) }); @@ -111,7 +115,9 @@ fn stores_for_request( ..stores }), Err(refusal) => { - let named = host.unwrap_or_default(); + let named = req + .get_header_str_lossy(header::HOST.as_str()) + .unwrap_or_default(); match &refusal { config_selection::Refusal::NoConfigForHost => log::warn!( "no app-config blob for host {named:?} under key selector `{}`", @@ -128,6 +134,16 @@ fn stores_for_request( } } +/// The request's `Host` header as text, or `None` when it is absent or its +/// bytes are not valid UTF-8. +/// +/// Read with `get_header`, because `get_header_str` panics on a value that is +/// not UTF-8 and a client chooses the bytes of `Host`. +fn request_host(req: &FastlyRequest) -> Option<&str> { + req.get_header(header::HOST.as_str()) + .and_then(|value| core::str::from_utf8(value.as_bytes()).ok()) +} + /// The answer to a request no app-config blob serves. /// /// A host with no blob is answered `421 Misdirected Request`. The answer is @@ -1082,7 +1098,8 @@ pub(crate) async fn derive_device_signals( /// it is converted. /// /// The scheme is `https` when the entry point marked the request as arriving -/// over TLS, which it does from Fastly's own TLS metadata. +/// over TLS, which it does from Fastly's own TLS metadata. A `Host` that is +/// not UTF-8 is shown as no host. fn entry_request(req: &FastlyRequest) -> ModuleRequest<'_> { let scheme = if req.get_header_str("fastly-ssl").is_some() { "https" @@ -1091,8 +1108,7 @@ fn entry_request(req: &FastlyRequest) -> ModuleRequest<'_> { }; ModuleRequest::new( req.get_method(), - req.get_header_str(header::HOST.as_str()) - .unwrap_or_default(), + request_host(req).unwrap_or_default(), scheme, req.get_path(), ) @@ -1379,6 +1395,76 @@ mod tests { ); } + /// A `Host` header whose bytes are not valid UTF-8. + const HOST_NOT_UTF8: &[u8] = b"\xffa.example"; + + /// A request whose `Host` header is [`HOST_NOT_UTF8`]. + fn request_with_host_not_utf8() -> FastlyRequest { + let mut req = request_for_host(None); + req.set_header(header::HOST.as_str(), HOST_NOT_UTF8); + req + } + + #[test] + fn a_host_that_is_not_utf8_is_refused_without_a_panic() { + let refusal = stores_for_request( + stores_with_selector("{host}"), + &request_with_host_not_utf8(), + ) + .expect_err("should refuse a Host that is not UTF-8"); + + assert_eq!( + refusal, + config_selection::Refusal::NoConfigForHost, + "should take a Host that is not UTF-8 as no host" + ); + assert_eq!( + refusal_response(&refusal).get_status(), + fastly::http::StatusCode::MISDIRECTED_REQUEST, + "should answer 421" + ); + } + + #[test] + fn a_fixed_selector_does_not_read_the_host() { + let resolved = stores_for_request( + stores_with_selector("site_a"), + &request_with_host_not_utf8(), + ) + .expect("should serve a fixed selector whatever the Host"); + + assert_eq!( + resolved.config_key, "site_a", + "a service that reads one blob should read it without reading Host" + ); + } + + #[test] + fn a_module_is_shown_no_host_where_host_is_not_utf8() { + let settings = test_settings(); + let services = build_finalize_services(&settings, Arc::new(UnavailableKvStore)); + let registered: Arc = Arc::new(MarkedDeviceModule); + let mut req = page_request(); + req.set_header(header::HOST.as_str(), HOST_NOT_UTF8); + // A selector without the placeholder serves the request without + // reading Host, so the request goes on to the module request. + stores_for_request(stores_with_selector("site_a"), &req) + .expect("should serve a fixed selector whatever the Host"); + + let signals = futures::executor::block_on(derive_device_signals( + &settings, + Some(registered), + &req, + &services, + )); + + assert_eq!( + signals.platform_class.as_deref(), + Some("marked:example_width=1280:https:///article?page=2"), + "a module should be shown no host where the Host header is not UTF-8" + ); + } + #[test] fn take_finalize_sentinel_strips_sentinel() { let mut response = HttpResponse::new(EdgeBody::empty()); From 6d3c69a30bda0445e85f28987e6dc3b0571fe3d5 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 07:13:18 +0100 Subject: [PATCH 07/18] Answer a host whose config key would pass 255 characters 421 without asking the store A Fastly config store key holds at most 255 characters, counted as EdgeZero counts them. A host may be 253 characters, so a selector such as {host}_v2 could build a key the store cannot hold, and the lookup then failed with KeyTooLong and answered 500. Such a key is now never looked up and counts as no blob, so the request is answered 421. --- .../src/config_selection.rs | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/config_selection.rs b/crates/trusted-server-adapter-fastly/src/config_selection.rs index b3dfa2050e..7f84ae68f2 100644 --- a/crates/trusted-server-adapter-fastly/src/config_selection.rs +++ b/crates/trusted-server-adapter-fastly/src/config_selection.rs @@ -16,6 +16,9 @@ pub(crate) const HOST_PLACEHOLDER: &str = "{host}"; /// The longest name DNS carries. const MAX_HOST_LEN: usize = 253; +/// The longest key a Fastly config store entry takes, in characters. +const MAX_KEY_LEN: usize = 255; + /// Why a request is not served. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum Refusal { @@ -57,11 +60,12 @@ pub(crate) fn normalized_host(host: &str) -> Option { /// and nothing is looked up, which is every service that serves one /// publisher. Otherwise the key is the selector with the request's host in /// the placeholder's place, and `exists` is asked whether the store holds it. +/// A key longer than the store takes is never looked up. /// /// # Errors /// -/// Returns [`Refusal::NoConfigForHost`] when the request names no host name -/// or the store holds no blob at the host's key. Returns +/// Returns [`Refusal::NoConfigForHost`] when the request names no host name, +/// the host's key is too long, or the store holds no blob at it. Returns /// [`Refusal::StoreUnavailable`] when `exists` fails, because a request is /// not served while it is unknown which blob is its own. pub(crate) fn resolve_key( @@ -75,6 +79,7 @@ pub(crate) fn resolve_key( let key = host .and_then(normalized_host) .map(|host| selector.replace(HOST_PLACEHOLDER, &host)) + .filter(|key| key.chars().count() <= MAX_KEY_LEN) .ok_or(Refusal::NoConfigForHost)?; match exists(&key) { Ok(true) => Ok(key), @@ -296,4 +301,23 @@ mod tests { "should not serve while the host's blob is unknown" ); } + + #[test] + fn a_host_key_over_255_characters_is_not_looked_up() { + let label = "a".repeat(63); + let host = format!("{label}.{label}.{label}.{}", "a".repeat(61)); + assert_eq!(host.len(), MAX_HOST_LEN, "should build the longest name"); + + assert_eq!( + resolve_key("{host}_v2", Some(&host), no_lookup), + Err(Refusal::NoConfigForHost), + "a key of 256 characters should count as no blob, with no lookup" + ); + let longest_key = format!("{host}_v"); + assert_eq!( + resolve_key("{host}_v", Some(&host), holding(&[longest_key.as_str()])), + Ok(longest_key.clone()), + "a key of 255 characters should still be looked up" + ); + } } From a6305184dea306d42eaffaa4ce935fa9f32b12f9 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 07:17:53 +0100 Subject: [PATCH 08/18] Read the Fastly selector from the TS__CONFIG__KEY entry of the store linked as trusted_server_config EdgeZero v0.0.9 removed the edgezero_runtime_env store that held the per-service __KEY selector, so a service that keeps a blob for each host would read the one blob at trusted_server_config for every host. The entry point now reads the selector from the TS__CONFIG__KEY entry of the app-config store on every request, beside the blobs it names and the sandbox bounds, with try_get so a failed lookup cannot panic. Without the entry the key is the logical store ID, as on main. A selector that is empty, carries whitespace, a control character or {host} more than once, carries a brace outside its one {host}, or is longer than 255 characters is malformed and answered 500, never read as one key for every host. A store that cannot be opened or read is answered 500. The comments of main that said no selector reaches the Fastly runtime now say where it is read. --- .../trusted-server-adapter-fastly/src/app.rs | 18 +- .../src/config_selection.rs | 163 +++++++++++++++++- .../trusted-server-adapter-fastly/src/lib.rs | 96 +++++++++-- .../trusted-server-core/src/settings_data.rs | 12 +- 4 files changed, 253 insertions(+), 36 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 8456d1ed61..1a4c7a8e3c 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -169,9 +169,10 @@ use crate::platform::{ /// The stores a service reads and the key of its app-config blob. /// -/// [`Self::logical`] gives the logical stores and key. A selector that names a -/// blob for each host is resolved for the request before anything is built, -/// by `stores_for_request` in the entry point. +/// [`Self::logical`] gives the logical stores and key. The entry point puts +/// the selector the config store holds in place of the key and resolves it +/// for the request before anything is built, with +/// `stores_with_stored_selector` and `stores_for_request`. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct RuntimeStoreConfig { pub(crate) config_store_name: StoreName, @@ -182,12 +183,11 @@ pub(crate) struct RuntimeStoreConfig { impl RuntimeStoreConfig { /// Store bindings for the Fastly runtime. /// - /// Store selection is a deploy-time input; no store selector reaches the - /// Fastly runtime. `EdgeZero` links each selected physical store to the - /// service version under its logical ID, so the runtime opens stores by - /// logical ID and reads the config entry under that same ID for every - /// publication target. Staging isolation comes from linking a different - /// physical store, never from a different key. + /// A deployment chooses each physical store, and `EdgeZero` links it to + /// the service version under its logical ID, so the runtime opens every + /// store by logical ID. The config key is the logical store ID until the + /// entry point puts the store's selector in its place. A staging version + /// is kept apart by the physical store it links. pub(crate) fn logical() -> Self { Self { config_store_name: StoreName::from(DEFAULT_CONFIG_STORE_ID), diff --git a/crates/trusted-server-adapter-fastly/src/config_selection.rs b/crates/trusted-server-adapter-fastly/src/config_selection.rs index 7f84ae68f2..16ac668de4 100644 --- a/crates/trusted-server-adapter-fastly/src/config_selection.rs +++ b/crates/trusted-server-adapter-fastly/src/config_selection.rs @@ -1,16 +1,21 @@ //! Which app-config blob serves a request. //! -//! A service reads one blob, at the key its `__KEY` selector names or at the -//! logical store ID. A selector that carries [`HOST_PLACEHOLDER`] names a blob -//! for each host instead. The placeholder is replaced by the host a request -//! names, so one service serves several publishers, each from a blob of its -//! own. +//! The service's selector is the entry [`SELECTOR_ENTRY`] in the config store +//! linked as `trusted_server_config`, beside the blobs it names. A service +//! without the entry reads one blob, at the logical store ID, and a selector +//! without [`HOST_PLACEHOLDER`] is the key of one blob. A selector that +//! carries the placeholder names a blob for each host instead. The +//! placeholder is replaced by the host a request names, so one service serves +//! several publishers, each from a blob of its own. //! //! A host that has no blob is refused. It is never answered from another key. use core::fmt::Display; -/// The part of a `__KEY` selector that a request's host replaces. +/// The config store entry that holds the service's selector. +pub(crate) const SELECTOR_ENTRY: &str = "TS__CONFIG__KEY"; + +/// The part of a selector that a request's host replaces. pub(crate) const HOST_PLACEHOLDER: &str = "{host}"; /// The longest name DNS carries. @@ -24,8 +29,62 @@ const MAX_KEY_LEN: usize = 255; pub(crate) enum Refusal { /// The request names no host, or a host the store holds no blob for. NoConfigForHost, - /// Whether the store holds the host's blob could not be read. + /// The selector, or whether the store holds the host's blob, could not + /// be read. StoreUnavailable(String), + /// The selector is malformed, as the message says. + MalformedSelector(String), +} + +/// The selector `lookup` finds at [`SELECTOR_ENTRY`], or `logical_key` when +/// there is no entry. +/// +/// # Errors +/// +/// Returns [`Refusal::StoreUnavailable`] when the lookup fails and +/// [`Refusal::MalformedSelector`] when the selector is malformed, so a +/// service whose selector cannot be used serves no request rather than +/// reading the logical key for every host. +pub(crate) fn read_selector( + logical_key: &str, + lookup: impl FnOnce(&str) -> Result, E>, +) -> Result { + match lookup(SELECTOR_ENTRY) { + Ok(None) => Ok(logical_key.to_owned()), + Ok(Some(selector)) => match malformed(&selector) { + None => Ok(selector), + Some(rule) => Err(Refusal::MalformedSelector(format!( + "selector {selector:?} in `{SELECTOR_ENTRY}` {rule}" + ))), + }, + Err(error) => Err(Refusal::StoreUnavailable(error.to_string())), + } +} + +/// The rule `selector` breaks, or `None` when it is well formed. +/// +/// A brace outside the one placeholder catches `{Host}`, `{hostname}` and an +/// unclosed brace, each of which would otherwise be one key for every host. +fn malformed(selector: &str) -> Option<&'static str> { + if selector.is_empty() { + Some("is empty") + } else if selector + .chars() + .any(|c| c.is_whitespace() || c.is_control()) + { + Some("carries whitespace or a control character") + } else if selector.matches(HOST_PLACEHOLDER).count() > 1 { + Some("carries `{host}` more than once") + } else if selector + .replacen(HOST_PLACEHOLDER, "", 1) + .contains(['{', '}']) + { + Some("carries a brace that is not part of `{host}`") + } else if selector.chars().count() > MAX_KEY_LEN { + Some("is longer than 255 characters") + } else { + None + } } /// The host as a blob is keyed by it, or `None` when `host` is not a host @@ -55,7 +114,7 @@ pub(crate) fn normalized_host(host: &str) -> Option { /// Resolves the key of the blob that serves a request. /// -/// `selector` is the service's `__KEY` selector, or the logical store ID +/// `selector` is the service's selector, or the logical store ID /// where none is set. A selector without the placeholder is the key itself /// and nothing is looked up, which is every service that serves one /// publisher. Otherwise the key is the selector with the request's host in @@ -88,6 +147,28 @@ pub(crate) fn resolve_key( } } +/// The selector in the config store linked as `store_name`, as +/// [`read_selector`] reads it. +/// +/// # Errors +/// +/// Returns [`Refusal::StoreUnavailable`] when the store cannot be opened or +/// the lookup fails, and [`Refusal::MalformedSelector`] as [`read_selector`] +/// does. Uses [`fastly::ConfigStore::try_get`], because `get` panics on a +/// lookup error. +pub(crate) fn selector_in_store(store_name: &str, logical_key: &str) -> Result { + let store = fastly::ConfigStore::try_open(store_name).map_err(|error| { + Refusal::StoreUnavailable(format!( + "failed to open config store `{store_name}`: {error}" + )) + })?; + read_selector(logical_key, |entry| { + store.try_get(entry).map_err(|error| { + format!("failed to look up `{entry}` in config store `{store_name}`: {error}") + }) + }) +} + /// Whether the config store linked as `store_name` holds an entry at `key`. /// /// Reads the entry at the key alone and not the chunks a large blob is split @@ -123,6 +204,16 @@ mod tests { move |key| Ok(keys.contains(&key)) } + /// A store whose selector entry holds `selector`, or nothing. + fn selector_entry( + selector: Option<&str>, + ) -> impl FnOnce(&str) -> Result, String> + '_ { + move |entry| { + assert_eq!(entry, SELECTOR_ENTRY, "should read the selector entry"); + Ok(selector.map(str::to_owned)) + } + } + #[test] fn a_selector_without_the_placeholder_is_the_key_and_nothing_is_looked_up() { for selector in ["trusted_server_config", "trusted_server_config_staging"] { @@ -320,4 +411,60 @@ mod tests { "a key of 255 characters should still be looked up" ); } + + #[test] + fn an_absent_entry_selects_the_logical_key() { + assert_eq!( + read_selector("trusted_server_config", selector_entry(None)), + Ok("trusted_server_config".to_owned()), + "a store with no selector should read the logical key" + ); + } + + #[test] + fn a_well_formed_entry_is_the_selector_as_written() { + for selector in ["{host}", "{host}_v2", "site_a"] { + assert_eq!( + read_selector("trusted_server_config", selector_entry(Some(selector))), + Ok(selector.to_owned()), + "`{selector}` should be read as written" + ); + } + } + + #[test] + fn a_malformed_entry_is_refused() { + let too_long = "a".repeat(MAX_KEY_LEN + 1); + for selector in [ + "", + " {host}", + "{host}\t", + "{host}\u{7}", + "{host}{host}", + "{Host}", + "{hostname}", + "{host", + "host}", + too_long.as_str(), + ] { + let read = read_selector("trusted_server_config", selector_entry(Some(selector))); + assert!( + matches!(read, Err(Refusal::MalformedSelector(_))), + "{selector:?} should be malformed and never one key for every host, got {read:?}" + ); + } + } + + #[test] + fn a_failed_lookup_of_the_entry_is_store_unavailable() { + let read = read_selector("trusted_server_config", |_entry| { + Err::, _>("lookup failed") + }); + + assert_eq!( + read, + Err(Refusal::StoreUnavailable("lookup failed".to_owned())), + "should refuse rather than fall back to the logical key" + ); + } } diff --git a/crates/trusted-server-adapter-fastly/src/lib.rs b/crates/trusted-server-adapter-fastly/src/lib.rs index 5b5b5a539d..417b653e3a 100644 --- a/crates/trusted-server-adapter-fastly/src/lib.rs +++ b/crates/trusted-server-adapter-fastly/src/lib.rs @@ -87,11 +87,40 @@ fn open_trusted_server_config_store(store_name: &str) -> Result Result { + match config_selection::selector_in_store(stores.config_store_name.as_ref(), &stores.config_key) + { + Ok(config_key) => Ok(RuntimeStoreConfig { + config_key, + ..stores + }), + Err(refusal) => { + if let config_selection::Refusal::StoreUnavailable(reason) + | config_selection::Refusal::MalformedSelector(reason) = &refusal + { + log::error!("cannot read the app-config selector: {reason}"); + } + Err(refusal) + } + } +} + /// Chooses the app-config blob that serves this request. /// -/// A `__KEY` selector that carries the host placeholder names a blob for each -/// host, so the key is the selector with this request's host in it, and only -/// then is `Host` read. Any other selector is the key as it stands. +/// A selector that carries the host placeholder names a blob for each host, +/// so the key is the selector with this request's host in it, and only then +/// is `Host` read. Any other selector is the key as it stands. /// /// # Errors /// @@ -101,7 +130,10 @@ fn stores_for_request( stores: RuntimeStoreConfig, req: &FastlyRequest, ) -> Result { - let host = if stores.config_key.contains(config_selection::HOST_PLACEHOLDER) { + let host = if stores + .config_key + .contains(config_selection::HOST_PLACEHOLDER) + { request_host(req) } else { None @@ -120,13 +152,12 @@ fn stores_for_request( .unwrap_or_default(); match &refusal { config_selection::Refusal::NoConfigForHost => log::warn!( - "no app-config blob for host {named:?} under key selector `{}`", + "no app-config blob for host {named:?} under selector `{}`", stores.config_key ), - config_selection::Refusal::StoreUnavailable(reason) => { - log::error!( - "cannot tell whether host {named:?} has an app-config blob: {reason}" - ); + config_selection::Refusal::StoreUnavailable(reason) + | config_selection::Refusal::MalformedSelector(reason) => { + log::error!("cannot choose the app-config blob for host {named:?}: {reason}"); } } Err(refusal) @@ -148,7 +179,8 @@ fn request_host(req: &FastlyRequest) -> Option<&str> { /// /// A host with no blob is answered `421 Misdirected Request`. The answer is /// not to be stored, so a host is served as soon as its blob is pushed. A -/// store that could not say whether the host has a blob is answered `500`. +/// store that could not say whether the host has a blob, or whose selector +/// cannot be read or is malformed, is answered `500`. fn refusal_response(refusal: &config_selection::Refusal) -> FastlyResponse { match refusal { config_selection::Refusal::NoConfigForHost => { @@ -156,7 +188,8 @@ fn refusal_response(refusal: &config_selection::Refusal) -> FastlyResponse { .with_header("cache-control", "private, no-store") .with_body_text_plain("Misdirected Request") } - config_selection::Refusal::StoreUnavailable(_) => { + config_selection::Refusal::StoreUnavailable(_) + | config_selection::Refusal::MalformedSelector(_) => { FastlyResponse::from_status(fastly::http::StatusCode::INTERNAL_SERVER_ERROR) .with_body_text_plain("Internal Server Error") } @@ -442,7 +475,9 @@ fn heap_mib() -> String { fn edgezero_main(mut req: FastlyRequest, sandbox: &mut Sandbox, ordinal: u64, request_id: &str) { // Settled before anything reads settings, so every path below reads the // blob of the publisher this request is for. - let runtime_stores = match stores_for_request(RuntimeStoreConfig::logical(), &req) { + let runtime_stores = match stores_with_stored_selector(RuntimeStoreConfig::logical()) + .and_then(|stores| stores_for_request(stores, &req)) + { Ok(stores) => stores, Err(refusal) => { refusal_response(&refusal).send_to_client(); @@ -1465,6 +1500,43 @@ mod tests { ); } + #[test] + fn a_malformed_selector_is_answered_500() { + let response = refusal_response(&config_selection::Refusal::MalformedSelector( + "selector \"{Host}\" carries a brace".to_owned(), + )); + + assert_eq!( + response.get_status(), + fastly::http::StatusCode::INTERNAL_SERVER_ERROR, + "a selector the operator wrote wrongly should not answer 421" + ); + } + + #[test] + fn the_root_store_holds_no_selector() { + let stores = stores_with_stored_selector(RuntimeStoreConfig::logical()) + .expect("should read the root fastly.toml's config store"); + + assert_eq!( + stores, + RuntimeStoreConfig::logical(), + "a store with no selector entry should read the logical key" + ); + } + + #[test] + fn a_store_that_is_not_linked_is_store_unavailable() { + let refusal = + config_selection::selector_in_store("not_a_linked_store", "trusted_server_config") + .expect_err("should not read a selector from a store that is not linked"); + + assert!( + matches!(refusal, config_selection::Refusal::StoreUnavailable(_)), + "should refuse rather than read the logical key, got {refusal:?}" + ); + } + #[test] fn take_finalize_sentinel_strips_sentinel() { let mut response = HttpResponse::new(EdgeBody::empty()); diff --git a/crates/trusted-server-core/src/settings_data.rs b/crates/trusted-server-core/src/settings_data.rs index 38beccf8df..c656b5f04c 100644 --- a/crates/trusted-server-core/src/settings_data.rs +++ b/crates/trusted-server-core/src/settings_data.rs @@ -43,10 +43,10 @@ pub fn config_key(env: &EnvConfig) -> String { /// Resolves the `EdgeZero` app-config store name from the process environment. /// -/// Native adapters such as Axum use this wrapper. Fastly reads no selector at -/// runtime: it opens the store under the logical ID that a managed deployment -/// links to the selected physical store. Without an override, this wrapper -/// also returns the manifest default logical store ID. +/// Native adapters such as Axum use this wrapper. Fastly opens the store under +/// the logical ID that a managed deployment links to the selected physical +/// store. Without an override, this wrapper also returns the manifest default +/// logical store ID. #[must_use] pub fn default_config_store_name() -> StoreName { config_store_name(&EnvConfig::from_env()) @@ -55,9 +55,7 @@ pub fn default_config_store_name() -> StoreName { /// Resolves the app-config blob key from the process environment. /// /// Native adapters such as Axum use this wrapper and honour a `__KEY` -/// override. The Fastly runtime always reads the entry under the logical store -/// ID, and its CLI rejects any other `--key` or `__KEY` value, so no override -/// applies there. +/// override. #[must_use] pub fn default_config_key() -> String { config_key(&EnvConfig::from_env()) From faf802659509681997b2853104e53626f16f2247 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 07:24:09 +0100 Subject: [PATCH 09/18] Push a Fastly blob under a key other than the logical store ID with --local EdgeZero v0.0.9 refuses any Fastly config key other than the logical store ID, for a local push as much as a remote one, so a blob for each host could no longer be pushed. ts config push --adapter fastly with another --key now goes to a writer of its own. It validates the blob as ts config validate does, wraps it in EdgeZero's envelope, splits a blob over 8,000 bytes into EdgeZero's chunk format with the chunks before the pointer, checks every key against 255 characters before writing anything, and writes the local server's store in the fastly.toml the manifest names. --dry-run lists the keys and writes nothing. Without --local the push is refused with a message that says so, because writing a live store needs the Fastly CLI runner that arrives with the signing key commands. --staging with --key is still refused when the arguments are parsed. A push of the logical store ID still goes through EdgeZero. --- Cargo.lock | 2 + crates/trusted-server-cli/Cargo.toml | 2 + crates/trusted-server-cli/src/app_config.rs | 3 +- .../src/commands/config/keyed_push.rs | 359 ++++++++++++++++++ .../src/commands/config/mod.rs | 1 + crates/trusted-server-cli/src/run.rs | 31 +- .../tests/config_push_by_key.rs | 325 ++++++++++++++++ .../tests/config_store_defaults.rs | 4 +- 8 files changed, 720 insertions(+), 7 deletions(-) create mode 100644 crates/trusted-server-cli/src/commands/config/keyed_push.rs create mode 100644 crates/trusted-server-cli/tests/config_push_by_key.rs diff --git a/Cargo.lock b/Cargo.lock index 1297b080d9..eda7dfefd0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6762,6 +6762,7 @@ dependencies = [ "base64", "bytes", "chromiumoxide", + "chrono", "clap", "derive_more", "directories", @@ -6789,6 +6790,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml_ng", + "sha2 0.10.9", "similar", "temp-env", "tempfile", diff --git a/crates/trusted-server-cli/Cargo.toml b/crates/trusted-server-cli/Cargo.toml index 2da02463d1..604724a5f9 100644 --- a/crates/trusted-server-cli/Cargo.toml +++ b/crates/trusted-server-cli/Cargo.toml @@ -16,6 +16,7 @@ workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] chromiumoxide = { workspace = true } +chrono = { workspace = true } clap = { workspace = true } derive_more = { workspace = true } error-stack = { workspace = true } @@ -50,6 +51,7 @@ scraper = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } serde_yaml_ng = { workspace = true } +sha2 = { workspace = true } similar = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true } diff --git a/crates/trusted-server-cli/src/app_config.rs b/crates/trusted-server-cli/src/app_config.rs index 179aa592e7..e145a50dde 100644 --- a/crates/trusted-server-cli/src/app_config.rs +++ b/crates/trusted-server-cli/src/app_config.rs @@ -131,7 +131,8 @@ fn load_settings_with_env_overlay( }) } -fn resolve_app_config_path( +/// `explicit`, or else `.toml` beside the manifest. +pub(crate) fn resolve_app_config_path( explicit: Option<&Path>, manifest_path: &Path, app_name: &str, diff --git a/crates/trusted-server-cli/src/commands/config/keyed_push.rs b/crates/trusted-server-cli/src/commands/config/keyed_push.rs new file mode 100644 index 0000000000..8d08e48d29 --- /dev/null +++ b/crates/trusted-server-cli/src/commands/config/keyed_push.rs @@ -0,0 +1,359 @@ +//! Pushes an app-config blob to Fastly under a key other than the logical +//! store ID. +//! +//! A service that chooses its blob by host keeps a blob under each host, and +//! `EdgeZero` writes no Fastly key but the logical store ID. This writer +//! validates the blob as `ts config validate` does, wraps it in `EdgeZero`'s +//! envelope and splits a long one into `EdgeZero`'s chunk format, so the +//! runtime reads it as it reads any pushed blob. `EdgeZero` keeps that format +//! private to its crate, so the constants below copy it. The writer writes +//! only the local server's store, in the `fastly.toml` the manifest names. + +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use edgezero_cli::args::{ConfigPushArgs, ConfigValidateArgs}; +use edgezero_core::app_config::{self, AppConfigLoadOptions}; +use edgezero_core::blob_envelope::BlobEnvelope; +use edgezero_core::manifest::{Manifest, ManifestLoader}; +use serde::Serialize; +use sha2::{Digest as _, Sha256}; +use toml_edit::{DocumentMut, Item, Table}; +use trusted_server_core::config::TrustedServerAppConfig; +use trusted_server_core::config_payload::DEFAULT_CONFIG_STORE_ID; + +use crate::app_config::resolve_app_config_path; + +/// The adapter whose keys this writer writes. +const ADAPTER: &str = "fastly"; +/// The longest value one config store entry holds, counted in bytes. +const ENTRY_LIMIT: usize = 8_000; +/// The most bytes of an envelope one chunk carries. +const CHUNK_LIMIT: usize = 7_000; +/// The longest key a config store entry takes, in characters. +const KEY_LIMIT: usize = 255; +/// Joins a blob's key to a chunk's content address and index. +const CHUNK_KEY_INFIX: &str = ".__edgezero_chunks."; +/// Marks an entry as a pointer to the chunks of one blob. +const POINTER_KIND: &str = "fastly_config_chunks"; + +/// The pointer to the chunks of one blob, with `EdgeZero`'s fields in +/// `EdgeZero`'s order. +#[derive(Serialize)] +struct ChunkPointer<'a> { + chunks: Vec, + data_sha256: &'a str, + edgezero_kind: &'static str, + envelope_len: usize, + envelope_sha256: &'a str, + version: u8, +} + +/// One chunk a pointer names. +#[derive(Serialize)] +struct ChunkRef { + key: String, + len: usize, + sha256: String, +} + +/// The key this writer pushes for `args`, or `None` when the push is +/// `EdgeZero`'s. +/// +/// The writer takes a Fastly push of a `--key` other than the logical store +/// ID, which `EdgeZero` refuses. +#[must_use] +pub(crate) fn own_key(args: &ConfigPushArgs) -> Option<&str> { + let logical_store = args + .store + .as_deref() + .is_none_or(|store| store == DEFAULT_CONFIG_STORE_ID); + if !args.adapter.eq_ignore_ascii_case(ADAPTER) || !logical_store { + return None; + } + args.key + .as_deref() + .filter(|key| *key != DEFAULT_CONFIG_STORE_ID) +} + +/// Pushes the blob `args` names under `key`, into the local server's store. +/// +/// Every key is checked before anything is written, and a long blob's +/// chunks are written before the pointer to them. `--dry-run` lists the keys +/// and writes nothing. +/// +/// # Errors +/// +/// Returns a message when the push is not `--local`, the blob fails +/// validation, a key is refused, or the local manifest cannot be read or +/// written. +pub(crate) fn run(args: &ConfigPushArgs, key: &str, out: &mut dyn Write) -> Result<(), String> { + if !args.local { + return Err(format!( + "`--key {key}` can be pushed only with `--local` on Fastly. Write a live \ + store's entries with `fastly config-store-entry update`." + )); + } + let loader = ManifestLoader::from_path(&args.manifest) + .map_err(|error| format!("failed to load {}: {error}", args.manifest.display()))?; + let manifest = loader.manifest(); + let envelope = envelope(args, manifest)?; + let envelope_json = serde_json::to_string(&envelope) + .map_err(|error| format!("failed to serialize the envelope: {error}"))?; + let entries = entries(key, &envelope_json, &envelope.sha256)?; + let fastly_toml = local_manifest(&args.manifest, manifest)?; + + let verb = if args.dry_run { "would write" } else { "wrote" }; + if !args.dry_run { + write_local(&fastly_toml, &entries)?; + } + for (entry_key, value) in &entries { + writeln!( + out, + "{verb} `{entry_key}` ({} bytes) to \ + [local_server.config_stores.{DEFAULT_CONFIG_STORE_ID}.contents] in {}", + value.len(), + fastly_toml.display() + ) + .map_err(|error| format!("failed to write command output: {error}"))?; + } + Ok(()) +} + +/// The blob `args` names, validated as `ts config validate` validates it and +/// wrapped in `EdgeZero`'s envelope. +fn envelope(args: &ConfigPushArgs, manifest: &Manifest) -> Result { + let mut validate = ConfigValidateArgs::default(); + validate.app_config.clone_from(&args.app_config); + validate.manifest.clone_from(&args.manifest); + validate.no_env = args.no_env; + edgezero_cli::run_config_validate_typed::(&validate)?; + + let app_name = manifest.app.name.as_deref().ok_or_else(|| { + format!( + "{} has no [app].name; cannot resolve the app config", + args.manifest.display() + ) + })?; + let path = resolve_app_config_path(args.app_config.as_deref(), &args.manifest, app_name); + let mut options = AppConfigLoadOptions::default(); + options.env_overlay = !args.no_env; + let typed = app_config::deserialize_app_config_with_options::( + &path, app_name, &options, + ) + .map_err(|error| format!("failed to load {}: {error}", path.display()))?; + let data = serde_json::to_value(&typed) + .map_err(|error| format!("failed to serialize the app config: {error}"))?; + let generated_at = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + Ok(BlobEnvelope::new(data, generated_at)) +} + +/// The entries that hold `envelope_json` under `key`, in the order they are +/// written. +/// +/// An envelope that fits one entry is that entry. A longer one is split into +/// chunks at `EdgeZero`'s boundaries, followed by the pointer to them, so the +/// chunks are always in place before the pointer that names them. +/// +/// # Errors +/// +/// Returns a message, before anything is written, when `key` is empty or +/// holds the chunk infix, when it or a chunk key derived from it is longer +/// than [`KEY_LIMIT`] characters, or when the pointer is too long for an +/// entry. +fn entries( + key: &str, + envelope_json: &str, + data_sha256: &str, +) -> Result, String> { + if key.is_empty() || key.contains(CHUNK_KEY_INFIX) { + return Err(format!( + "config key {key:?} is empty or holds `{CHUNK_KEY_INFIX}`, which only chunk keys \ + carry" + )); + } + check_key_length(key)?; + if envelope_json.len() <= ENTRY_LIMIT { + return Ok(vec![(key.to_owned(), envelope_json.to_owned())]); + } + + let envelope_sha256 = sha256_hex(envelope_json); + let mut entries = Vec::new(); + let mut chunks = Vec::new(); + for (index, chunk) in chunks_of(envelope_json).enumerate() { + let chunk_key = format!("{key}{CHUNK_KEY_INFIX}{envelope_sha256}.{index}"); + check_key_length(&chunk_key)?; + chunks.push(ChunkRef { + key: chunk_key.clone(), + len: chunk.len(), + sha256: sha256_hex(chunk), + }); + entries.push((chunk_key, chunk.to_owned())); + } + let pointer = serde_json::to_string(&ChunkPointer { + chunks, + data_sha256, + edgezero_kind: POINTER_KIND, + envelope_len: envelope_json.len(), + envelope_sha256: &envelope_sha256, + version: 1, + }) + .map_err(|error| format!("failed to serialize the chunk pointer for `{key}`: {error}"))?; + if pointer.len() > ENTRY_LIMIT { + return Err(format!( + "the chunk pointer for `{key}` is {} bytes, over the {ENTRY_LIMIT}-byte entry limit", + pointer.len() + )); + } + entries.push((key.to_owned(), pointer)); + Ok(entries) +} + +/// Refuses a key longer than a config store entry takes. +fn check_key_length(key: &str) -> Result<(), String> { + let length = key.chars().count(); + if length > KEY_LIMIT { + return Err(format!( + "config key `{key}` is {length} characters, over the {KEY_LIMIT}-character limit. \ + A long blob's chunk keys add 85 or more characters to its key, so use a shorter \ + --key." + )); + } + Ok(()) +} + +/// `envelope_json` in pieces of at most [`CHUNK_LIMIT`] bytes, each ending on +/// a character boundary, which are the boundaries `EdgeZero` splits at. +fn chunks_of(envelope_json: &str) -> impl Iterator { + let mut rest = envelope_json; + core::iter::from_fn(move || { + if rest.is_empty() { + return None; + } + let mut end = CHUNK_LIMIT.min(rest.len()); + while !rest.is_char_boundary(end) { + end -= 1; + } + let (chunk, tail) = rest.split_at(end); + rest = tail; + Some(chunk) + }) +} + +/// The lowercase hex SHA-256 of `text`. +fn sha256_hex(text: &str) -> String { + format!("{:x}", Sha256::digest(text.as_bytes())) +} + +/// The `fastly.toml` the manifest names for the Fastly adapter, beside the +/// manifest. +fn local_manifest(manifest_path: &Path, manifest: &Manifest) -> Result { + let named = manifest + .adapter_entry(ADAPTER) + .and_then(|(_, adapter)| adapter.adapter.manifest.as_deref()) + .ok_or_else(|| { + format!( + "{} names no [adapters.fastly.adapter].manifest for a local push", + manifest_path.display() + ) + })?; + let root = manifest_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + Ok(root.join(named)) +} + +/// Writes `entries` into the local server's logical config store in +/// `fastly_toml`, keeping every other entry. +fn write_local(fastly_toml: &Path, entries: &[(String, String)]) -> Result<(), String> { + let raw = fs::read_to_string(fastly_toml) + .map_err(|error| format!("failed to read {}: {error}", fastly_toml.display()))?; + // The parse error is left out, because it can quote a stored value. + let mut document: DocumentMut = raw + .parse() + .map_err(|_error| format!("failed to parse {} as TOML", fastly_toml.display()))?; + let contents = local_contents(&mut document).ok_or_else(|| { + format!( + "{} holds [local_server.config_stores.{DEFAULT_CONFIG_STORE_ID}] in a form other \ + than an inline-toml table", + fastly_toml.display() + ) + })?; + for (key, value) in entries { + contents.insert(key, toml_edit::value(value.as_str())); + } + fs::write(fastly_toml, document.to_string()) + .map_err(|error| format!("failed to write {}: {error}", fastly_toml.display())) +} + +/// The `contents` table of the local server's logical config store, made +/// when absent, or `None` when the manifest holds the store in another form. +fn local_contents(document: &mut DocumentMut) -> Option<&mut Table> { + let store = document + .entry("local_server") + .or_insert(toml_edit::table()) + .as_table_mut()? + .entry("config_stores") + .or_insert(toml_edit::table()) + .as_table_mut()? + .entry(DEFAULT_CONFIG_STORE_ID) + .or_insert_with(|| { + let mut store = Table::new(); + store.insert("format", toml_edit::value("inline-toml")); + Item::Table(store) + }) + .as_table_mut()?; + if store.get("format").and_then(Item::as_str) != Some("inline-toml") { + return None; + } + store + .entry("contents") + .or_insert(toml_edit::table()) + .as_table_mut() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_long_envelope_is_split_at_character_boundaries_with_the_pointer_last() { + // After one byte, two-byte characters straddle the chunk limit, so a + // split by bytes alone would cut one in half. + let envelope_json = format!("x{}", "\u{e9}".repeat(ENTRY_LIMIT)); + + let written = entries("a.example", &envelope_json, "data-sha").expect("should split"); + + let (pointer_key, pointer) = written.last().expect("should write a pointer"); + assert_eq!(pointer_key, "a.example", "should write the pointer last"); + let chunks: Vec<&str> = written[..written.len() - 1] + .iter() + .map(|(_, chunk)| chunk.as_str()) + .collect(); + assert_eq!( + chunks.concat(), + envelope_json, + "the chunks should join back into the envelope" + ); + assert_eq!( + chunks[0].len(), + CHUNK_LIMIT - 1, + "should end the first chunk before the character the limit cuts" + ); + assert!( + chunks.iter().all(|chunk| chunk.len() <= CHUNK_LIMIT), + "no chunk should pass the chunk limit" + ); + let pointer: serde_json::Value = + serde_json::from_str(pointer).expect("should write the pointer as JSON"); + assert_eq!(pointer["edgezero_kind"], POINTER_KIND); + assert_eq!(pointer["envelope_len"], envelope_json.len()); + assert_eq!( + pointer["chunks"][0]["key"], + format!("a.example{CHUNK_KEY_INFIX}{}.0", sha256_hex(&envelope_json)), + "should key each chunk by the envelope's hash and its place" + ); + } +} diff --git a/crates/trusted-server-cli/src/commands/config/mod.rs b/crates/trusted-server-cli/src/commands/config/mod.rs index 77af7de851..d11ae4ef40 100644 --- a/crates/trusted-server-cli/src/commands/config/mod.rs +++ b/crates/trusted-server-cli/src/commands/config/mod.rs @@ -1,2 +1,3 @@ pub mod ad_templates; pub mod init; +pub mod keyed_push; diff --git a/crates/trusted-server-cli/src/run.rs b/crates/trusted-server-cli/src/run.rs index eb93661903..28db7942c7 100644 --- a/crates/trusted-server-cli/src/run.rs +++ b/crates/trusted-server-cli/src/run.rs @@ -10,6 +10,7 @@ use trusted_server_core::config::TrustedServerAppConfig; use crate::commands::audit::{AuditArgs, run_audit}; use crate::commands::config::ad_templates::{AdTemplatesCommand, run_ad_templates}; use crate::commands::config::init::{ConfigInitArgs, run_config_init}; +use crate::commands::config::keyed_push; use crate::commands::pbs::{self, PbsArgs}; use crate::prebid_bundle::{NpmPrebidBundleGenerator, PrebidBundleArgs, run_bundle}; @@ -67,7 +68,8 @@ enum ConfigCommand { Diff(ConfigDiffArgs), /// Reclaim orphaned chunk entries leaked from prior oversized pushes. Gc(ConfigGcArgs), - /// Push `trusted-server.toml` as a blob envelope through `EdgeZero`. + /// Push `trusted-server.toml` as a blob envelope through `EdgeZero`, or + /// under a Fastly `--key` of its own with `--local`. Push(ConfigPushArgs), /// Validate `edgezero.toml` and the typed Trusted Server config. Validate(ConfigValidateArgs), @@ -143,10 +145,15 @@ fn dispatch(args: Args) -> Result { Command::Config(ConfigCommand::Gc(args)) => { edgezero_cli::run_config_gc(&args).map(|()| RunOutcome::Success) } - Command::Config(ConfigCommand::Push(args)) => { - edgezero_cli::run_config_push_typed::(&args) - .map(|()| RunOutcome::Success) + Command::Config(ConfigCommand::Push(args)) => match keyed_push::own_key(&args) { + Some(key) => { + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + keyed_push::run(&args, key, &mut out) + } + None => edgezero_cli::run_config_push_typed::(&args), } + .map(|()| RunOutcome::Success), Command::Config(ConfigCommand::Validate(args)) => { edgezero_cli::run_config_validate_typed::(&args) .map(|()| RunOutcome::Success) @@ -611,6 +618,22 @@ mod tests { .expect_err("should reject --key with --staging; the staging key is derived"); } + #[test] + fn a_host_key_cannot_be_pushed_with_staging() { + Args::try_parse_from([ + "ts", + "config", + "push", + "--adapter", + "fastly", + "--local", + "--staging", + "--key", + "a.example", + ]) + .expect_err("should refuse a host's key with --staging"); + } + #[test] fn config_diff_parses_staging_and_rejects_explicit_key() { let args = parse(&["ts", "config", "diff", "--adapter", "fastly", "--staging"]); diff --git a/crates/trusted-server-cli/tests/config_push_by_key.rs b/crates/trusted-server-cli/tests/config_push_by_key.rs new file mode 100644 index 0000000000..6c2da1b587 --- /dev/null +++ b/crates/trusted-server-cli/tests/config_push_by_key.rs @@ -0,0 +1,325 @@ +//! A Fastly push under a key other than the logical store ID, which `ts` +//! writes itself because `EdgeZero` writes no such key. + +use std::collections::BTreeMap; +use std::fs; +use std::path::PathBuf; +use std::process::{Command, Output}; + +use error_stack::Report; +use tempfile::TempDir; +use toml_edit::DocumentMut; +use trusted_server_core::config_payload::DEFAULT_CONFIG_STORE_ID; +use trusted_server_core::error::TrustedServerError; +use trusted_server_core::platform::{ + PlatformConfigStore, PlatformError, PlatformSecretStore, StoreId, StoreName, +}; +use trusted_server_core::settings::Settings; +use trusted_server_core::settings_data::get_settings_from_config_store_with; + +const HOST: &str = "a.publisher.example"; + +/// A project holding the repository's manifests, with only the Fastly +/// adapter declared, and the example app config. +fn project() -> TempDir { + let directory = tempfile::tempdir().expect("should create a temporary project"); + let mut manifest: toml::Value = toml::from_str(include_str!("../../../edgezero.toml")) + .expect("should parse the repository manifest"); + manifest["adapters"] + .as_table_mut() + .expect("should declare adapters") + .retain(|name, _| name == "fastly"); + fs::write( + directory.path().join("edgezero.toml"), + toml::to_string(&manifest).expect("should serialize the Fastly test manifest"), + ) + .expect("should write the test manifest"); + fs::write( + directory.path().join("fastly.toml"), + include_str!("../../../fastly.toml"), + ) + .expect("should copy the local Fastly manifest"); + fs::write( + directory.path().join("trusted-server.toml"), + include_str!("../../../trusted-server.example.toml"), + ) + .expect("should copy the example app config"); + directory +} + +/// Makes the project's app config long enough to be stored as chunks. +fn lengthen_app_config(project: &TempDir) { + let path = project.path().join("trusted-server.toml"); + let mut document: DocumentMut = fs::read_to_string(&path) + .expect("should read the app config") + .parse() + .expect("should parse the app config"); + let mut domains = toml_edit::Array::new(); + for index in 0..400 { + domains.push(format!("*.assets-{index}.publisher.example")); + } + document["proxy"]["allowed_domains"] = toml_edit::value(domains); + fs::write(&path, document.to_string()).expect("should write the app config"); +} + +fn fastly_toml(project: &TempDir) -> PathBuf { + project.path().join("fastly.toml") +} + +/// Runs `ts config push --adapter fastly` in `project` with `args`. +fn push(project: &TempDir, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_ts")) + .args([ + "config", + "push", + "--adapter", + "fastly", + "--no-diff", + "--yes", + ]) + .args(args) + .current_dir(project.path()) + // Do not inherit operator configuration or credentials from the test runner. + .env_clear() + .env("RUST_LOG", "info") + .env("TRUSTED_SERVER__PUBLISHER__DOMAIN", HOST) + .env( + "TRUSTED_SERVER__PUBLISHER__COOKIE_DOMAIN", + format!(".{HOST}"), + ) + .env( + "TRUSTED_SERVER__PUBLISHER__ORIGIN_URL", + "https://origin.publisher.example", + ) + .output() + .expect("should run a config push") +} + +fn assert_success(output: &Output) { + assert!( + output.status.success(), + "the push should succeed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +/// The local server's logical config store, in the order its entries sit in +/// the file. +fn stored_entries(project: &TempDir) -> Vec<(String, String)> { + let document: DocumentMut = fs::read_to_string(fastly_toml(project)) + .expect("should read the local Fastly manifest") + .parse() + .expect("should parse the local Fastly manifest"); + document["local_server"]["config_stores"][DEFAULT_CONFIG_STORE_ID]["contents"] + .as_table() + .expect("should hold the store's contents") + .iter() + .map(|(key, value)| { + let value = value.as_str().expect("should store each entry as a string"); + (key.to_owned(), value.to_owned()) + }) + .collect() +} + +struct MemoryConfigStore(BTreeMap); + +impl PlatformConfigStore for MemoryConfigStore { + fn get(&self, _store_name: &StoreName, key: &str) -> Result> { + self.0.get(key).cloned().ok_or_else(|| { + Report::new(PlatformError::ConfigStore).attach(format!("missing key `{key}`")) + }) + } + + fn put( + &self, + _store_id: &StoreId, + _key: &str, + _value: &str, + ) -> Result<(), Report> { + Ok(()) + } + + fn delete(&self, _store_id: &StoreId, _key: &str) -> Result<(), Report> { + Ok(()) + } +} + +/// Answers every secret reference with one value, long enough for each. +struct FixedSecretStore; + +impl PlatformSecretStore for FixedSecretStore { + fn get_bytes( + &self, + _store_name: &StoreName, + _key: &str, + ) -> Result, Report> { + Ok(b"read-back-secret-value-of-at-least-32-bytes".to_vec()) + } + + fn create( + &self, + _store_id: &StoreId, + _name: &str, + _value: &str, + ) -> Result<(), Report> { + Ok(()) + } + + fn delete(&self, _store_id: &StoreId, _name: &str) -> Result<(), Report> { + Ok(()) + } +} + +/// Reads the blob at `key` the way the runtime does, chunks included. +fn read_back( + entries: &[(String, String)], + key: &str, +) -> Result> { + get_settings_from_config_store_with( + &MemoryConfigStore(entries.iter().cloned().collect()), + &FixedSecretStore, + &StoreName::from(DEFAULT_CONFIG_STORE_ID), + key, + &StoreName::from("trusted_server_secrets"), + &trusted_server_modules::builders(), + ) +} + +#[test] +fn a_local_push_of_a_host_key_writes_the_local_store() { + let project = project(); + + let output = push(&project, &["--local", "--key", HOST]); + + assert_success(&output); + let entries = stored_entries(&project); + let settings = read_back(&entries, HOST).expect("should read the host's blob back"); + assert_eq!( + settings.publisher.domain, HOST, + "should store the pushed app config" + ); + assert!( + entries + .iter() + .all(|(key, _)| key != DEFAULT_CONFIG_STORE_ID), + "a host's push should not write the logical store ID" + ); + + let logical = push(&project, &["--local", "--key", DEFAULT_CONFIG_STORE_ID]); + + assert_success(&logical); + assert!( + String::from_utf8_lossy(&logical.stdout) + .contains(&format!("pushed-key={DEFAULT_CONFIG_STORE_ID}")), + "a push of the logical store ID should still go through EdgeZero: {}", + String::from_utf8_lossy(&logical.stdout) + ); +} + +#[test] +fn a_long_document_is_written_as_chunks_then_a_pointer() { + let project = project(); + lengthen_app_config(&project); + let before = fs::read_to_string(fastly_toml(&project)).expect("should read the manifest"); + + let preview = push(&project, &["--local", "--key", HOST, "--dry-run"]); + + assert_success(&preview); + assert!( + String::from_utf8_lossy(&preview.stdout).contains("would write"), + "a dry run should list the keys it would write" + ); + assert_eq!( + fs::read_to_string(fastly_toml(&project)).expect("should read the manifest"), + before, + "a dry run should write nothing" + ); + + assert_success(&push(&project, &["--local", "--key", HOST])); + + let mut entries = stored_entries(&project); + let chunk_prefix = format!("{HOST}.__edgezero_chunks."); + let chunk_count = entries + .iter() + .filter(|(key, _)| key.starts_with(&chunk_prefix)) + .count(); + assert!(chunk_count > 1, "a long blob should be split into chunks"); + let pointer_at = entries + .iter() + .position(|(key, _)| key == HOST) + .expect("should write the pointer"); + assert!( + entries[..pointer_at] + .iter() + .filter(|(key, _)| key.starts_with(&chunk_prefix)) + .count() + == chunk_count, + "every chunk should be written before the pointer" + ); + let settings = read_back(&entries, HOST).expect("should read the chunked blob back whole"); + assert_eq!( + settings.publisher.domain, HOST, + "should reassemble the pushed app config" + ); + assert_eq!( + settings.proxy.allowed_domains.len(), + 400, + "should reassemble every part of the long blob" + ); + + let pointer = &mut entries[pointer_at].1; + let mut changed: serde_json::Value = + serde_json::from_str(pointer).expect("should write the pointer as JSON"); + changed["chunks"][0]["sha256"] = serde_json::Value::from("0".repeat(64)); + *pointer = changed.to_string(); + let refused = + read_back(&entries, HOST).expect_err("a chunk whose hash differs should be refused"); + assert!( + format!("{refused:?}").contains("sha mismatch"), + "should refuse the chunk for its hash: {refused:?}" + ); +} + +#[test] +fn a_key_whose_chunk_keys_are_too_long_is_refused_before_writing() { + let project = project(); + lengthen_app_config(&project); + let before = fs::read_to_string(fastly_toml(&project)).expect("should read the manifest"); + // Short enough for the pointer, too long once a chunk key adds its + // infix, the envelope's hash and an index. + let key = "k".repeat(200); + + let output = push(&project, &["--local", "--key", &key]); + + assert!(!output.status.success(), "the push should be refused"); + assert!( + String::from_utf8_lossy(&output.stderr).contains("255-character limit"), + "should say why: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + fs::read_to_string(fastly_toml(&project)).expect("should read the manifest"), + before, + "a refused push should write nothing" + ); +} + +#[test] +fn a_remote_push_of_a_host_key_is_refused_without_local() { + let project = project(); + let before = fs::read_to_string(fastly_toml(&project)).expect("should read the manifest"); + + let output = push(&project, &["--key", HOST]); + + assert!(!output.status.success(), "the push should be refused"); + assert!( + String::from_utf8_lossy(&output.stderr).contains("only with `--local`"), + "should say the key can be pushed only with --local: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + fs::read_to_string(fastly_toml(&project)).expect("should read the manifest"), + before, + "a refused push should write nothing" + ); +} diff --git a/crates/trusted-server-cli/tests/config_store_defaults.rs b/crates/trusted-server-cli/tests/config_store_defaults.rs index 2d120855d0..7424f2c66a 100644 --- a/crates/trusted-server-cli/tests/config_store_defaults.rs +++ b/crates/trusted-server-cli/tests/config_store_defaults.rs @@ -137,8 +137,8 @@ fn config_push_resolves_the_physical_name_and_explicit_key() { // Under EdgeZero v0.0.8 the CLI ignored it and always wrote the logical id; // v0.0.9 returns the selector verbatim so the pushed entry and the runtime // that reads it cannot diverge. This test runs the Axum adapter: the Fastly -// runtime reads the logical id for every target, and the Fastly CLI rejects -// any other `__KEY` value, so the override only applies off Fastly. +// runtime reads no `__KEY` override, and the Fastly CLI rejects any other +// `__KEY` value, so the override only applies off Fastly. #[test] fn config_push_writes_the_runtime_key_override_without_the_key_flag() { let project = project(); From 3fd0d7d4533e6def8a168c64a678c959f993cfe2 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 07:30:00 +0100 Subject: [PATCH 10/18] Describe and test the TS__CONFIG__KEY selector in the guide and the local harness The guide's section on one blob for each host writes the selector into TS__CONFIG__KEY in the store linked as trusted_server_config, pushes each host's blob with --local, lists the 421 and 500 answers and what makes a selector malformed, and points a service that kept its selector in edgezero_runtime_env at the upgrade checklist. The CLI guide and main's runtime config section say a selector entry may name another key. The harness writes the selector into the local trusted_server_config store and starts Viceroy for each value, covering no entry, {host}, {host}_v2 beside a blob at the logical store ID, a malformed {Host}, and an old service-scoped selector in an edgezero_runtime_env store, which is not read. In reuse mode the sandbox bounds sit unscoped beside the blobs, and the harness uses main's trusted_server_secrets store. --- CHANGELOG.md | 2 +- docs/guide/cli.md | 10 +- docs/guide/configuration.md | 61 ++++++++---- scripts/README.md | 2 +- scripts/config-by-host-local-test.sh | 133 ++++++++++++++++++--------- 5 files changed, 140 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5df8e97418..232d9e23b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,7 +69,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- One Fastly service can serve several publishers. A `__KEY` selector that contains `{host}` names an app-config blob for each host, pushed with `ts config push --key `, and each request reads the blob under its own host. A host with no blob is answered `421 Misdirected Request`, never from the logical store ID or from another host's blob, and a sandbox that serves several requests keeps an application for each blob, found only by that blob's key. A selector without the placeholder reads one blob as before. `scripts/config-by-host-local-test.sh` runs it under Viceroy. +- One Fastly service can serve several publishers. A `TS__CONFIG__KEY` entry in the `trusted_server_config` store that contains `{host}` names an app-config blob for each host, pushed with `ts config push --local --key `, and each request reads the blob under its own host. A host with no blob is answered `421 Misdirected Request`, never from the logical store ID or from another host's blob, a malformed selector is answered `500`, and a sandbox that serves several requests keeps an application for each blob, found only by that blob's key. Without the entry, or with a selector that has no placeholder, the service reads one blob. `scripts/config-by-host-local-test.sh` runs it under Viceroy. - `ts audit generate` writes the `[[fetch]]` and `[[serve]]` entries that run the page changes of the modules it selects on every HTML page, and `trusted-server.example.toml` documents the entries with every middleware the stock modules supply. `trusted-server-modules` lists those middleware with `middleware()` and `middleware_for(...)`. - Page changes as middleware. A module registers a change it makes to a page with `.with_middleware(...)`, and the ordered `[[fetch]]` and `[[serve]]` entries of the settings say which pages each one runs on and in what order. A fetch middleware runs on the page as the origin sent it and what it writes is stored with a shared template. A serve middleware runs on each reader's copy, whether the page came from the store or from the origin, and nothing it writes is stored. - A builder registers its module from the compiled auction plan with `with_plan_registration` and checks its settings against the plan with `with_plan_validator`, so a module whose page support follows what the plan selects is not named in `trusted-server-core`. Prebid and APS register this way. A renderer descriptor carries its builder's statement of which payload key holds the identifier the renderer picks its bid by, made with `BidRenderer::picking_bid_by`, and the page's `hb_adid` is read through that statement. diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 33b1ee7ce4..76f0e1dc3c 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -256,8 +256,10 @@ EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME= \ ts config diff --adapter fastly --staging ``` -The config key is fixed on Fastly: production, staging, and local Viceroy all -read ``. `--staging` does not choose a different store or +On Fastly, production, staging, and local Viceroy all read +`` unless the store's `TS__CONFIG__KEY` entry names another +key ([One blob for each host](/guide/configuration#one-blob-for-each-host)). +`--staging` does not choose a different store or key: the physical store is whatever `EDGEZERO__STORES__CONFIG____NAME` resolves to in the shell or deploy job that runs the push, falling back to the logical ID when it is unset. If production and staging select the same @@ -268,8 +270,8 @@ commands above do. `--staging` on `config push` / `config diff` writes and compares the `` key in the physical store selected by the current -environment. It is mutually exclusive with `--key`, and on Fastly any `--key` -other than the logical store ID is rejected for every target, staged or not. +environment. It is mutually exclusive with `--key`. On Fastly, `ts` pushes a +`--key` other than the logical store ID itself, and only with `--local`. Inspect and verify deployments with the deploy lifecycle commands. All three are Fastly-only — the axum, cloudflare, and spin adapters reject them: diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 87bcc681c2..832c45c232 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -3183,13 +3183,15 @@ canary keys are no longer read. `[stores.config].default` in `edgezero.toml` supplies the logical config store ID, currently `trusted_server_config`. The Fastly runtime opens the store by -that logical ID and reads the entry under the same key on every target. Store +that logical ID and reads the entry under the same key on every target, unless +the store's `TS__CONFIG__KEY` entry names another +([One blob for each host](#one-blob-for-each-host)). Store selection is a deploy-time input: a managed `ts deploy --adapter fastly` links the physical store named by `EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME` to the service version -under the logical ID. No selector is read from a Config Store at runtime, and -on Fastly the CLI rejects any `--key` or `__KEY` selector other than the -logical ID. +under the logical ID. On Fastly the CLI rejects a `__KEY` selector other than +the logical ID, and it pushes a `--key` other than the logical ID only with +`--local`. Keep the `__NAME` selector set in the shell or deploy job for every push, so `ts config push` writes into the same physical store the deployment links. @@ -3201,9 +3203,10 @@ staging flow. ### One blob for each host One Fastly service can serve several publishers, each from an app-config blob -of its own. Write `{host}` in the `__KEY` selector and the runtime replaces it -with the host of each request before it reads any settings, so every host -reads the blob stored under its own name. +of its own. The `TS__CONFIG__KEY` entry in the store linked as +`trusted_server_config` is the service's selector. Write `{host}` in it and the +runtime replaces it with the host of each request before it reads any +settings, so every host reads the blob stored under its own name. Push a blob for each host the service answers for, then set the selector. Push each key in lower case. The runtime lowercases the request's host and @@ -3212,17 +3215,20 @@ Push each key in lower case. The runtime lowercases the request's host and answered `421`. ```bash -ts config push --adapter fastly --key www.publisher-a.example --app-config publisher-a.toml -ts config push --adapter fastly --key www.publisher-b.example --app-config publisher-b.toml +ts config push --adapter fastly --local --key www.publisher-a.example --app-config publisher-a.toml +ts config push --adapter fastly --local --key www.publisher-b.example --app-config publisher-b.toml fastly config-store-entry update --upsert \ - --store-id \ - --key "EDGEZERO__SERVICES__${FASTLY_SERVICE_ID}__STORES__CONFIG__TRUSTED_SERVER_CONFIG__KEY" \ + --store-id \ + --key TS__CONFIG__KEY \ --value '{host}' ``` -The selector takes effect on the active service as soon as it is written, as -any entry of its `edgezero_runtime_env` store does, so the blobs come first. +`ts config push` writes a key other than `trusted_server_config` only with +`--local`, into the local store in `fastly.toml`. Copy the entries it writes +into a live store with `fastly config-store-entry update`, a long blob's +chunks before its pointer. The selector is read on every request, so the +blobs come first. | A request for | Reads the blob at | Is answered | | ----------------------------------------------------------- | ------------------------- | -------------------------------- | @@ -3230,8 +3236,17 @@ any entry of its `edgezero_runtime_env` store does, so the blobs come first. | `WWW.Publisher-A.example:443` or `www.publisher-a.example.` | `www.publisher-a.example` | From publisher A's config | | `www.publisher-b.example` | `www.publisher-b.example` | From publisher B's config | | A host with no blob | None | `421 Misdirected Request` | -| A `Host` that is not a host name, or no `Host` | None | `421 Misdirected Request` | +| A `Host` that is not a host name, not UTF-8, or absent | None | `421 Misdirected Request` | +| A host whose key would be over 255 characters | None | `421 Misdirected Request` | | `/health`, for any host | None | `200`, before any config is read | +| Any host, under a malformed selector | None | `500` | +| Any host, where the store cannot be read | None | `500` | + +A selector is malformed when it is empty, carries whitespace, a control +character or `{host}` more than once, carries a brace outside its `{host}` +(such as `{Host}`), or is longer than 255 characters. Without the entry every +request reads `trusted_server_config`, and a selector without `{host}` is the +key of one blob for every host. A host with no blob is never answered from the logical store ID or from another host's blob, because a shared fallback is how a host nobody configured @@ -3239,8 +3254,8 @@ comes to be served another publisher's site. A host name is letters, digits and hyphens in labels joined by single dots, and no label starts or ends with a hyphen. An IPv4 address such as `127.0.0.1` passes, and an IPv6 literal such as `[::1]` is refused. Nothing else is looked up, so a request cannot name the -logical store ID or a part of a stored blob, each of which carries an -underscore. +logical store ID, the selector or a part of a stored blob, each of which +carries an underscore. - A publisher with several names has a blob under each. `www.publisher.example` and `publisher.example` are two keys, and what each serves is for the @@ -3251,12 +3266,20 @@ underscore. maps, so each publisher's blob names keys of its own. - Request signing keys belong to the service, so every publisher on it signs with the same keys. -- `ts deploy --staging` points a staged version's selector at - `_staging`, which carries no placeholder. A staged version - therefore reads that one blob for every host and never a production blob. +- A staged version reads the selector and the blobs of the store its version + links as `trusted_server_config`, so a staging store of its own may carry a + selector of its own, such as `{host}_staging`. - Only the Fastly adapter reads the placeholder. The Axum, Cloudflare and Spin adapters read one app-config blob. +A service that wrote its selector as the service-scoped `__KEY` entry of +`edgezero_runtime_env` follows +[Upgrading from EdgeZero v0.0.8](/guide/fastly#upgrading-from-edgezero-v0-0-8) +and writes the same value into `TS__CONFIG__KEY` before it deploys a build +that reads that entry. It also removes any blob at `trusted_server_config` from +a store that holds a blob for each host, so a store that loses its selector +serves no host from it. + `./scripts/config-by-host-local-test.sh` serves two publishers from one local Viceroy instance. It checks that each host is answered from its own blob and that a host with none is refused. diff --git a/scripts/README.md b/scripts/README.md index 9b5c72ec86..c7fa305347 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -16,7 +16,7 @@ their documented orchestration handles a probe deliberately. | `smoke-cloudflare.sh` | Pinned Wrangler, `cargo`, `curl`, `jq`, and Python | Uses isolated Wrangler manifests, KV state, ports, and logs; stops owned processes and removes its workspace. | | `smoke-spin.sh` | Spin CLI, `cargo`, `curl`, and Python | Uses an isolated Spin manifest and SQLite KV store; stops owned processes and removes its workspace. | | `smoke-common.sh` | Sourced by the four smoke scripts | Defines bounded port, process, config, secret, and response assertions. Do not invoke it as a standalone smoke. | -| `config-by-host-local-test.sh` | Viceroy, `curl`, Python; optional mode, ports and artifacts | Builds local artifacts, runs three stub origins and Viceroy from a temporary directory, stops owned servers, and removes the directory. | +| `config-by-host-local-test.sh` | Viceroy, `curl`, Python; optional mode, ports and artifacts | Builds local artifacts, runs four stub origins and Viceroy from a temporary directory, stops owned servers, and removes the directory. | | `template-cache-local-test.sh` | Viceroy, Node, OpenSSL, `curl`, `lsof`; optional ports and mode | Builds local artifacts, generates certificates and fixtures in a temporary directory, stops owned servers, and removes the directory. | | `test-cli.sh` | Rustup and the host toolchain; optional host triple | May install the selected Rust target, then runs native CLI and browser-audit tests. Cargo artifacts persist. | diff --git a/scripts/config-by-host-local-test.sh b/scripts/config-by-host-local-test.sh index a19c2be896..26de1c84f9 100755 --- a/scripts/config-by-host-local-test.sh +++ b/scripts/config-by-host-local-test.sh @@ -2,10 +2,12 @@ # # Local harness for a service that reads one app-config blob for each host. # -# Runs Trusted Server under Viceroy against three stub origins, one for each -# of two publishers and one behind the blob at the logical store ID, and -# asserts that every host is answered from its own blob and from no other. -# Everything it needs is generated into a temp directory; your +# Runs Trusted Server under Viceroy against four stub origins, one behind the +# blob at the logical store ID and one behind each other blob, and asserts +# that every host is answered from the blob its selector names and from no +# other. The selector is the `TS__CONFIG__KEY` entry in the +# `trusted_server_config` store, and Viceroy is started afresh for each value +# it takes. Everything it needs is generated into a temp directory, so your # `trusted-server.toml` is never read and your tracked `fastly.toml` is never # modified. # @@ -18,7 +20,7 @@ # Environment: # WASM_BINARY_PATH a built adapter, in place of building one # TS_BIN a built `ts`, in place of building one -# ORIGIN_PORT the first of three consecutive stub-origin ports +# ORIGIN_PORT the first of four consecutive stub-origin ports # TS_PORT the port Viceroy listens on set -euo pipefail @@ -39,12 +41,14 @@ TS_PORT="${TS_PORT:-7799}" PORT_A="$ORIGIN_PORT" PORT_B="$((ORIGIN_PORT + 1))" PORT_SHARED="$((ORIGIN_PORT + 2))" +PORT_V2="$((ORIGIN_PORT + 3))" HOST_A="a.publisher.example" HOST_B="b.publisher.example" HOST_TRIPLE="$(rustc -vV | sed -n 's/^host: //p')" -# Viceroy reports this service id, which scopes every runtime-env key. -SERVICE_ID="0000000000000000000000" -KEY_SELECTOR="EDGEZERO__SERVICES__${SERVICE_ID}__STORES__CONFIG__TRUSTED_SERVER_CONFIG__KEY" +SELECTOR_ENTRY="TS__CONFIG__KEY" +# A selector in an `edgezero_runtime_env` store, scoped by the service id +# Viceroy reports. The adapter does not read that store. +OLD_SELECTOR_ENTRY="EDGEZERO__SERVICES__0000000000000000000000__STORES__CONFIG__TRUSTED_SERVER_CONFIG__KEY" REQUEST_TIMEOUT_SECONDS="${REQUEST_TIMEOUT_SECONDS:-30}" PASS=0 @@ -96,7 +100,7 @@ PYTHON="$(command -v python3 || command -v python || true)" # A port already in use means requests would go to something else entirely, # most likely a leftover run whose config would read as a result. -for port in "$PORT_A" "$PORT_B" "$PORT_SHARED" "$TS_PORT"; do +for port in "$PORT_A" "$PORT_B" "$PORT_SHARED" "$PORT_V2" "$TS_PORT"; do if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then echo "Port $port is already in use. Stop the process, or set" >&2 echo "ORIGIN_PORT / TS_PORT to something free." >&2 @@ -123,9 +127,9 @@ if [ -z "$TS" ]; then TS="$REPO_ROOT/target/$HOST_TRIPLE/debug/ts" fi -info "Starting stub origins on :$PORT_A, :$PORT_B and :$PORT_SHARED" +info "Starting stub origins on :$PORT_A, :$PORT_B, :$PORT_SHARED and :$PORT_V2" cat > "$WORK/origins.py" <<'PYEOF' -"""Three stub publisher origins, each answering with a marker of its own. +"""Stub publisher origins, each answering with a marker of its own. A page is private and not to be stored, so no cache stands between a request and the blob that serves it, and a reused sandbox reports its counters on it. @@ -165,10 +169,10 @@ for server in servers[1:]: threading.Thread(target=server.serve_forever, daemon=True).start() servers[0].serve_forever() PYEOF -"$PYTHON" "$WORK/origins.py" "A=$PORT_A" "B=$PORT_B" "SHARED=$PORT_SHARED" \ +"$PYTHON" "$WORK/origins.py" "A=$PORT_A" "B=$PORT_B" "SHARED=$PORT_SHARED" "V2=$PORT_V2" \ > "$WORK/origins.log" 2>&1 & ORIGIN_PID=$! -for port in "$PORT_A" "$PORT_B" "$PORT_SHARED"; do +for port in "$PORT_A" "$PORT_B" "$PORT_SHARED" "$PORT_V2"; do for _ in $(seq 1 40); do curl -s -o /dev/null "http://127.0.0.1:$port/" && break sleep 0.25 @@ -184,15 +188,15 @@ cp "$REPO_ROOT/fastly.toml" "$WORK/fastly.toml" ln -s "$REPO_ROOT/crates" "$WORK/crates" cat >> "$WORK/fastly.toml" <<'SECRETSEOF' -[[local_server.secret_stores.ts_secrets]] +[[local_server.secret_stores.trusted_server_secrets]] key = "publisher_proxy_secret" data = "fictional-local-publisher-proxy-secret-value" -[[local_server.secret_stores.ts_secrets]] +[[local_server.secret_stores.trusted_server_secrets]] key = "ec_passphrase" data = "fictional-local-ec-passphrase-secret-value" -[[local_server.secret_stores.ts_secrets]] +[[local_server.secret_stores.trusted_server_secrets]] key = "handler_password" data = "fictional-local-handler-password-secret-value" SECRETSEOF @@ -215,21 +219,28 @@ push_blob() { # push_blob [--key ] --no-diff --yes "$@" >/dev/null ) } -# One blob at the logical store ID and one under each publisher's host. +# One blob at the logical store ID, one under each publisher's host, and one +# under publisher A's host with a suffix. push_blob "shared.example" "$PORT_SHARED" push_blob "$HOST_A" "$PORT_A" --key "$HOST_A" push_blob "$HOST_B" "$PORT_B" --key "$HOST_B" +push_blob "$HOST_A" "$PORT_V2" --key "${HOST_A}_v2" +# Each case starts from the pushed blobs alone. +cp "$WORK/fastly.toml" "$WORK/fastly.base.toml" -set_runtime_env() { # set_runtime_env ... +add_entries() { # add_entries ... "$PYTHON" - "$WORK/fastly.toml" "$@" <<'PYEOF' import sys -manifest, *entries = sys.argv[1:] -header = "[local_server.config_stores.edgezero_runtime_env.contents]" +manifest, store, *entries = sys.argv[1:] +header = "[local_server.config_stores.%s.contents]" % store lines = open(manifest).read().split("\n") at = [index for index, line in enumerate(lines) if line.strip() == header] -if len(at) != 1: - raise SystemExit("expected one runtime-env table in the local manifest") +if len(at) > 1: + raise SystemExit("expected one %s table in the local manifest" % header) +if not at: + lines += ["", "[local_server.config_stores.%s]" % store, 'format = "inline-toml"', header] + at = [len(lines) - 1] for entry in reversed(entries): key, value = entry.split("=", 1) lines.insert(at[0] + 1, '%s = "%s"' % (key, value)) @@ -237,6 +248,24 @@ open(manifest, "w").write("\n".join(lines)) PYEOF } +use_selector() { # use_selector [ ] + cp "$WORK/fastly.base.toml" "$WORK/fastly.toml" + if [ -n "$1" ]; then + add_entries trusted_server_config "$SELECTOR_ENTRY=$1" + fi + # The sandbox bounds sit unscoped beside the blobs. + if [ "$MODE" = "reuse" ]; then + add_entries trusted_server_config \ + "TS__SANDBOX__MAX_REQUESTS=50" \ + "TS__SANDBOX__MAX_LIFETIME_MS=120000" \ + "TS__SANDBOX__TIMEOUT_MS=10000" \ + "TS__SANDBOX__MAX_MEMORY_MIB=512" + fi + if [ "$#" -eq 3 ]; then + add_entries "$2" "$3" + fi +} + start_viceroy() { # start_viceroy VICEROY_LOG="$WORK/$1.log" # Deliberately not wrapped in a subshell: `$!` would then be the subshell's @@ -264,10 +293,18 @@ fetch() { # fetch [path], sets STATUS, BODY and HEADERS -w '%{http_code}' -H "Host: $1" "http://127.0.0.1:$TS_PORT${2:-/}" || true)" } +no_page() { # no_page , about the last response + if grep -qE "ORIGIN_(A|B|SHARED|V2)_SENTINEL" "$BODY"; then + bad "$1: the answer carries a publisher's page" + else + ok "$1: no publisher's page is served" + fi +} + served_by() { # served_by local found="" fetch "$2" - for name in A B SHARED; do + for name in A B SHARED V2; do if grep -qF "ORIGIN_${name}_SENTINEL" "$BODY"; then found="$found$name"; fi done check "$1" "$STATUS $found" "200 $3" @@ -281,33 +318,19 @@ counter() { # counter , from the last response refused() { # refused fetch "$2" check "$1" "$STATUS" "421" - if grep -qE "ORIGIN_(A|B|SHARED)_SENTINEL" "$BODY"; then - bad "$1: the refusal carries a publisher's page" - else - ok "$1: no publisher's page is served" - fi + no_page "$1" } -info "Without a host selector the service reads its one blob" -start_viceroy one-blob +info "Without a selector every host reads the blob at the logical store ID" +use_selector "" +start_viceroy no-selector served_by "a publisher's host reads the blob at the logical store ID" "$HOST_A" SHARED served_by "any other host reads it too" "unknown.example" SHARED stop_viceroy -info "With '{host}' as the key selector each host reads its own blob" -ENTRIES=("$KEY_SELECTOR={host}") -if [ "$MODE" = "reuse" ]; then - SANDBOX="EDGEZERO__SERVICES__${SERVICE_ID}__TS__SANDBOX" - ENTRIES+=( - "${SANDBOX}__MAX_REQUESTS=50" - "${SANDBOX}__MAX_LIFETIME_MS=120000" - "${SANDBOX}__TIMEOUT_MS=10000" - "${SANDBOX}__MAX_MEMORY_MIB=512" - ) -fi -set_runtime_env "${ENTRIES[@]}" +info "With '{host}' as the selector each host reads its own blob" +use_selector "{host}" start_viceroy by-host - served_by "publisher A's host reads publisher A's blob" "$HOST_A" A served_by "publisher B's host reads publisher B's blob" "$HOST_B" B served_by "publisher A again, after B" "$HOST_A" A @@ -338,6 +361,30 @@ else fi stop_viceroy +info "With '{host}_v2' each host reads the blob under its name and the suffix" +use_selector "{host}_v2" +start_viceroy suffixed +served_by "publisher A's host reads the blob under its name and the suffix" "$HOST_A" V2 +refused "a host with no suffixed blob is refused" "$HOST_B" +refused "no host is served from the blob at the logical store ID" "unknown.example" +stop_viceroy + +info "A malformed selector serves no page" +use_selector "{Host}" +start_viceroy malformed +fetch "$HOST_A" +check "a page route is answered 500" "$STATUS" "500" +no_page "a page route under a malformed selector" +fetch "$HOST_A" /health +check "the health probe still answers" "$STATUS" "200" +stop_viceroy + +info "A selector in an edgezero_runtime_env store is not read" +use_selector "" edgezero_runtime_env "$OLD_SELECTOR_ENTRY={host}" +start_viceroy old-selector +served_by "a publisher's host reads the blob at the logical store ID" "$HOST_A" SHARED +stop_viceroy + printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" if [ "$FAIL" -ne 0 ]; then echo "Viceroy log of the last run:" >&2 From 62306a42b6a2702f4506faae0ac7ac1dd299877f Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 07:54:18 +0100 Subject: [PATCH 11/18] Follow the selector design's revised tests and core comment for row 11 The design now tests entry_request directly, with entry_request_reads_a_host_that_is_not_utf8_as_no_host in place of a test through the device module, and relies on the existing config_push_parses_staging_and_rejects_explicit_key for --staging with --key, so the extra parse test is removed. Core changes only in the comment on default_config_key, so the comment on default_config_store_name is main's again. --- .../trusted-server-adapter-fastly/src/lib.rs | 25 ++++--------------- crates/trusted-server-cli/src/run.rs | 16 ------------ .../trusted-server-core/src/settings_data.rs | 8 +++--- 3 files changed, 9 insertions(+), 40 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/lib.rs b/crates/trusted-server-adapter-fastly/src/lib.rs index 417b653e3a..a436541198 100644 --- a/crates/trusted-server-adapter-fastly/src/lib.rs +++ b/crates/trusted-server-adapter-fastly/src/lib.rs @@ -1475,28 +1475,13 @@ mod tests { } #[test] - fn a_module_is_shown_no_host_where_host_is_not_utf8() { - let settings = test_settings(); - let services = build_finalize_services(&settings, Arc::new(UnavailableKvStore)); - let registered: Arc = Arc::new(MarkedDeviceModule); - let mut req = page_request(); - req.set_header(header::HOST.as_str(), HOST_NOT_UTF8); - // A selector without the placeholder serves the request without - // reading Host, so the request goes on to the module request. - stores_for_request(stores_with_selector("site_a"), &req) - .expect("should serve a fixed selector whatever the Host"); - - let signals = futures::executor::block_on(derive_device_signals( - &settings, - Some(registered), - &req, - &services, - )); + fn entry_request_reads_a_host_that_is_not_utf8_as_no_host() { + let req = request_with_host_not_utf8(); assert_eq!( - signals.platform_class.as_deref(), - Some("marked:example_width=1280:https:///article?page=2"), - "a module should be shown no host where the Host header is not UTF-8" + entry_request(&req).host(), + "", + "a module request should carry no host where Host is not UTF-8" ); } diff --git a/crates/trusted-server-cli/src/run.rs b/crates/trusted-server-cli/src/run.rs index 28db7942c7..df65653f31 100644 --- a/crates/trusted-server-cli/src/run.rs +++ b/crates/trusted-server-cli/src/run.rs @@ -618,22 +618,6 @@ mod tests { .expect_err("should reject --key with --staging; the staging key is derived"); } - #[test] - fn a_host_key_cannot_be_pushed_with_staging() { - Args::try_parse_from([ - "ts", - "config", - "push", - "--adapter", - "fastly", - "--local", - "--staging", - "--key", - "a.example", - ]) - .expect_err("should refuse a host's key with --staging"); - } - #[test] fn config_diff_parses_staging_and_rejects_explicit_key() { let args = parse(&["ts", "config", "diff", "--adapter", "fastly", "--staging"]); diff --git a/crates/trusted-server-core/src/settings_data.rs b/crates/trusted-server-core/src/settings_data.rs index c656b5f04c..1ddb41ef1b 100644 --- a/crates/trusted-server-core/src/settings_data.rs +++ b/crates/trusted-server-core/src/settings_data.rs @@ -43,10 +43,10 @@ pub fn config_key(env: &EnvConfig) -> String { /// Resolves the `EdgeZero` app-config store name from the process environment. /// -/// Native adapters such as Axum use this wrapper. Fastly opens the store under -/// the logical ID that a managed deployment links to the selected physical -/// store. Without an override, this wrapper also returns the manifest default -/// logical store ID. +/// Native adapters such as Axum use this wrapper. Fastly reads no selector at +/// runtime: it opens the store under the logical ID that a managed deployment +/// links to the selected physical store. Without an override, this wrapper +/// also returns the manifest default logical store ID. #[must_use] pub fn default_config_store_name() -> StoreName { config_store_name(&EnvConfig::from_env()) From ac6a47a8b51e9bff698f47c902219df696f6c0bb Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:26:41 +0000 Subject: [PATCH 12/18] The keyed push writes its SHA-256 in hex one byte at a time sha2 0.11, which the workspace takes from main (#1123), returns its digest as a hybrid-array Array, and Array does not implement LowerHex, so format!("{:x}", ...) no longer compiles. sha256_hex now writes each byte as two lowercase hex digits, which gives the same text as before. --- .../trusted-server-cli/src/commands/config/keyed_push.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-cli/src/commands/config/keyed_push.rs b/crates/trusted-server-cli/src/commands/config/keyed_push.rs index 8d08e48d29..831c070962 100644 --- a/crates/trusted-server-cli/src/commands/config/keyed_push.rs +++ b/crates/trusted-server-cli/src/commands/config/keyed_push.rs @@ -9,6 +9,7 @@ //! private to its crate, so the constants below copy it. The writer writes //! only the local server's store, in the `fastly.toml` the manifest names. +use std::fmt::Write as _; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; @@ -243,7 +244,12 @@ fn chunks_of(envelope_json: &str) -> impl Iterator { /// The lowercase hex SHA-256 of `text`. fn sha256_hex(text: &str) -> String { - format!("{:x}", Sha256::digest(text.as_bytes())) + let digest = Sha256::digest(text.as_bytes()); + let mut hex = String::with_capacity(digest.len() * 2); + for byte in digest { + write!(hex, "{byte:02x}").expect("should write hex digit to string"); + } + hex } /// The `fastly.toml` the manifest names for the Fastly adapter, beside the From 61e86bb106dfcd0bc70341f5f6659d44ed7d4bbe Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:28:34 +0000 Subject: [PATCH 13/18] Core names the shared policy entry and reads the file it holds --- .../trusted-server-core/src/platform/mod.rs | 5 +- .../src/platform/policy_source.rs | 115 ++++++++++++++++++ .../trusted-server-core/src/settings_data.rs | 10 +- 3 files changed, 128 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index 933754993b..a9e23a4536 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -72,7 +72,10 @@ pub use image_optimizer::{ PlatformImageOptimizerParams, PlatformImageOptimizerRegion, }; pub use kv::UnavailableKvStore; -pub use policy_source::{PlatformPolicySource, PolicyOrigin}; +pub use policy_source::{ + POLICY_KEY_PREFIX, PlatformPolicySource, PolicyEntry, PolicyOrigin, policy_entry_text, + policy_key, +}; pub use template_assembly::{ PlatformTemplateAssembler, TemplateAssemblyError, UnavailableTemplateAssembler, contains_publisher_esi_directive, diff --git a/crates/trusted-server-core/src/platform/policy_source.rs b/crates/trusted-server-core/src/platform/policy_source.rs index 60313c55e1..d521aa1840 100644 --- a/crates/trusted-server-core/src/platform/policy_source.rs +++ b/crates/trusted-server-core/src/platform/policy_source.rs @@ -3,12 +3,25 @@ //! On most adapters the push writes the file into the domain's blob, so the //! runtime loader takes it from there. An adapter that keeps policy files //! somewhere of its own reads them through a [`PlatformPolicySource`]. +//! +//! A blob for one host carries no file. Each file is stored once, in a shared +//! entry under [`policy_key`], however many hosts' blobs pin it, as an +//! envelope whose data is a [`PolicyEntry`]. +use edgezero_core::blob_envelope::BlobEnvelope; use error_stack::Report; +use serde::{Deserialize, Serialize}; use super::PlatformError; use crate::permissions::PolicyReference; +/// What the key of a shared policy entry starts with, before the file's +/// SHA-256. +/// +/// No host name holds an underscore, so no `Host` header names a policy entry +/// under a selector that is a host alone. +pub const POLICY_KEY_PREFIX: &str = "permissions_"; + /// Reads the permission policy file a domain's settings name. pub trait PlatformPolicySource: Send + Sync { /// The text of the file `reference` names. @@ -30,3 +43,105 @@ pub enum PolicyOrigin<'a> { /// A source of the adapter's own. Any text the blob carries is not used. Source(&'a dyn PlatformPolicySource), } + +/// The config store key of the shared entry holding the policy file whose +/// SHA-256 is `sha256`. +#[must_use] +pub fn policy_key(sha256: &str) -> String { + format!("{POLICY_KEY_PREFIX}{sha256}") +} + +/// The data of a shared policy entry's envelope. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PolicyEntry { + /// The file's stem. + pub name: String, + /// The file itself. + pub text: String, +} + +/// The policy file a shared entry holds, from the entry's value as the store +/// returns it once any chunks are put back together. +/// +/// The runtime loader checks the file against the digest the host's blob +/// pins. +/// +/// # Errors +/// +/// [`PlatformError::PolicySource`] when the value is not an envelope, fails +/// the envelope's check of its own data, or does not hold a policy entry. +pub fn policy_entry_text(value: &str) -> Result> { + let refuse = |reason: String| Report::new(PlatformError::PolicySource).attach(reason); + let envelope: BlobEnvelope = serde_json::from_str(value) + .map_err(|error| refuse(format!("the policy entry is not an envelope: {error}")))?; + envelope.verify().map_err(|error| { + refuse(format!( + "the policy entry fails its envelope's check: {error}" + )) + })?; + let entry: PolicyEntry = serde_json::from_value(envelope.into_data()) + .map_err(|error| refuse(format!("the envelope holds no policy entry: {error}")))?; + Ok(entry.text) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::permissions::{SAMPLE_POLICY_NAME, SAMPLE_POLICY_TEXT, policy_sha256}; + + fn envelope_of(data: serde_json::Value) -> String { + serde_json::to_string(&BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_owned())) + .expect("should serialize the envelope") + } + + fn sample_entry() -> String { + envelope_of( + serde_json::to_value(PolicyEntry { + name: SAMPLE_POLICY_NAME.to_owned(), + text: SAMPLE_POLICY_TEXT.to_owned(), + }) + .expect("should serialize the entry"), + ) + } + + #[test] + fn a_policy_key_is_the_prefix_then_the_file_s_digest() { + let sha256 = policy_sha256(SAMPLE_POLICY_TEXT); + + assert_eq!(policy_key(&sha256), format!("permissions_{sha256}")); + } + + #[test] + fn an_entry_gives_back_the_file_it_holds() { + let text = policy_entry_text(&sample_entry()).expect("should read the entry"); + + assert_eq!(text, SAMPLE_POLICY_TEXT); + } + + #[test] + fn a_value_that_is_not_a_checked_policy_entry_is_refused() { + let mut changed: serde_json::Value = + serde_json::from_str(&sample_entry()).expect("should parse the entry"); + changed["data"]["text"] = json!("groups: {}\n"); + + for (value, reason) in [ + ("not an envelope".to_owned(), "is not an envelope"), + (changed.to_string(), "fails its envelope's check"), + ( + envelope_of(json!({ "publisher": { "domain": "example.com" } })), + "holds no policy entry", + ), + ] { + let error = policy_entry_text(&value).expect_err("the value should be refused"); + + assert!( + matches!(error.current_context(), PlatformError::PolicySource), + "{error:?}" + ); + assert!(format!("{error:?}").contains(reason), "{error:?}"); + } + } +} diff --git a/crates/trusted-server-core/src/settings_data.rs b/crates/trusted-server-core/src/settings_data.rs index 43b1d0f989..f915cfe1ff 100644 --- a/crates/trusted-server-core/src/settings_data.rs +++ b/crates/trusted-server-core/src/settings_data.rs @@ -144,7 +144,15 @@ fn read_config_entry( .change_context(TrustedServerError::Configuration { message }) } -fn resolve_fastly_chunk_pointer( +/// The value a Fastly config store entry holds, put back together from its +/// chunks when the entry is a pointer to them, as a long value is written, +/// and the value itself otherwise. +/// +/// # Errors +/// +/// [`TrustedServerError::Configuration`] when a pointer is malformed, or a +/// chunk cannot be read or has the wrong length or SHA-256. +pub fn resolve_fastly_chunk_pointer( config_store: &dyn PlatformConfigStore, store_name: &StoreName, value: &str, From 30d35805a2dc48fcb137d50a07a9f3c213976be3 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:28:35 +0000 Subject: [PATCH 14/18] A host's blob reads its policy file from the shared entry --- .../trusted-server-adapter-fastly/src/app.rs | 37 ++++- .../src/platform.rs | 131 +++++++++++++++++- 2 files changed, 160 insertions(+), 8 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 1a329a6e3f..31a2983688 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -161,7 +161,7 @@ use trusted_server_device_fastly::FastlyHostSignals; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; use crate::platform::{ FastlyPlatformBackend, FastlyPlatformConfigStore, FastlyPlatformGeo, FastlyPlatformHttpClient, - FastlyPlatformSecretStore, UnavailableKvStore, + FastlyPlatformSecretStore, FastlyPolicyEntries, UnavailableKvStore, }; // --------------------------------------------------------------------------- @@ -245,6 +245,12 @@ pub(crate) fn build_state( pub(crate) fn load_settings_from_config_store( stores: &RuntimeStoreConfig, ) -> Result> { + let entries = FastlyPolicyEntries::new(&FastlyPlatformConfigStore, &stores.config_store_name); + let policy_origin = if blob_carries_its_policy(&stores.config_key) { + PolicyOrigin::Blob + } else { + PolicyOrigin::Source(&entries) + }; // The settings are validated as they load, so the stock builders and the // ones a deployment registered are supplied here as well as to the state // build. Without them a `[demand]` or `[ad-server]` name one of them @@ -255,11 +261,19 @@ pub(crate) fn load_settings_from_config_store( &stores.config_store_name, &stores.config_key, &stores.secret_store_name, - PolicyOrigin::Blob, + policy_origin, &trusted_server_modules::builders_with(registered_integrations()), ) } +/// Whether the blob under `config_key` carries its policy file, which a blob +/// at the logical store ID does, as `EdgeZero`'s push writes it. The keyed +/// push writes every other key, and its blobs name a shared policy entry +/// instead. +fn blob_carries_its_policy(config_key: &str) -> bool { + config_key == DEFAULT_CONFIG_STORE_ID +} + /// Build the application state from explicit settings. /// /// # Errors @@ -1716,11 +1730,11 @@ mod tests { use std::time::Duration; use super::{ - AppContext, AppState, AuctionDispatch, EcContext, EdgeCacheHeader, EidSyncSource, - HandlerFuture, NAMED_ROUTES, NamedRouteHandler, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, - RuntimeStoreConfig, TrustedServerApp, build_orchestrator_with_plan, - build_per_request_services, build_state_from_settings, handle_publisher_request, - publisher_response_into_streaming_response, startup_error_router, + AppContext, AppState, AuctionDispatch, DEFAULT_CONFIG_STORE_ID, EcContext, EdgeCacheHeader, + EidSyncSource, HandlerFuture, NAMED_ROUTES, NamedRouteHandler, PAGE_BIDS_LEGACY_PATH, + PAGE_BIDS_PATH, RuntimeStoreConfig, TrustedServerApp, blob_carries_its_policy, + build_orchestrator_with_plan, build_per_request_services, build_state_from_settings, + handle_publisher_request, publisher_response_into_streaming_response, startup_error_router, }; use base64::Engine as _; use bytes::Bytes; @@ -1888,6 +1902,15 @@ mod tests { TrustedServerApp::routes_for_state(&state) } + #[test] + fn only_the_blob_at_the_logical_store_id_carries_its_policy_file() { + assert!(blob_carries_its_policy(DEFAULT_CONFIG_STORE_ID)); + assert!( + !blob_carries_its_policy("a.publisher.example"), + "a blob the keyed push wrote for a host names a shared entry" + ); + } + #[test] fn each_request_is_given_the_policy_its_domain_loaded() { let policy = trusted_server_core::test_support::policies::open(); diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index 95828637c0..7892402f94 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -8,15 +8,18 @@ use std::io::Read as _; use std::net::IpAddr; use crate::backend::BackendConfig; +use trusted_server_core::permissions::PolicyReference; pub(crate) use trusted_server_core::platform::UnavailableKvStore; use trusted_server_core::platform::{ BackendNamingPolicy, ClientInfo, PlatformBackend, PlatformBackendSpec, PlatformCacheIntent, PlatformConfigStore, PlatformError, PlatformHttpClient, PlatformHttpRequest, PlatformImageOptimizerCrop, PlatformImageOptimizerCropMode, PlatformImageOptimizerOptions, PlatformImageOptimizerParams, PlatformImageOptimizerRegion, PlatformPendingRequest, - PlatformResponse, PlatformSecretStore, PlatformSelectResult, StoreId, StoreName, + PlatformPolicySource, PlatformResponse, PlatformSecretStore, PlatformSelectResult, StoreId, + StoreName, policy_entry_text, policy_key, }; use trusted_server_core::settings::TrustedClientIpConfig; +use trusted_server_core::settings_data::resolve_fastly_chunk_pointer; // --------------------------------------------------------------------------- // FastlyPlatformConfigStore @@ -37,6 +40,48 @@ use trusted_server_core::settings::TrustedClientIpConfig; /// the open handle so that cost is negligible. pub struct FastlyPlatformConfigStore; +/// The shared policy entries of a config store, from which a blob the keyed +/// push wrote for a host, which carries no policy file, reads the file it +/// pins. +/// +/// An entry is read as a blob is, through the store and core's chunk reader, +/// and the runtime loader checks the file against the blob's digest. +pub(crate) struct FastlyPolicyEntries<'a> { + config_store: &'a dyn PlatformConfigStore, + store_name: &'a StoreName, +} + +impl<'a> FastlyPolicyEntries<'a> { + /// The entries of the store `store_name` names, read through + /// `config_store`. + pub(crate) fn new( + config_store: &'a dyn PlatformConfigStore, + store_name: &'a StoreName, + ) -> Self { + Self { + config_store, + store_name, + } + } +} + +impl PlatformPolicySource for FastlyPolicyEntries<'_> { + fn policy_text(&self, reference: &PolicyReference) -> Result> { + let key = policy_key(&reference.sha256); + let value = self + .config_store + .get(self.store_name, &key) + .change_context(PlatformError::PolicySource) + .attach(format!("the policy entry `{key}` cannot be read"))?; + let entry = resolve_fastly_chunk_pointer(self.config_store, self.store_name, &value) + .change_context(PlatformError::PolicySource) + .attach(format!( + "the chunks of the policy entry `{key}` cannot be read" + ))?; + policy_entry_text(&entry) + } +} + impl PlatformConfigStore for FastlyPlatformConfigStore { fn get(&self, store_name: &StoreName, key: &str) -> Result> { let name = store_name.as_ref(); @@ -748,6 +793,90 @@ mod tests { const PEER_IP: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 9)); + /// Config store entries held in memory, as a deployed store holds them. + struct EntriesStore(std::collections::BTreeMap); + + impl PlatformConfigStore for EntriesStore { + fn get(&self, _store_name: &StoreName, key: &str) -> Result> { + self.0 + .get(key) + .cloned() + .ok_or_else(|| Report::new(PlatformError::ConfigStore).attach("not found")) + } + + fn put( + &self, + _store_id: &StoreId, + _key: &str, + _value: &str, + ) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn delete(&self, _store_id: &StoreId, _key: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + } + + /// The reference a host's blob carries for the repository's sample. + fn sample_reference() -> PolicyReference { + use trusted_server_core::permissions::{ + SAMPLE_POLICY_NAME, SAMPLE_POLICY_TEXT, policy_sha256, + }; + + PolicyReference { + policy: SAMPLE_POLICY_NAME.to_owned(), + sha256: policy_sha256(SAMPLE_POLICY_TEXT), + } + } + + #[test] + fn a_host_blob_reads_its_policy_file_from_the_shared_entry() { + use trusted_server_core::permissions::{SAMPLE_POLICY_NAME, SAMPLE_POLICY_TEXT}; + use trusted_server_core::platform::PolicyEntry; + + let reference = sample_reference(); + let entry = edgezero_core::blob_envelope::BlobEnvelope::new( + serde_json::to_value(PolicyEntry { + name: SAMPLE_POLICY_NAME.to_owned(), + text: SAMPLE_POLICY_TEXT.to_owned(), + }) + .expect("should serialize the entry"), + "2026-01-01T00:00:00Z".to_owned(), + ); + let store = EntriesStore(std::collections::BTreeMap::from([( + policy_key(&reference.sha256), + serde_json::to_string(&entry).expect("should serialize the envelope"), + )])); + let store_name = StoreName::from("trusted_server_config"); + + let text = FastlyPolicyEntries::new(&store, &store_name) + .policy_text(&reference) + .expect("should read the policy file from its entry"); + + assert_eq!(text, SAMPLE_POLICY_TEXT); + } + + #[test] + fn a_host_blob_whose_policy_entry_is_missing_is_refused_naming_the_entry() { + let reference = sample_reference(); + let store = EntriesStore(std::collections::BTreeMap::new()); + let store_name = StoreName::from("trusted_server_config"); + + let error = FastlyPolicyEntries::new(&store, &store_name) + .policy_text(&reference) + .expect_err("a missing entry should be refused"); + + assert!( + matches!(error.current_context(), PlatformError::PolicySource), + "{error:?}" + ); + assert!( + format!("{error:?}").contains(&policy_key(&reference.sha256)), + "the reason should name the entry: {error:?}" + ); + } + fn trusted_client_ip_config() -> TrustedClientIpConfig { TrustedClientIpConfig { ip_header: "fastly-client-ip".to_owned(), From c36504025dc85203e14815bf03431e461841c5ef Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:28:36 +0000 Subject: [PATCH 15/18] The keyed push keeps each policy file once, under its digest --- crates/trusted-server-cli/src/app_config.rs | 3 +- .../src/commands/config/keyed_push.rs | 282 ++++++++++++++++-- .../src/commands/config/policy.rs | 30 +- crates/trusted-server-cli/src/run.rs | 2 +- .../tests/config_push_by_key.rs | 196 +++++++++++- 5 files changed, 455 insertions(+), 58 deletions(-) diff --git a/crates/trusted-server-cli/src/app_config.rs b/crates/trusted-server-cli/src/app_config.rs index 8b78b4089c..d4d1386ab1 100644 --- a/crates/trusted-server-cli/src/app_config.rs +++ b/crates/trusted-server-cli/src/app_config.rs @@ -157,8 +157,7 @@ fn load_settings_with_env_overlay( }) } -/// `explicit`, or else `.toml` beside the manifest. -pub(crate) fn resolve_app_config_path( +fn resolve_app_config_path( explicit: Option<&Path>, manifest_path: &Path, app_name: &str, diff --git a/crates/trusted-server-cli/src/commands/config/keyed_push.rs b/crates/trusted-server-cli/src/commands/config/keyed_push.rs index 831c070962..198f6cded5 100644 --- a/crates/trusted-server-cli/src/commands/config/keyed_push.rs +++ b/crates/trusted-server-cli/src/commands/config/keyed_push.rs @@ -8,7 +8,13 @@ //! runtime reads it as it reads any pushed blob. `EdgeZero` keeps that format //! private to its crate, so the constants below copy it. The writer writes //! only the local server's store, in the `fastly.toml` the manifest names. +//! +//! A host's blob names its permission policy file and pins the file's +//! SHA-256, and carries no copy of the file. Each file `--permissions` +//! supplies is kept once, in a shared entry under its digest, written before +//! the blob, and an entry that already holds the file is not written again. +use std::collections::BTreeMap; use std::fmt::Write as _; use std::fs; use std::io::Write; @@ -18,13 +24,20 @@ use edgezero_cli::args::{ConfigPushArgs, ConfigValidateArgs}; use edgezero_core::app_config::{self, AppConfigLoadOptions}; use edgezero_core::blob_envelope::BlobEnvelope; use edgezero_core::manifest::{Manifest, ManifestLoader}; +use error_stack::Report; use serde::Serialize; use sha2::{Digest as _, Sha256}; use toml_edit::{DocumentMut, Item, Table}; use trusted_server_core::config::TrustedServerAppConfig; use trusted_server_core::config_payload::DEFAULT_CONFIG_STORE_ID; +use trusted_server_core::permissions::policy_sha256; +use trusted_server_core::platform::{ + PlatformConfigStore, PlatformError, PolicyEntry, StoreId, StoreName, policy_entry_text, + policy_key, +}; +use trusted_server_core::settings_data::resolve_fastly_chunk_pointer; -use crate::app_config::resolve_app_config_path; +use super::policy::{self, PolicyText, PushArgs}; /// The adapter whose keys this writer writes. const ADAPTER: &str = "fastly"; @@ -78,38 +91,75 @@ pub(crate) fn own_key(args: &ConfigPushArgs) -> Option<&str> { .filter(|key| *key != DEFAULT_CONFIG_STORE_ID) } -/// Pushes the blob `args` names under `key`, into the local server's store. +/// Pushes the blob `args` names under `key`, into the local server's store, +/// with each policy file `--permissions` supplies in its shared entry. /// -/// Every key is checked before anything is written, and a long blob's -/// chunks are written before the pointer to them. `--dry-run` lists the keys -/// and writes nothing. +/// The settings are checked as the plain push checks them, and the policy +/// file they name must be among the files supplied. Every key is checked +/// before anything is written. Each policy entry is written before the blob, +/// and a long value's chunks before the pointer to them. `--dry-run` lists +/// the keys and writes nothing. /// /// # Errors /// -/// Returns a message when the push is not `--local`, the blob fails -/// validation, a key is refused, or the local manifest cannot be read or -/// written. -pub(crate) fn run(args: &ConfigPushArgs, key: &str, out: &mut dyn Write) -> Result<(), String> { - if !args.local { +/// Returns a message when the push is not `--local`, the settings or the +/// policy files are refused, the blob fails validation, a key is refused, or +/// the local manifest cannot be read or written. +pub(crate) fn run(args: &PushArgs, key: &str, out: &mut dyn Write) -> Result<(), String> { + let push = &args.edgezero; + if !push.local { return Err(format!( "`--key {key}` can be pushed only with `--local` on Fastly. Write a live \ store's entries with `fastly config-store-entry update`." )); } - let loader = ManifestLoader::from_path(&args.manifest) - .map_err(|error| format!("failed to load {}: {error}", args.manifest.display()))?; + let loader = ManifestLoader::from_path(&push.manifest) + .map_err(|error| format!("failed to load {}: {error}", push.manifest.display()))?; let manifest = loader.manifest(); - let envelope = envelope(args, manifest)?; + // The blob pins the file it names and carries no copy, because the file + // is kept once, in its shared entry. + let prepared = policy::prepare( + &push.manifest, + push.app_config.as_deref(), + push.no_env, + &args.policy.files, + PolicyText::Omitted, + )?; + let envelope = envelope(&push.manifest, &prepared.file, manifest)?; let envelope_json = serde_json::to_string(&envelope) .map_err(|error| format!("failed to serialize the envelope: {error}"))?; - let entries = entries(key, &envelope_json, &envelope.sha256)?; - let fastly_toml = local_manifest(&args.manifest, manifest)?; + let blob = entries(key, &envelope_json, &envelope.sha256)?; + let policies = prepared + .files + .iter() + .map(|file| policy_entries(&file.name, &file.text)) + .collect::, _>>()?; + let fastly_toml = local_manifest(&push.manifest, manifest)?; + let stored = local_entries(&fastly_toml)?; - let verb = if args.dry_run { "would write" } else { "wrote" }; - if !args.dry_run { - write_local(&fastly_toml, &entries)?; + let (kept, missing): (Vec<_>, Vec<_>) = policies + .iter() + .partition(|policy| holds_policy(&stored, &policy.key, &policy.sha256)); + let written: Vec<(String, String)> = missing + .iter() + .flat_map(|policy| policy.entries.iter().cloned()) + .chain(blob) + .collect(); + + let verb = if push.dry_run { "would write" } else { "wrote" }; + if !push.dry_run { + write_local(&fastly_toml, &written)?; + } + let output_failed = |error: std::io::Error| format!("failed to write command output: {error}"); + for policy in kept { + writeln!( + out, + "kept `{}`, which already holds the policy `{}`", + policy.key, policy.name + ) + .map_err(output_failed)?; } - for (entry_key, value) in &entries { + for (entry_key, value) in &written { writeln!( out, "{verb} `{entry_key}` ({} bytes) to \ @@ -117,37 +167,121 @@ pub(crate) fn run(args: &ConfigPushArgs, key: &str, out: &mut dyn Write) -> Resu value.len(), fastly_toml.display() ) - .map_err(|error| format!("failed to write command output: {error}"))?; + .map_err(output_failed)?; } Ok(()) } -/// The blob `args` names, validated as `ts config validate` validates it and -/// wrapped in `EdgeZero`'s envelope. -fn envelope(args: &ConfigPushArgs, manifest: &Manifest) -> Result { +/// The blob in the settings file `app_config`, validated as +/// `ts config validate` validates it and wrapped in `EdgeZero`'s envelope. +/// +/// The file is one [`policy::prepare`] wrote, with the `TRUSTED_SERVER__` +/// overlay already applied, so it is read without the overlay. +fn envelope( + manifest_path: &Path, + app_config: &Path, + manifest: &Manifest, +) -> Result { let mut validate = ConfigValidateArgs::default(); - validate.app_config.clone_from(&args.app_config); - validate.manifest.clone_from(&args.manifest); - validate.no_env = args.no_env; + validate.app_config = Some(app_config.to_path_buf()); + validate.manifest = manifest_path.to_path_buf(); + validate.no_env = true; edgezero_cli::run_config_validate_typed::(&validate)?; let app_name = manifest.app.name.as_deref().ok_or_else(|| { format!( "{} has no [app].name; cannot resolve the app config", - args.manifest.display() + manifest_path.display() ) })?; - let path = resolve_app_config_path(args.app_config.as_deref(), &args.manifest, app_name); let mut options = AppConfigLoadOptions::default(); - options.env_overlay = !args.no_env; + options.env_overlay = false; let typed = app_config::deserialize_app_config_with_options::( - &path, app_name, &options, + app_config, app_name, &options, ) - .map_err(|error| format!("failed to load {}: {error}", path.display()))?; + .map_err(|error| format!("failed to load {}: {error}", app_config.display()))?; let data = serde_json::to_value(&typed) .map_err(|error| format!("failed to serialize the app config: {error}"))?; - let generated_at = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - Ok(BlobEnvelope::new(data, generated_at)) + Ok(BlobEnvelope::new(data, generated_at())) +} + +/// The time an envelope is made, as `EdgeZero` stamps one. +fn generated_at() -> String { + chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) +} + +/// One policy file, as the entries that hold it under its shared key. +struct PolicyEntries { + /// The file's stem. + name: String, + /// The file's SHA-256. + sha256: String, + /// The shared key, `permissions_` and the file's SHA-256. + key: String, + /// The envelope, or its chunks followed by the pointer to them. + entries: Vec<(String, String)>, +} + +/// The entries that hold the policy file `name` with the contents `text` +/// under its shared key, an envelope whose data is the file's name and the +/// file, split as a long blob is. +fn policy_entries(name: &str, text: &str) -> Result { + let sha256 = policy_sha256(text); + let key = policy_key(&sha256); + let data = serde_json::to_value(PolicyEntry { + name: name.to_owned(), + text: text.to_owned(), + }) + .map_err(|error| format!("failed to serialize the policy entry for `{name}`: {error}"))?; + let envelope = BlobEnvelope::new(data, generated_at()); + let envelope_json = serde_json::to_string(&envelope) + .map_err(|error| format!("failed to serialize the policy entry for `{name}`: {error}"))?; + let entries = entries(&key, &envelope_json, &envelope.sha256)?; + Ok(PolicyEntries { + name: name.to_owned(), + sha256, + key, + entries, + }) +} + +/// Whether `stored` holds the policy file whose SHA-256 is `sha256` under +/// `key`, read back as the runtime reads it, chunks included. An entry that +/// cannot be read back, or holds another file, does not. +fn holds_policy(stored: &BTreeMap, key: &str, sha256: &str) -> bool { + let store = StoredEntries(stored); + let store_name = StoreName::from(DEFAULT_CONFIG_STORE_ID); + stored + .get(key) + .and_then(|value| resolve_fastly_chunk_pointer(&store, &store_name, value).ok()) + .and_then(|value| policy_entry_text(&value).ok()) + .is_some_and(|text| policy_sha256(&text) == sha256) +} + +/// Entries already read from a store, for the chunk reader to read chunks +/// from. +struct StoredEntries<'a>(&'a BTreeMap); + +impl PlatformConfigStore for StoredEntries<'_> { + fn get(&self, _store_name: &StoreName, key: &str) -> Result> { + self.0 + .get(key) + .cloned() + .ok_or_else(|| Report::new(PlatformError::ConfigStore).attach("not found")) + } + + fn put( + &self, + _store_id: &StoreId, + _key: &str, + _value: &str, + ) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn delete(&self, _store_id: &StoreId, _key: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } } /// The entries that hold `envelope_json` under `key`, in the order they are @@ -271,6 +405,31 @@ fn local_manifest(manifest_path: &Path, manifest: &Manifest) -> Result Result, String> { + let raw = fs::read_to_string(fastly_toml) + .map_err(|error| format!("failed to read {}: {error}", fastly_toml.display()))?; + // The parse error is left out, because it can quote a stored value. + let document: DocumentMut = raw + .parse() + .map_err(|_error| format!("failed to parse {} as TOML", fastly_toml.display()))?; + let contents = document + .get("local_server") + .and_then(|item| item.get("config_stores")) + .and_then(|item| item.get(DEFAULT_CONFIG_STORE_ID)) + .and_then(|item| item.get("contents")) + .and_then(Item::as_table_like); + Ok(contents + .map(|table| { + table + .iter() + .filter_map(|(key, value)| Some((key.to_owned(), value.as_str()?.to_owned()))) + .collect() + }) + .unwrap_or_default()) +} + /// Writes `entries` into the local server's logical config store in /// `fastly_toml`, keeping every other entry. fn write_local(fastly_toml: &Path, entries: &[(String, String)]) -> Result<(), String> { @@ -324,6 +483,63 @@ fn local_contents(document: &mut DocumentMut) -> Option<&mut Table> { mod tests { use super::*; + /// A valid policy whose top node grants everything. + const OPEN_POLICY: &str = "groups:\n open:\n default: granted\nrules:\n group: open\n jurisdiction: non-regulated\n"; + + /// A valid policy whose top node grants nothing without a signal. + const STRICT_POLICY: &str = "groups:\n strict:\n default: requires_signal\nrules:\n group: strict\n jurisdiction: gdpr\n"; + + /// The repetitions of a comment line that make a policy file too long for + /// one entry. + const PADDING_LINES: usize = 1_200; + + #[test] + fn a_policy_file_is_kept_under_its_digest_and_read_back_whole() { + let text = format!("{OPEN_POLICY}{}", "# padding\n".repeat(PADDING_LINES)); + + let policy = policy_entries("open", &text).expect("should build the policy's entries"); + + assert_eq!(policy.key, format!("permissions_{}", policy_sha256(&text))); + assert!( + policy.entries.len() > 2, + "a long file should be kept as chunks and a pointer" + ); + assert_eq!( + policy.entries.last().map(|(key, _)| key.as_str()), + Some(policy.key.as_str()), + "the pointer should be written last" + ); + let stored: BTreeMap = policy.entries.iter().cloned().collect(); + assert!( + holds_policy(&stored, &policy.key, &policy.sha256), + "the entries should read back as the file" + ); + } + + #[test] + fn an_entry_missing_unreadable_or_holding_another_file_is_written_again() { + let open = policy_entries("open", OPEN_POLICY).expect("should build the open entries"); + let strict = + policy_entries("strict", STRICT_POLICY).expect("should build the strict entries"); + let garbled = BTreeMap::from([(open.key.clone(), "not an envelope".to_owned())]); + let other: BTreeMap = strict + .entries + .iter() + .map(|(_, value)| (open.key.clone(), value.clone())) + .collect(); + + for (stored, case) in [ + (BTreeMap::new(), "a missing entry"), + (garbled, "an entry that cannot be read back"), + (other, "an entry holding another file"), + ] { + assert!( + !holds_policy(&stored, &open.key, &open.sha256), + "{case} should be written again" + ); + } + } + #[test] fn a_long_envelope_is_split_at_character_boundaries_with_the_pointer_last() { // After one byte, two-byte characters straddle the chunk limit, so a diff --git a/crates/trusted-server-cli/src/commands/config/policy.rs b/crates/trusted-server-cli/src/commands/config/policy.rs index 11e6cde13b..f443f79ee8 100644 --- a/crates/trusted-server-cli/src/commands/config/policy.rs +++ b/crates/trusted-server-cli/src/commands/config/policy.rs @@ -213,10 +213,12 @@ pub fn run_diff(args: DiffArgs) -> Result { /// Whether the settings handed to `EdgeZero` carry the policy file itself. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum PolicyText { +pub(crate) enum PolicyText { /// For the adapters that read the policy from the blob. Included, - /// For Axum, which reads the file from its folder, and for validation. + /// For Axum, which reads the file from its folder, for a blob the keyed + /// push writes for a host, which reads it from the shared entry, and for + /// validation. Omitted, } @@ -232,22 +234,26 @@ impl PolicyText { /// The settings `EdgeZero` is handed, with the policy they name. #[derive(Debug)] -struct PreparedSettings { +pub(crate) struct PreparedSettings { /// The settings file, closed so `EdgeZero` can read it, and removed /// when this is dropped. - file: TempPath, + pub(crate) file: TempPath, /// The policy's name. - policy: String, + pub(crate) policy: String, /// The SHA-256 of the policy file. - sha256: String, + pub(crate) sha256: String, + /// Every file `--permissions` supplied, the named one among them. + pub(crate) files: Vec, } /// A policy file `--permissions` supplied, read and checked. #[derive(Debug)] -struct PolicyFile { - name: String, +pub(crate) struct PolicyFile { + /// The file's stem. + pub(crate) name: String, path: PathBuf, - text: String, + /// The file itself. + pub(crate) text: String, } /// The settings a command reads, loaded with the overlay unless `no_env` is @@ -286,7 +292,7 @@ fn load_settings( /// Loads the settings, checks the policy files, and writes the settings with /// the named file's SHA-256, and its text when `text` asks for it, to a /// temporary file. -fn prepare( +pub(crate) fn prepare( manifest: &Path, app_config: Option<&Path>, no_env: bool, @@ -305,6 +311,7 @@ fn prepare( let files = read_policy_files(files)?; let policy = named_policy(&settings.permissions, &files)?; let sha256 = policy_sha256(&policy.text); + let name = policy.name.clone(); let table = document .get_mut(PERMISSIONS_TABLE) @@ -325,8 +332,9 @@ fn prepare( Ok(PreparedSettings { file: write_settings(&document)?, - policy: policy.name.clone(), + policy: name, sha256, + files, }) } diff --git a/crates/trusted-server-cli/src/run.rs b/crates/trusted-server-cli/src/run.rs index 27b838825a..2a55ca7e15 100644 --- a/crates/trusted-server-cli/src/run.rs +++ b/crates/trusted-server-cli/src/run.rs @@ -149,7 +149,7 @@ fn dispatch(args: Args) -> Result { Some(key) => { let stdout = std::io::stdout(); let mut out = stdout.lock(); - keyed_push::run(&args.edgezero, key, &mut out) + keyed_push::run(&args, key, &mut out) } None => policy::run_push(args), } diff --git a/crates/trusted-server-cli/tests/config_push_by_key.rs b/crates/trusted-server-cli/tests/config_push_by_key.rs index 41a35b36b9..6b7d25a144 100644 --- a/crates/trusted-server-cli/tests/config_push_by_key.rs +++ b/crates/trusted-server-cli/tests/config_push_by_key.rs @@ -11,17 +11,47 @@ use tempfile::TempDir; use toml_edit::DocumentMut; use trusted_server_core::config_payload::DEFAULT_CONFIG_STORE_ID; use trusted_server_core::error::TrustedServerError; +use trusted_server_core::permissions::{PolicyReference, policy_sha256}; use trusted_server_core::platform::{ - PlatformConfigStore, PlatformError, PlatformSecretStore, PolicyOrigin, StoreId, StoreName, + PlatformConfigStore, PlatformError, PlatformPolicySource, PlatformSecretStore, PolicyOrigin, + StoreId, StoreName, policy_entry_text, policy_key, }; use trusted_server_core::settings::Settings; -use trusted_server_core::settings_data::get_settings_from_config_store_with; +use trusted_server_core::settings_data::{ + get_settings_from_config_store_with, resolve_fastly_chunk_pointer, +}; const HOST: &str = "a.publisher.example"; +/// A second host, whose blob names the same policy file as [`HOST`]'s. +const OTHER_HOST: &str = "b.publisher.example"; + +/// The policy file each push supplies, in the project. +const POLICY_FILE: &str = "sample.yaml"; + +/// The repository's sample, which the project's app config names. +const POLICY_TEXT: &str = include_str!("../../../config/permissions/sample.yaml"); + /// A project holding the repository's manifests, with only the Fastly -/// adapter declared, and the example app config. +/// adapter declared, the example app config naming the repository's sample +/// policy, and that policy's file. fn project() -> TempDir { + let directory = project_naming_no_policy(); + let path = directory.path().join("trusted-server.toml"); + let app_config = fs::read_to_string(&path).expect("should read the app config"); + fs::write( + &path, + format!("{app_config}\n[permissions]\npolicy = \"sample\"\n"), + ) + .expect("should name the policy"); + fs::write(directory.path().join(POLICY_FILE), POLICY_TEXT) + .expect("should write the policy file"); + directory +} + +/// A project holding the repository's manifests, with only the Fastly +/// adapter declared, and the example app config, which names no policy. +fn project_naming_no_policy() -> TempDir { let directory = tempfile::tempdir().expect("should create a temporary project"); let mut manifest: toml::Value = toml::from_str(include_str!("../../../edgezero.toml")) .expect("should parse the repository manifest"); @@ -170,18 +200,41 @@ impl PlatformSecretStore for FixedSecretStore { } } -/// Reads the blob at `key` the way the runtime does, chunks included. +/// Reads a host's policy file from its shared entry, as the Fastly adapter +/// does, chunks included. +struct SharedPolicyEntries<'a>(&'a MemoryConfigStore); + +impl PlatformPolicySource for SharedPolicyEntries<'_> { + fn policy_text(&self, reference: &PolicyReference) -> Result> { + let store_name = StoreName::from(DEFAULT_CONFIG_STORE_ID); + let value = self.0.get(&store_name, &policy_key(&reference.sha256))?; + let entry = resolve_fastly_chunk_pointer(self.0, &store_name, &value).map_err(|error| { + Report::new(PlatformError::PolicySource).attach(format!("{error:?}")) + })?; + policy_entry_text(&entry) + } +} + +/// Reads the blob at `key` the way the runtime does, chunks included, with a +/// host's policy file read from its shared entry. fn read_back( entries: &[(String, String)], key: &str, ) -> Result> { + let store = MemoryConfigStore(entries.iter().cloned().collect()); + let shared = SharedPolicyEntries(&store); + let origin = if key == DEFAULT_CONFIG_STORE_ID { + PolicyOrigin::Blob + } else { + PolicyOrigin::Source(&shared) + }; get_settings_from_config_store_with( - &MemoryConfigStore(entries.iter().cloned().collect()), + &store, &FixedSecretStore, &StoreName::from(DEFAULT_CONFIG_STORE_ID), key, &StoreName::from("trusted_server_secrets"), - PolicyOrigin::Blob, + origin, &trusted_server_modules::builders(), ) } @@ -190,7 +243,10 @@ fn read_back( fn a_local_push_of_a_host_key_writes_the_local_store() { let project = project(); - let output = push(&project, &["--local", "--key", HOST]); + let output = push( + &project, + &["--local", "--key", HOST, "--permissions", POLICY_FILE], + ); assert_success(&output); let entries = stored_entries(&project); @@ -199,6 +255,11 @@ fn a_local_push_of_a_host_key_writes_the_local_store() { settings.publisher.domain, HOST, "should store the pushed app config" ); + assert_eq!( + settings.permission_policy().map(|policy| policy.name()), + Some("sample"), + "the host should run the policy its blob names" + ); assert!( entries .iter() @@ -206,7 +267,16 @@ fn a_local_push_of_a_host_key_writes_the_local_store() { "a host's push should not write the logical store ID" ); - let logical = push(&project, &["--local", "--key", DEFAULT_CONFIG_STORE_ID]); + let logical = push( + &project, + &[ + "--local", + "--key", + DEFAULT_CONFIG_STORE_ID, + "--permissions", + POLICY_FILE, + ], + ); assert_success(&logical); assert!( @@ -223,7 +293,17 @@ fn a_long_document_is_written_as_chunks_then_a_pointer() { lengthen_app_config(&project); let before = fs::read_to_string(fastly_toml(&project)).expect("should read the manifest"); - let preview = push(&project, &["--local", "--key", HOST, "--dry-run"]); + let preview = push( + &project, + &[ + "--local", + "--key", + HOST, + "--dry-run", + "--permissions", + POLICY_FILE, + ], + ); assert_success(&preview); assert!( @@ -236,7 +316,10 @@ fn a_long_document_is_written_as_chunks_then_a_pointer() { "a dry run should write nothing" ); - assert_success(&push(&project, &["--local", "--key", HOST])); + assert_success(&push( + &project, + &["--local", "--key", HOST, "--permissions", POLICY_FILE], + )); let mut entries = stored_entries(&project); let chunk_prefix = format!("{HOST}.__edgezero_chunks."); @@ -290,7 +373,10 @@ fn a_key_whose_chunk_keys_are_too_long_is_refused_before_writing() { // infix, the envelope's hash and an index. let key = "k".repeat(200); - let output = push(&project, &["--local", "--key", &key]); + let output = push( + &project, + &["--local", "--key", &key, "--permissions", POLICY_FILE], + ); assert!(!output.status.success(), "the push should be refused"); assert!( @@ -305,6 +391,94 @@ fn a_key_whose_chunk_keys_are_too_long_is_refused_before_writing() { ); } +/// Two hosts whose blobs name one file share one entry, so the file is kept +/// once however many hosts name it. +#[test] +fn two_hosts_naming_one_policy_file_keep_it_once() { + let project = project(); + let policy_entry = policy_key(&policy_sha256(POLICY_TEXT)); + + assert_success(&push( + &project, + &["--local", "--key", HOST, "--permissions", POLICY_FILE], + )); + let first = stored_entries(&project); + let second = push( + &project, + &["--local", "--key", OTHER_HOST, "--permissions", POLICY_FILE], + ); + + assert_success(&second); + assert!( + String::from_utf8_lossy(&second.stdout).contains(&format!("kept `{policy_entry}`")), + "the second push should keep the entry the first wrote: {}", + String::from_utf8_lossy(&second.stdout) + ); + let entries = stored_entries(&project); + let held = |entries: &[(String, String)]| { + entries + .iter() + .filter(|(key, _)| key.starts_with(&policy_entry)) + .cloned() + .collect::>() + }; + assert!( + entries.iter().any(|(key, _)| *key == policy_entry), + "the file should be kept under its digest" + ); + assert_eq!( + held(&entries), + held(&first), + "the second push should write no entry of the file again" + ); + let pointer_at = entries + .iter() + .position(|(key, _)| *key == policy_entry) + .expect("should keep the policy entry"); + let blob_at = entries + .iter() + .position(|(key, _)| key == HOST) + .expect("should write the first host's blob"); + assert!( + pointer_at < blob_at, + "the policy entry should be written before the blob that names it" + ); + for host in [HOST, OTHER_HOST] { + let settings = read_back(&entries, host).expect("each host's blob should load"); + assert_eq!( + settings.permission_policy().map(|policy| policy.sha256()), + Some(policy_sha256(POLICY_TEXT).as_str()), + "{host} should run the file both pushes supplied" + ); + } +} + +/// The keyed push refuses what the plain push refuses, so a host's blob +/// always names a supplied policy file. +#[test] +fn a_keyed_push_refuses_settings_that_name_no_policy_and_writes_nothing() { + let project = project_naming_no_policy(); + fs::write(project.path().join(POLICY_FILE), POLICY_TEXT).expect("should write the policy file"); + let before = fs::read_to_string(fastly_toml(&project)).expect("should read the manifest"); + + let output = push( + &project, + &["--local", "--key", HOST, "--permissions", POLICY_FILE], + ); + + assert!(!output.status.success(), "the push should be refused"); + assert!( + String::from_utf8_lossy(&output.stderr).contains("the settings name no permission policy"), + "should say what is missing: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + fs::read_to_string(fastly_toml(&project)).expect("should read the manifest"), + before, + "a refused push should write nothing" + ); +} + #[test] fn a_remote_push_of_a_host_key_is_refused_without_local() { let project = project(); From 953e2c38f7503c75ef4437fba0ad994a58436f17 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:28:37 +0000 Subject: [PATCH 16/18] The config by host guide, harness and changelog name the policy file --- CHANGELOG.md | 2 +- docs/guide/cli.md | 9 ++++++++- docs/guide/configuration.md | 23 ++++++++++++++++++----- scripts/config-by-host-local-test.sh | 19 +++++++++++++++++-- 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f820561b83..62dd5581ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,7 +76,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- One Fastly service can serve several publishers. A `TS__CONFIG__KEY` entry in the `trusted_server_config` store that contains `{host}` names an app-config blob for each host, pushed with `ts config push --local --key `, and each request reads the blob under its own host. A host with no blob is answered `421 Misdirected Request`, never from the logical store ID or from another host's blob, a malformed selector is answered `500`, and a sandbox that serves several requests keeps an application for each blob, found only by that blob's key. Without the entry, or with a selector that has no placeholder, the service reads one blob. `scripts/config-by-host-local-test.sh` runs it under Viceroy. +- One Fastly service can serve several publishers. A `TS__CONFIG__KEY` entry in the `trusted_server_config` store that contains `{host}` names an app-config blob for each host, pushed with `ts config push --local --key `, and each request reads the blob under its own host. Each host's blob names its permission policy file and pins its SHA-256, and the push keeps each file `--permissions` supplies once, in a shared entry under `permissions_`, written before the blob. A host with no blob is answered `421 Misdirected Request`, never from the logical store ID or from another host's blob, a malformed selector is answered `500`, and a sandbox that serves several requests keeps an application for each blob, found only by that blob's key. Without the entry, or with a selector that has no placeholder, the service reads one blob. `scripts/config-by-host-local-test.sh` runs it under Viceroy. - `ts audit generate` writes the `[[fetch]]` and `[[serve]]` entries that run the page changes of the modules it selects on every HTML page, and `trusted-server.example.toml` documents the entries with every middleware the stock modules supply. `trusted-server-modules` lists those middleware with `middleware()` and `middleware_for(...)`. - Page changes as middleware. A module registers a change it makes to a page with `.with_middleware(...)`, and the ordered `[[fetch]]` and `[[serve]]` entries of the settings say which pages each one runs on and in what order. A fetch middleware runs on the page as the origin sent it and what it writes is stored with a shared template. A serve middleware runs on each reader's copy, whether the page came from the store or from the origin, and nothing it writes is stored. Where text a middleware writes into a script would end the script, or begin `` key in the physical store selected by the current environment. It is mutually exclusive with `--key`. On Fastly, `ts` pushes a -`--key` other than the logical store ID itself, and only with `--local`. +`--key` other than the logical store ID itself, and only with `--local`. That +push makes the same policy checks, writes the named file's SHA-256 and not the +file into the blob, and keeps each file `--permissions` supplies once, under +`permissions_`, before the blob +([One blob for each host](/guide/configuration#one-blob-for-each-host)). +`config diff` compares only the logical store ID on Fastly, because EdgeZero +refuses every other key, so the policy a host runs is checked on that host's +`/_ts/config`, which shows the policy's name and SHA-256. Inspect and verify deployments with the deploy lifecycle commands. All three are Fastly-only — the axum, cloudflare, and spin adapters reject them: diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 0721896054..6ee5a0f26d 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -3341,8 +3341,10 @@ Push each key in lower case. The runtime lowercases the request's host and answered `421`. ```bash -ts config push --adapter fastly --local --key www.publisher-a.example --app-config publisher-a.toml -ts config push --adapter fastly --local --key www.publisher-b.example --app-config publisher-b.toml +ts config push --adapter fastly --local --key www.publisher-a.example \ + --app-config publisher-a.toml --permissions standard.yaml +ts config push --adapter fastly --local --key www.publisher-b.example \ + --app-config publisher-b.toml --permissions standard.yaml fastly config-store-entry update --upsert \ --store-id \ @@ -3352,9 +3354,19 @@ fastly config-store-entry update --upsert \ `ts config push` writes a key other than `trusted_server_config` only with `--local`, into the local store in `fastly.toml`. Copy the entries it writes -into a live store with `fastly config-store-entry update`, a long blob's -chunks before its pointer. The selector is read on every request, so the -blobs come first. +into a live store with `fastly config-store-entry update`, the policy entries +first and a long value's chunks before its pointer. The selector is read on +every request, so the blobs come first. + +Each host's blob names its permission policy file in `[permissions] policy` +and pins the file's SHA-256, and carries no copy of the file. The push keeps +each file `--permissions` supplies once, in an entry of its own under +`permissions_`, written before the host's blob, and keeps an entry that +already holds the file, so a hundred hosts that name three files store three +entries. A changed file is a new entry, so a host still running an earlier +blob still finds the file that blob pins. The blob at `trusted_server_config` +carries its file, as on a service with one blob. `ts config diff` compares only +that blob, so the policy a host runs is checked on that host's `/_ts/config`. | A request for | Reads the blob at | Is answered | | ----------------------------------------------------------- | ------------------------- | -------------------------------- | @@ -3367,6 +3379,7 @@ blobs come first. | `/health`, for any host | None | `200`, before any config is read | | Any host, under a malformed selector | None | `500` | | Any host, where the store cannot be read | None | `500` | +| A host whose policy entry is missing or is another file | The host's blob | `500`, with the reason logged | A selector is malformed when it is empty, carries whitespace, a control character or `{host}` more than once, carries a brace outside its `{host}` diff --git a/scripts/config-by-host-local-test.sh b/scripts/config-by-host-local-test.sh index 26de1c84f9..2dcfa33edc 100755 --- a/scripts/config-by-host-local-test.sh +++ b/scripts/config-by-host-local-test.sh @@ -202,6 +202,19 @@ data = "fictional-local-handler-password-secret-value" SECRETSEOF "$TS" config init --app-config "$WORK/app.toml" >/dev/null +# The example names no permission policy, so the blobs name the repository's +# sample, and every push supplies its file. +POLICY_FILE="$REPO_ROOT/config/permissions/sample.yaml" +sed -e 's/^# \[permissions\]$/[permissions]/' \ + -e 's/^# policy = "standard"$/policy = "sample"/' \ + "$WORK/app.toml" > "$WORK/app.policy.toml" +mv "$WORK/app.policy.toml" "$WORK/app.toml" +for line in '[permissions]' 'policy = "sample"'; do + grep --fixed-strings --line-regexp --quiet -- "$line" "$WORK/app.toml" || { + echo "could not name the sample policy in $WORK/app.toml" >&2 + exit 1 + } +done # Every response then says which sandbox served it and how many applications # that sandbox had built. printf '\n[debug]\nsandbox_metrics_enabled = true\n' >> "$WORK/app.toml" @@ -216,11 +229,13 @@ push_blob() { # push_blob [--key ] TRUSTED_SERVER__PUBLISHER__ORIGIN_URL="http://127.0.0.1:$port" \ "$TS" config push --adapter fastly --local \ --manifest "$WORK/edgezero.toml" --app-config "$WORK/app.toml" \ - --no-diff --yes "$@" >/dev/null + --permissions "$POLICY_FILE" --no-diff --yes "$@" >/dev/null ) } # One blob at the logical store ID, one under each publisher's host, and one -# under publisher A's host with a suffix. +# under publisher A's host with a suffix. The blob at the logical store ID +# carries the policy file, and the keyed pushes keep it once, in its shared +# entry. push_blob "shared.example" "$PORT_SHARED" push_blob "$HOST_A" "$PORT_A" --key "$HOST_A" push_blob "$HOST_B" "$PORT_B" --key "$HOST_B" From e297f44f20f7af8507ebea3abb751010c9d9e7af Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:29:12 +0000 Subject: [PATCH 17/18] The keyed push tests hand the temp folder to ts The push helper in config_push_by_key.rs clears the environment and handed no TMPDIR, TMP or TEMP to the child, so on a Windows host ts could not write the temporary settings file the push hands EdgeZero, and four of the six tests failed. The helper now hands the three variables over, as config_store_defaults.rs does. On Linux the fallback is /tmp, so CI was unaffected. --- crates/trusted-server-cli/tests/config_push_by_key.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/trusted-server-cli/tests/config_push_by_key.rs b/crates/trusted-server-cli/tests/config_push_by_key.rs index 6b7d25a144..1016659810 100644 --- a/crates/trusted-server-cli/tests/config_push_by_key.rs +++ b/crates/trusted-server-cli/tests/config_push_by_key.rs @@ -111,6 +111,8 @@ fn push(project: &TempDir, args: &[&str]) -> Output { .current_dir(project.path()) // Do not inherit operator configuration or credentials from the test runner. .env_clear() + // The push hands its settings over in a file in the temp folder. + .envs(["TMPDIR", "TMP", "TEMP"].map(|name| (name, std::env::temp_dir().into_os_string()))) .env("RUST_LOG", "info") .env("TRUSTED_SERVER__PUBLISHER__DOMAIN", HOST) .env( From c58153a9f69f03b230d08dba026e9cec331c8e6d Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Wed, 28 Oct 2026 11:32:24 +0000 Subject: [PATCH 18/18] The keyed push read-back tests pin a digest of their own The keyed push tests read a host's blob back with its policy file read from the shared entry the push wrote. A policy already held is not read again, so a read-back that pins the sample's digest can be answered by rules an earlier test in the same process loaded, and it then proves nothing about the entry. The three tests that read a blob back now push a policy only that test loads, and count the reads the shared entries answer. A host key's read-back reads its entry once, and of two hosts naming one file the second reads nothing and runs the rules the first one loaded. Core's helper of the same name is compiled only into core's own tests, so the test file has a copy of it. The tests that read nothing back still push the sample. A second read-back of the first test's blob counts no read, which shows that the count catches a read-back a held copy answers. The configuration guide says, after its table of answers, that an instance that already holds the rules for the SHA-256 a host's blob pins does not read that host's policy entry. --- .../tests/config_push_by_key.rs | 78 ++++++++++++++++--- docs/guide/configuration.md | 5 ++ 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/crates/trusted-server-cli/tests/config_push_by_key.rs b/crates/trusted-server-cli/tests/config_push_by_key.rs index 1016659810..dc39a2f2db 100644 --- a/crates/trusted-server-cli/tests/config_push_by_key.rs +++ b/crates/trusted-server-cli/tests/config_push_by_key.rs @@ -1,6 +1,7 @@ //! A Fastly push under a key other than the logical store ID, which `ts` //! writes itself because `EdgeZero` writes no such key. +use std::cell::Cell; use std::collections::BTreeMap; use std::fs; use std::path::PathBuf; @@ -32,6 +33,21 @@ const POLICY_FILE: &str = "sample.yaml"; /// The repository's sample, which the project's app config names. const POLICY_TEXT: &str = include_str!("../../../config/permissions/sample.yaml"); +/// A small valid policy whose digest no other test uses, given a `group` no +/// other test passes, so no earlier read-back in the process holds its rules +/// and a read-back has to read the file from its shared entry. +fn policy_only_this_test_loads(group: &str) -> String { + format!( + "groups: + {group}: + default: requires_signal +rules: + group: {group} + jurisdiction: unknown +" + ) +} + /// A project holding the repository's manifests, with only the Fastly /// adapter declared, the example app config naming the repository's sample /// policy, and that policy's file. @@ -49,6 +65,13 @@ fn project() -> TempDir { directory } +/// A project as [`project`] makes, with its policy file holding `policy`. +fn project_with_policy(policy: &str) -> TempDir { + let directory = project(); + fs::write(directory.path().join(POLICY_FILE), policy).expect("should write the policy file"); + directory +} + /// A project holding the repository's manifests, with only the Fastly /// adapter declared, and the example app config, which names no policy. fn project_naming_no_policy() -> TempDir { @@ -202,12 +225,19 @@ impl PlatformSecretStore for FixedSecretStore { } } +thread_local! { + /// The policy file reads [`SharedPolicyEntries`] has answered on this + /// thread, which runs one test at a time. + static POLICY_READS: Cell = const { Cell::new(0) }; +} + /// Reads a host's policy file from its shared entry, as the Fastly adapter /// does, chunks included. struct SharedPolicyEntries<'a>(&'a MemoryConfigStore); impl PlatformPolicySource for SharedPolicyEntries<'_> { fn policy_text(&self, reference: &PolicyReference) -> Result> { + POLICY_READS.with(|reads| reads.set(reads.get() + 1)); let store_name = StoreName::from(DEFAULT_CONFIG_STORE_ID); let value = self.0.get(&store_name, &policy_key(&reference.sha256))?; let entry = resolve_fastly_chunk_pointer(self.0, &store_name, &value).map_err(|error| { @@ -241,9 +271,21 @@ fn read_back( ) } +/// Reads the blob at `key` back as [`read_back`] does, and returns the result +/// with the number of times a shared policy entry was read, which is none +/// when the process already holds the rules of the digest the blob pins. +fn read_back_counting_reads( + entries: &[(String, String)], + key: &str, +) -> (Result>, usize) { + let before = POLICY_READS.with(Cell::get); + let settings = read_back(entries, key); + (settings, POLICY_READS.with(Cell::get) - before) +} + #[test] fn a_local_push_of_a_host_key_writes_the_local_store() { - let project = project(); + let project = project_with_policy(&policy_only_this_test_loads("host-key")); let output = push( &project, @@ -252,7 +294,12 @@ fn a_local_push_of_a_host_key_writes_the_local_store() { assert_success(&output); let entries = stored_entries(&project); - let settings = read_back(&entries, HOST).expect("should read the host's blob back"); + let (settings, reads) = read_back_counting_reads(&entries, HOST); + let settings = settings.expect("should read the host's blob back"); + assert_eq!( + reads, 1, + "the read-back should read the policy file from its shared entry" + ); assert_eq!( settings.publisher.domain, HOST, "should store the pushed app config" @@ -291,7 +338,7 @@ fn a_local_push_of_a_host_key_writes_the_local_store() { #[test] fn a_long_document_is_written_as_chunks_then_a_pointer() { - let project = project(); + let project = project_with_policy(&policy_only_this_test_loads("chunked-blob")); lengthen_app_config(&project); let before = fs::read_to_string(fastly_toml(&project)).expect("should read the manifest"); @@ -342,7 +389,12 @@ fn a_long_document_is_written_as_chunks_then_a_pointer() { == chunk_count, "every chunk should be written before the pointer" ); - let settings = read_back(&entries, HOST).expect("should read the chunked blob back whole"); + let (settings, reads) = read_back_counting_reads(&entries, HOST); + let settings = settings.expect("should read the chunked blob back whole"); + assert_eq!( + reads, 1, + "the read-back should read the policy file from its shared entry" + ); assert_eq!( settings.publisher.domain, HOST, "should reassemble the pushed app config" @@ -397,8 +449,9 @@ fn a_key_whose_chunk_keys_are_too_long_is_refused_before_writing() { /// once however many hosts name it. #[test] fn two_hosts_naming_one_policy_file_keep_it_once() { - let project = project(); - let policy_entry = policy_key(&policy_sha256(POLICY_TEXT)); + let policy_text = policy_only_this_test_loads("two-hosts"); + let project = project_with_policy(&policy_text); + let policy_entry = policy_key(&policy_sha256(&policy_text)); assert_success(&push( &project, @@ -445,11 +498,18 @@ fn two_hosts_naming_one_policy_file_keep_it_once() { pointer_at < blob_at, "the policy entry should be written before the blob that names it" ); - for host in [HOST, OTHER_HOST] { - let settings = read_back(&entries, host).expect("each host's blob should load"); + // The first host's read-back reads the shared entry, and the second + // host's is answered by the rules the first one loaded. + for (host, expected_reads) in [(HOST, 1), (OTHER_HOST, 0)] { + let (settings, reads) = read_back_counting_reads(&entries, host); + let settings = settings.expect("each host's blob should load"); + assert_eq!( + reads, expected_reads, + "only the first host's read-back should read the shared entry" + ); assert_eq!( settings.permission_policy().map(|policy| policy.sha256()), - Some(policy_sha256(POLICY_TEXT).as_str()), + Some(policy_sha256(&policy_text).as_str()), "{host} should run the file both pushes supplied" ); } diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index e082201b1f..61f203c3c3 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -3381,6 +3381,11 @@ that blob, so the policy a host runs is checked on that host's `/_ts/config`. | Any host, where the store cannot be read | None | `500` | | A host whose policy entry is missing or is another file | The host's blob | `500`, with the reason logged | +An instance that already holds the rules for the SHA-256 a host's blob pins +does not read that host's policy entry, so a missing or replaced entry is +answered with `500` only by an instance that has not loaded that SHA-256 +before. + A selector is malformed when it is empty, carries whitespace, a control character or `{host}` more than once, carries a brace outside its `{host}` (such as `{Host}`), or is longer than 255 characters. Without the entry every