diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b4a8c411a7..d1123ef771 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -65,6 +65,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/CHANGELOG.md b/CHANGELOG.md index 57cf1428fd..6e017fb10d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +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. 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 ` Self { Self { config_store_name: StoreName::from(DEFAULT_CONFIG_STORE_ID), @@ -240,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 @@ -250,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 @@ -1718,11 +1737,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; @@ -1890,6 +1909,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/config_selection.rs b/crates/trusted-server-adapter-fastly/src/config_selection.rs new file mode 100644 index 0000000000..16ac668de4 --- /dev/null +++ b/crates/trusted-server-adapter-fastly/src/config_selection.rs @@ -0,0 +1,470 @@ +//! Which app-config blob serves a request. +//! +//! 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 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. +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 { + /// The request names no host, or a host the store holds no blob for. + NoConfigForHost, + /// 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 +/// name. +/// +/// 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, + _ => 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.starts_with('-') + && !label.ends_with('-') + && 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 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. +/// A key longer than the store takes is never looked up. +/// +/// # Errors +/// +/// 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( + 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)) + .filter(|key| key.chars().count() <= MAX_KEY_LEN) + .ok_or(Refusal::NoConfigForHost)?; + match exists(&key) { + Ok(true) => Ok(key), + Ok(false) => Err(Refusal::NoConfigForHost), + Err(error) => Err(Refusal::StoreUnavailable(error.to_string())), + } +} + +/// 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 +/// 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)) + } + + /// 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"] { + 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 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); + 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" + ); + } + + #[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" + ); + } + + #[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 0cd0baeeac..9e8448f3f1 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,115 @@ 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 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 +/// +/// Returns why the request is not served, which [`refusal_response`] turns +/// into the answer. +fn stores_for_request( + stores: RuntimeStoreConfig, + req: &FastlyRequest, +) -> Result { + 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) + }); + match resolved { + Ok(config_key) => Ok(RuntimeStoreConfig { + config_key, + ..stores + }), + Err(refusal) => { + 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 selector `{}`", + stores.config_key + ), + config_selection::Refusal::StoreUnavailable(reason) + | config_selection::Refusal::MalformedSelector(reason) => { + log::error!("cannot choose the app-config blob for host {named:?}: {reason}"); + } + } + Err(refusal) + } + } +} + +/// 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 +/// 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, 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 => { + 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(_) + | config_selection::Refusal::MalformedSelector(_) => { + 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" { let mut response = FastlyResponse::from_status(200).with_body_text_plain("ok"); @@ -257,7 +367,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) @@ -363,7 +473,17 @@ 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_stores = RuntimeStoreConfig::logical(); + // Settled before anything reads settings, so every path below reads the + // blob of the publisher this request is for. + 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(); + return; + } + }; // Short-circuit the sandbox counters probe before app construction. It must // not build the application: polling it would otherwise increment the very @@ -420,36 +540,29 @@ 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. - // - // `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; - } - }; + // 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. + 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 = @@ -1020,7 +1133,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" @@ -1029,8 +1143,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(), ) @@ -1229,6 +1342,188 @@ mod tests { ); } + /// The logical stores, with `selector` as the service's selector. + fn stores_with_selector(selector: &str) -> RuntimeStoreConfig { + RuntimeStoreConfig { + config_key: selector.to_owned(), + ..RuntimeStoreConfig::logical() + } + } + + /// 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 blob" + ); + } + } + + #[test] + 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!( + 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" + ); + } + + /// 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 entry_request_reads_a_host_that_is_not_utf8_as_no_host() { + let req = request_with_host_not_utf8(); + + assert_eq!( + entry_request(&req).host(), + "", + "a module request should carry no host where Host is not UTF-8" + ); + } + + #[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-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(), diff --git a/crates/trusted-server-adapter-fastly/src/sandbox.rs b/crates/trusted-server-adapter-fastly/src/sandbox.rs index fb11c8d055..0227a46210 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; @@ -57,21 +60,103 @@ 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. +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. +/// +/// The framework hands its payload out by shared reference, so the list sits +/// behind a [`RefCell`]. +#[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 @@ -101,16 +186,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. @@ -131,7 +217,7 @@ impl RetirementCounters { ) -> R { self.requests = sandbox.requests(); let outcome = callback(sandbox); - self.attempts = sandbox.initialization_attempts(); + self.attempts = build_attempts(sandbox); outcome } @@ -140,7 +226,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 } @@ -172,7 +258,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(), }) } @@ -723,6 +809,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(); @@ -732,19 +839,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()) }) @@ -752,12 +894,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!( @@ -765,21 +911,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") }); @@ -788,31 +930,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(), @@ -820,12 +954,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 @@ -840,15 +1091,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(), @@ -862,11 +1115,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"), @@ -879,8 +1138,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" ); } @@ -941,7 +1204,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 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/commands/config/keyed_push.rs b/crates/trusted-server-cli/src/commands/config/keyed_push.rs new file mode 100644 index 0000000000..198f6cded5 --- /dev/null +++ b/crates/trusted-server-cli/src/commands/config/keyed_push.rs @@ -0,0 +1,581 @@ +//! 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. +//! +//! 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; +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 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 super::policy::{self, PolicyText, PushArgs}; + +/// 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, +/// with each policy file `--permissions` supplies in its shared entry. +/// +/// 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 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(&push.manifest) + .map_err(|error| format!("failed to load {}: {error}", push.manifest.display()))?; + let manifest = loader.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 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 (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 &written { + writeln!( + out, + "{verb} `{entry_key}` ({} bytes) to \ + [local_server.config_stores.{DEFAULT_CONFIG_STORE_ID}.contents] in {}", + value.len(), + fastly_toml.display() + ) + .map_err(output_failed)?; + } + Ok(()) +} + +/// 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 = 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", + manifest_path.display() + ) + })?; + let mut options = AppConfigLoadOptions::default(); + options.env_overlay = false; + let typed = app_config::deserialize_app_config_with_options::( + app_config, app_name, &options, + ) + .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}"))?; + 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 +/// 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 { + 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 +/// 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)) +} + +/// The entries the local server's logical config store holds in +/// `fastly_toml`, none when it holds none yet. +fn local_entries(fastly_toml: &Path) -> 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> { + 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::*; + + /// 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 + // 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 b47238bc6d..f8bfce214e 100644 --- a/crates/trusted-server-cli/src/commands/config/mod.rs +++ b/crates/trusted-server-cli/src/commands/config/mod.rs @@ -1,3 +1,4 @@ pub mod ad_templates; pub mod init; +pub mod keyed_push; pub mod policy; 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 6c1175f434..2a55ca7e15 100644 --- a/crates/trusted-server-cli/src/run.rs +++ b/crates/trusted-server-cli/src/run.rs @@ -9,6 +9,7 @@ use edgezero_cli::args::{ 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::config::policy; use crate::commands::pbs::{self, PbsArgs}; use crate::prebid_bundle::{NpmPrebidBundleGenerator, PrebidBundleArgs, run_bundle}; @@ -68,7 +69,8 @@ enum ConfigCommand { /// Reclaim orphaned chunk entries leaked from prior oversized pushes. Gc(ConfigGcArgs), /// Push `trusted-server.toml`, with the permission policy file it names, - /// as a blob envelope through `EdgeZero`. + /// as a blob envelope through `EdgeZero`, or under a Fastly `--key` of + /// its own with `--local`. Push(policy::PushArgs), /// Validate `edgezero.toml`, the typed Trusted Server config and, with /// `--permissions`, the permission policy file it names. @@ -143,9 +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)) => { - policy::run_push(args).map(|()| RunOutcome::Success) + Command::Config(ConfigCommand::Push(args)) => match keyed_push::own_key(&args.edgezero) { + Some(key) => { + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + keyed_push::run(&args, key, &mut out) + } + None => policy::run_push(args), } + .map(|()| RunOutcome::Success), Command::Config(ConfigCommand::Validate(args)) => { policy::run_validate(args).map(|()| RunOutcome::Success) } 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..dc39a2f2db --- /dev/null +++ b/crates/trusted-server-cli/tests/config_push_by_key.rs @@ -0,0 +1,562 @@ +//! 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; +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::permissions::{PolicyReference, policy_sha256}; +use trusted_server_core::platform::{ + 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, 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 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. +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 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 { + 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() + // 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( + "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(()) + } +} + +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| { + 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( + &store, + &FixedSecretStore, + &StoreName::from(DEFAULT_CONFIG_STORE_ID), + key, + &StoreName::from("trusted_server_secrets"), + origin, + &trusted_server_modules::builders(), + ) +} + +/// 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_with_policy(&policy_only_this_test_loads("host-key")); + + let output = push( + &project, + &["--local", "--key", HOST, "--permissions", POLICY_FILE], + ); + + assert_success(&output); + let entries = stored_entries(&project); + 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" + ); + assert_eq!( + settings.permission_policy().map(|policy| policy.name()), + Some("sample"), + "the host should run the policy its blob names" + ); + 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, + "--permissions", + POLICY_FILE, + ], + ); + + 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_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"); + + let preview = push( + &project, + &[ + "--local", + "--key", + HOST, + "--dry-run", + "--permissions", + POLICY_FILE, + ], + ); + + 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, "--permissions", POLICY_FILE], + )); + + 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, 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" + ); + 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, "--permissions", POLICY_FILE], + ); + + 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" + ); +} + +/// 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 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, + &["--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" + ); + // 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()), + "{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(); + 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 b613a0d7c2..ca3569c2e1 100644 --- a/crates/trusted-server-cli/tests/config_store_defaults.rs +++ b/crates/trusted-server-cli/tests/config_store_defaults.rs @@ -207,8 +207,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(); 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 8d6438268d..f915cfe1ff 100644 --- a/crates/trusted-server-core/src/settings_data.rs +++ b/crates/trusted-server-core/src/settings_data.rs @@ -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()) @@ -146,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, diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 5c949a2fcf..aa76b6d7f7 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -276,8 +276,10 @@ EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME= \ ts config diff --adapter fastly --staging --permissions standard.yaml ``` -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 @@ -288,8 +290,15 @@ 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`. 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 bfa81ed085..61f203c3c3 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -3309,13 +3309,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. @@ -3324,6 +3326,108 @@ Isolate staging by selecting a different physical store. See [CLI lifecycle commands](/guide/cli#lifecycle-commands) for the full deploy and staging flow. +### One blob for each host + +One Fastly service can serve several publishers, each from an app-config blob +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 +`--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 --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 \ + --key TS__CONFIG__KEY \ + --value '{host}' +``` + +`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`, 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 | +| ----------------------------------------------------------- | ------------------------- | -------------------------------- | +| `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, 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 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 +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 +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, 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 + 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. +- 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. + ### Local development The repository's Viceroy configuration uses the logical app-config store ID and diff --git a/docs/guide/fastly.md b/docs/guide/fastly.md index bec742c55e..3b71049b0c 100644 --- a/docs/guide/fastly.md +++ b/docs/guide/fastly.md @@ -323,6 +323,10 @@ The app-config store is selected the same way, through `EDGEZERO__STORES__CONFIG__TRUSTED_SERVER_CONFIG__NAME` in the deployment environment. No runtime mapping is stored in a Config Store. +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: ```bash diff --git a/scripts/README.md b/scripts/README.md index 8abeb7e566..2a86c6df69 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 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. | | `check-no-compiled-policy.sh` | One or more release WASM paths, after a release build | Looks in each artifact for each `config/permissions` file's first comment line and `name:` value, skipping text under 16 characters, and fails naming what it finds. CI checks the Fastly, Spin and Cloudflare release WASMs, and the sample itself, which must fail. Writes nothing. | diff --git a/scripts/config-by-host-local-test.sh b/scripts/config-by-host-local-test.sh new file mode 100755 index 0000000000..2dcfa33edc --- /dev/null +++ b/scripts/config-by-host-local-test.sh @@ -0,0 +1,408 @@ +#!/usr/bin/env bash +# +# Local harness for a service that reads one app-config blob for each host. +# +# 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. +# +# 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. +# +# 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 four 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))" +PORT_V2="$((ORIGIN_PORT + 3))" +HOST_A="a.publisher.example" +HOST_B="b.publisher.example" +HOST_TRIPLE="$(rustc -vV | sed -n 's/^host: //p')" +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 +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" "$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 + 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, :$PORT_SHARED and :$PORT_V2" +cat > "$WORK/origins.py" <<'PYEOF' +"""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" "V2=$PORT_V2" \ + > "$WORK/origins.log" 2>&1 & +ORIGIN_PID=$! +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 + 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.trusted_server_secrets]] +key = "publisher_proxy_secret" +data = "fictional-local-publisher-proxy-secret-value" + +[[local_server.secret_stores.trusted_server_secrets]] +key = "ec_passphrase" +data = "fictional-local-ec-passphrase-secret-value" + +[[local_server.secret_stores.trusted_server_secrets]] +key = "handler_password" +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" + +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" \ + --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. 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" +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" + +add_entries() { # add_entries ... + "$PYTHON" - "$WORK/fastly.toml" "$@" <<'PYEOF' +import sys + +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 %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)) +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 + # 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)" +} + +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 V2; 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" + no_page "$1" +} + +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 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 +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 blob 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 + +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 + tail -40 "$VICEROY_LOG" >&2 + exit 1 +fi