diff --git a/.cargo/config.toml b/.cargo/config.toml index aea0df2d59..e1bb33505b 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -26,10 +26,10 @@ test_details = "test --target aarch64-apple-darwin" # native crate needs no change here. Axum (native), Cloudflare # (wasm32-unknown-unknown), Spin, the CLI (native), and integration-tests # (native) are simply not listed. -build-fastly = "build -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" -check-fastly = "check -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" -clippy-fastly = "clippy -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --all-targets --all-features --target wasm32-wasip1 -- -D warnings" -test-fastly = "test -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +build-fastly = "build -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +check-fastly = "check -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" +clippy-fastly = "clippy -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --all-targets --all-features --target wasm32-wasip1 -- -D warnings" +test-fastly = "test -p trusted-server-core -p trusted-server-adapter-fastly -p trusted-server-device-fastly -p trusted-server-geo-fastly -p trusted-server-js -p trusted-server-openrtb --target wasm32-wasip1" # Feature-on counterpart of `test-fastly`. `test-fastly` does NOT pass # --all-features, so without this the reusable-sandbox code is linted by # `clippy-fastly` but its tests never execute. diff --git a/Cargo.lock b/Cargo.lock index 7ebc0b08ea..8bd891a869 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6602,6 +6602,8 @@ dependencies = [ "serde_json", "toml", "trusted-server-core", + "trusted-server-device-fastly", + "trusted-server-geo-fastly", "url", "urlencoding", ] @@ -6732,6 +6734,23 @@ dependencies = [ "web-time", ] +[[package]] +name = "trusted-server-device-fastly" +version = "0.1.0" +dependencies = [ + "fastly", + "trusted-server-core", +] + +[[package]] +name = "trusted-server-geo-fastly" +version = "0.1.0" +dependencies = [ + "error-stack", + "fastly", + "trusted-server-core", +] + [[package]] name = "trusted-server-integration-tests" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 72fecdf6b5..32965d91ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,8 @@ [workspace] resolver = "2" members = [ + "crates/device/fastly", + "crates/geo/fastly", "crates/trusted-server-adapter-axum", "crates/trusted-server-adapter-cloudflare", "crates/trusted-server-adapter-fastly", @@ -120,6 +122,8 @@ toml_edit = "0.23.10" tower = "0.4" tracing = "0.1" trusted-server-core = { path = "crates/trusted-server-core" } +trusted-server-device-fastly = { path = "crates/device/fastly" } +trusted-server-geo-fastly = { path = "crates/geo/fastly" } trusted-server-js = { path = "crates/trusted-server-js" } trusted-server-openrtb = { path = "crates/trusted-server-openrtb" } url = "2.5.8" diff --git a/crates/device/README.md b/crates/device/README.md new file mode 100644 index 0000000000..865cb9671f --- /dev/null +++ b/crates/device/README.md @@ -0,0 +1,10 @@ +# Device modules + +Device-detection module crates live here, one per vendor. The Fastly module +(`trusted-server-device-fastly`) classifies a request with the host's TLS and +HTTP/2 signals. Future vendor modules (for example +`crates/device/`) slot in alongside it. + +The built-in default module (User-Agent only) ships in `trusted-server-core` +(`ec::device`). Adapters select and inject the vendor module via +`build_device_module`. diff --git a/crates/device/fastly/Cargo.toml b/crates/device/fastly/Cargo.toml new file mode 100644 index 0000000000..c25b4ada19 --- /dev/null +++ b/crates/device/fastly/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "trusted-server-device-fastly" +description = "Fastly host device module exposing opt-in TLS and HTTP/2 signals." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-core = { workspace = true } +fastly = { workspace = true } + +# The visible owner of this vendor crate, the way Prebid.js requires a named +# maintainer of every adapter. Fastly has not yet adopted this crate, so the +# Trusted Server maintainers own it until a vendor owner steps up. +[package.metadata.maintainers] +owner = "Trusted Server maintainers" +status = "seeking vendor owner" diff --git a/crates/device/fastly/src/lib.rs b/crates/device/fastly/src/lib.rs new file mode 100644 index 0000000000..8394059fff --- /dev/null +++ b/crates/device/fastly/src/lib.rs @@ -0,0 +1,98 @@ +//! The Fastly device module and host-signal capture. +//! +//! [`FastlyDeviceModule`] strengthens the built-in User-Agent classification +//! with the host's TLS (JA4) and HTTP/2 signals, for deployments on Fastly +//! Compute. It is selected by `[device] module = "fastly"` and wired in by the +//! Fastly adapter, which injects the request info and the captured host signals. +//! +//! [`FastlyHostSignals`] captures those signals from a live Fastly request +//! (`get_tls_ja4()`, `get_client_h2_fingerprint()`) into owned values, so it can +//! be shared as an injected [`HostSignals`] service that outlives the borrow of +//! the request. Capturing through the SDK is why this crate depends on the +//! `fastly` crate and builds only for the `wasm32-wasip1` target; off-host the +//! accessors return `None`, so classification degrades to User-Agent only. The +//! platform-neutral [`HostSignals`], [`RequestInfo`], and [`DeviceModule`] +//! traits and the built-in default live in `trusted-server-core`, where the +//! `DeviceSignals` classification logic stays unit-tested. + +use std::sync::Arc; + +use fastly::Request as FastlyRequest; +use trusted_server_core::ec::device::{DeviceModule, DeviceSignals}; +use trusted_server_core::evidence::{HostSignals, RequestInfo}; + +/// Host-computed client signals captured from a live Fastly request. +/// +/// Reads the TLS JA4 and HTTP/2 signals once through the Fastly SDK and +/// owns them, so the value can be injected as a [`HostSignals`] service that +/// outlives the borrow of the request it was captured from. Off-host the SDK +/// accessors return `None`, so the signals are simply absent. +#[derive(Debug, Clone, Default)] +pub struct FastlyHostSignals { + ja4: Option, + h2: Option, +} + +impl FastlyHostSignals { + /// Builds host signals from already-captured signal values. + /// + /// Use this when the adapter has read the signals once (for example + /// into the client metadata, or from the trusted internal headers the entry + /// point injects) and wants to share them without another SDK call. + #[must_use] + pub fn new(ja4: Option, h2: Option) -> Self { + Self { ja4, h2 } + } + + /// Captures the TLS JA4 and HTTP/2 signals from a live Fastly request. + #[must_use] + pub fn from_request(req: &FastlyRequest) -> Self { + Self { + ja4: req.get_tls_ja4().map(str::to_string), + h2: req.get_client_h2_fingerprint().map(str::to_string), + } + } +} + +impl HostSignals for FastlyHostSignals { + fn ja4(&self) -> Option<&str> { + self.ja4.as_deref() + } + + fn h2(&self) -> Option<&str> { + self.h2.as_deref() + } +} + +/// The Fastly device module, opt-in via `[device] module = "fastly"`. +/// +/// Classifies a request with [`DeviceSignals::derive`], which strengthens the +/// User-Agent classification with the host signals. It reads the User-Agent +/// from its injected [`RequestInfo`] and the TLS and HTTP/2 signals from its +/// injected [`HostSignals`], so the browser/bot gate is backed by the live +/// request. +pub struct FastlyDeviceModule { + host_signals: Arc, +} + +impl FastlyDeviceModule { + /// Creates the module with its injected host signals. + #[must_use] + pub fn new(host_signals: Arc) -> Self { + Self { host_signals } + } +} + +impl DeviceModule for FastlyDeviceModule { + fn id(&self) -> &'static str { + "fastly" + } + + fn detect(&self, request_info: &dyn RequestInfo) -> DeviceSignals { + DeviceSignals::derive( + request_info.user_agent(), + self.host_signals.ja4(), + self.host_signals.h2(), + ) + } +} diff --git a/crates/fastly.toml b/crates/fastly.toml new file mode 100644 index 0000000000..718e87ad30 --- /dev/null +++ b/crates/fastly.toml @@ -0,0 +1,13 @@ +# Minimal Viceroy config for testing crates nested one level deeper than the +# adapters (for example `crates/device/fastly` and `crates/geo/fastly`). +# +# The shared wasm test runner in `.cargo/config.toml` starts Viceroy with +# `-C ../../fastly.toml`, resolved from the crate directory. For a two-level +# crate such as `crates/trusted-server-adapter-fastly` that reaches the +# repository root manifest. For a three-level crate it resolves here, to +# `crates/fastly.toml`. These crates' unit tests use no backends, KV stores, +# or dictionaries, only a manifest Viceroy can start from. +manifest_version = 3 +name = "trusted-server-nested-crate-tests" + +[local_server] diff --git a/crates/geo/README.md b/crates/geo/README.md new file mode 100644 index 0000000000..1f556812d2 --- /dev/null +++ b/crates/geo/README.md @@ -0,0 +1,21 @@ +# Geo modules + +Geo and IP-intelligence module crates live here, one per implementation, each +implementing the `PlatformGeo` trait from `trusted-server-core`: + +- `crates/geo/fastly` (`trusted-server-geo-fastly`) is the host platform geo + module for Fastly Compute, wrapping Fastly's `geo_lookup`. The Fastly adapter + injects it via `build_geo_module`. It depends on the Fastly SDK, so it builds + only for `wasm32-wasip1`. +- Vendor geo modules (for example `crates/geo/`) will live alongside + it, one per vendor, selected by the `[geo] module` setting. + +Whatever the source, a module returns the same `GeoInfo` coding. The country +is an ISO 3166-1 alpha-2 code (`US`) and the region is the ISO 3166-2 subdivision +code with no country prefix (`CA`), so the Fastly and other modules feed the +same downstream rules without translation. + +The platform-neutral `PlatformGeo` trait and `DisabledGeo` both live in +`trusted-server-core`. With no `[geo] module`, or `module = "platform"`, the +adapter uses its host platform's own lookup. `module = "none"` selects +`DisabledGeo`, which resolves no location and makes no host call. diff --git a/crates/geo/fastly/Cargo.toml b/crates/geo/fastly/Cargo.toml new file mode 100644 index 0000000000..10c80cafca --- /dev/null +++ b/crates/geo/fastly/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "trusted-server-geo-fastly" +description = "Fastly host geo module backed by the Fastly geolocation API." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +trusted-server-core = { workspace = true } +error-stack = { workspace = true } +fastly = { workspace = true } + +# The visible owner of this vendor crate, the way Prebid.js requires a named +# maintainer of every adapter. Fastly has not yet adopted this crate, so the +# Trusted Server maintainers own it until a vendor owner steps up. +[package.metadata.maintainers] +owner = "Trusted Server maintainers" +status = "seeking vendor owner" diff --git a/crates/geo/fastly/src/lib.rs b/crates/geo/fastly/src/lib.rs new file mode 100644 index 0000000000..32ca0a6060 --- /dev/null +++ b/crates/geo/fastly/src/lib.rs @@ -0,0 +1,47 @@ +//! The Fastly host geo module. +//! +//! [`FastlyPlatformGeo`] implements [`PlatformGeo`] using Fastly's `geo_lookup`, +//! for deployments on Fastly Compute. It is the host platform's geo module, +//! injected by the Fastly adapter via `build_geo_module`. With no selector, +//! or `module = "platform"`, this host lookup resolves the location, and +//! `module = "none"` disables geo instead. +//! +//! Like the Fastly device module, this crate calls the Fastly SDK directly, +//! so it depends on the `fastly` crate and builds only for the `wasm32-wasip1` +//! target. The platform-neutral `PlatformGeo` trait and the `DisabledGeo` +//! default both live in `trusted-server-core`. + +use std::net::IpAddr; + +use error_stack::Report; +use fastly::geo::{Geo, geo_lookup}; +use trusted_server_core::platform::{GeoInfo, PlatformError, PlatformGeo}; + +/// Convert a Fastly [`Geo`] value into a platform-neutral [`GeoInfo`]. +fn geo_from_fastly(geo: &Geo) -> GeoInfo { + GeoInfo { + city: geo.city().to_string(), + country: geo.country_code().to_string(), + continent: format!("{:?}", geo.continent()), + latitude: geo.latitude(), + longitude: geo.longitude(), + metro_code: geo.metro_code(), + region: geo.region().map(str::to_string), + asn: None, + } +} + +/// Fastly geo-lookup implementation of [`PlatformGeo`]. +/// +/// The host platform geo module for Fastly Compute. The adapter injects it via +/// `build_geo_module`. With no selector, or `module = "platform"`, it +/// resolves the location, and `module = "none"` disables geo instead. +pub struct FastlyPlatformGeo; + +impl PlatformGeo for FastlyPlatformGeo { + fn lookup(&self, client_ip: Option) -> Result, Report> { + Ok(client_ip + .and_then(geo_lookup) + .map(|geo| geo_from_fastly(&geo))) + } +} diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 7b50568a09..802f732726 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -102,11 +102,11 @@ fn build_state_with_services( // whose application state is built once at start-up while theirs is rebuilt // for every request. With no services supplied it threads no module, so // `EcContext` resolves the selection itself on every request, building a - // fresh built-in module that reads no request data; this dev server - // accepts that per-request construction rather than caching a resolved - // module. + // fresh built-in module that reads no request data. It supplies no host + // signals either, so the `host_signals` argument is `None`. ensure_module_available( &settings.ec, + None, services .as_ref() .and_then(RuntimeServices::resolved_ec_module), @@ -128,7 +128,7 @@ impl AppState { fn services_for_request(&self, ctx: &RequestContext) -> RuntimeServices { self.services .clone() - .unwrap_or_else(|| build_runtime_services(ctx)) + .unwrap_or_else(|| build_runtime_services(ctx, &self.settings)) } } @@ -812,7 +812,7 @@ mod tests { .body(edgezero_core::body::Body::empty()) .expect("should build test request"); let ctx = RequestContext::new(req, PathParams::default()); - let services = build_runtime_services(&ctx); + let services = build_runtime_services(&ctx, &state.settings); let req = ctx.into_request(); let error = build_ec_context(&state, &services, &req) diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index e2713f47f2..369037fe53 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -527,7 +527,10 @@ impl PlatformHttpClient for AxumPlatformHttpClient { /// The generic runtime KV slot uses /// [`trusted_server_core::platform::UnavailableKvStore`]. A `warn` log is /// emitted once per process. -pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> RuntimeServices { +pub fn build_runtime_services( + ctx: &edgezero_core::context::RequestContext, + settings: &trusted_server_core::settings::Settings, +) -> RuntimeServices { static KV_WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); KV_WARNED.get_or_init(|| { log::warn!("Axum dev server: generic runtime KV is unavailable (UnavailableKvStore)."); @@ -569,9 +572,12 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R // API-route integration flow by reusing a poisoned connection after a // truncated POST. Revisit pooling if profiling shows allocation cost. .http_client(Arc::new(AxumPlatformHttpClient::new())) - .geo(Arc::clone(GEO.get_or_init(|| { - Arc::new(AxumPlatformGeo) as Arc - }))) + // Route through the [geo] module selector like the Fastly adapter, + // so the selector behaves the same on every adapter. + .geo(trusted_server_core::platform::build_geo_module( + settings, + Arc::clone(GEO.get_or_init(|| Arc::new(AxumPlatformGeo) as Arc)), + )) .client_info(ClientInfo { client_ip, tls_protocol: None, diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 5c1613597c..52d393d4e5 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -22,7 +22,7 @@ use trusted_server_core::ec::admin::{ admin_ec_lookup_not_supported as core_admin_ec_lookup_not_supported, deny_admin_diagnostic_fallback, handle_admin_eids_lookup, }; -use trusted_server_core::ec::module::{EdgeCookieModule, build_shared_module}; +use trusted_server_core::ec::module::{EdgeCookieModule, build_reusable_module}; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; @@ -220,10 +220,12 @@ fn build_state_with_services( // is served, so a selection this adapter can never supply fails here rather // than on the first request. Keeping what the resolution produced is what // stops the request path resolving the same settings again. This adapter - // injects no vendor Edge Cookie module of its own, so the only injected - // module is one a caller put into the services it supplied. - let ec_module = build_shared_module( + // supplies no host signals, so that argument is `None`, and injects no + // vendor Edge Cookie module of its own, so the only injected module is + // one a caller put into the services it supplied. + let ec_module = build_reusable_module( &settings.ec, + None, services .as_ref() .and_then(RuntimeServices::resolved_ec_module), @@ -246,10 +248,12 @@ impl AppState { /// Builds the per-request services, carrying the Edge Cookie module the /// composition root already resolved so the request path does not resolve /// `[ec] module` a second time. + /// Nothing is carried when the composition root found nothing safe to keep, + /// and the request path resolves for itself. fn services_for_request(&self, ctx: &RequestContext) -> RuntimeServices { self.services .clone() - .unwrap_or_else(|| build_runtime_services(ctx)) + .unwrap_or_else(|| build_runtime_services(ctx, &self.settings)) .with_resolved_ec_module(self.ec_module.clone()) } } @@ -869,7 +873,7 @@ mod tests { // No resolved module is threaded here, so the request path resolves // the selection itself, which is what an embedder driving core // directly does and where the loud failure has to stay. - let services = build_runtime_services(&ctx); + let services = build_runtime_services(&ctx, &settings); let req = ctx.into_request(); let error = build_ec_context(&settings, &services, &req) diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index 94616b2035..b946b96301 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -594,7 +594,10 @@ impl PlatformSecretStore for CloudflareSecretStoreAdapter { /// Geo information is read from Cloudflare's injected request headers /// (`cf-ipcountry`, etc.) which are present on all plans; headers absent on /// the native host target simply produce empty/zero defaults. -pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> RuntimeServices { +pub fn build_runtime_services( + ctx: &edgezero_core::context::RequestContext, + settings: &trusted_server_core::settings::Settings, +) -> RuntimeServices { let client_ip = extract_client_ip(ctx); #[cfg(target_arch = "wasm32")] @@ -629,7 +632,9 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R // Geo: read Cloudflare-injected headers — no #[cfg] needed; headers are // simply absent on the native host target, producing Ok(None) from lookup(). - let geo = build_geo(ctx); + // Routed through the [geo] module selector like the Fastly adapter, so + // the selector behaves the same on every adapter. + let geo = trusted_server_core::platform::build_geo_module(settings, Arc::new(build_geo(ctx))); RuntimeServices::builder() .config_store(config_store) @@ -637,7 +642,7 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R .kv_store(kv_store) .backend(Arc::new(NoopBackend)) .http_client(http_client) - .geo(Arc::new(geo)) + .geo(geo) .client_info(ClientInfo { client_ip, tls_protocol: None, @@ -654,15 +659,22 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R /// Reads Cloudflare geo headers injected by the Workers runtime. /// /// `cf-ipcountry` is available on all plans. `cf-ipcity`, `cf-ipcontinent`, -/// `cf-iplatitude`, and `cf-iplongitude` require an Enterprise plan. Absent or -/// unparseable values default to empty strings or `0.0`. Country code `XX` -/// (Cloudflare's "unknown" sentinel) is treated as absent. +/// `cf-iplatitude`, `cf-iplongitude` and `cf-region-code` require an Enterprise +/// plan and the visitor-location managed transform. Absent or unparseable +/// values default to empty strings or `0.0`. Country code `XX` (Cloudflare's +/// "unknown" sentinel) is treated as absent. +/// +/// The region is the ISO 3166-2 subdivision code from `cf-region-code`, for +/// example `CA`, and not the subdivision name from `cf-region`, because the US +/// state privacy rules in `[consent.us_states] privacy_states` are written as +/// two-letter codes and a name would never match one. struct CloudflareGeo { country: String, city: String, continent: String, latitude: f64, longitude: f64, + region: Option, } impl PlatformGeo for CloudflareGeo { @@ -677,7 +689,7 @@ impl PlatformGeo for CloudflareGeo { latitude: self.latitude, longitude: self.longitude, metro_code: 0, - region: None, + region: self.region.clone(), asn: None, })) } @@ -711,12 +723,19 @@ fn build_geo(ctx: &edgezero_core::context::RequestContext) -> CloudflareGeo { .and_then(|v| v.to_str().ok()) .and_then(|s| s.parse::().ok()) .unwrap_or(0.0); + let region = headers + .get("cf-region-code") + .and_then(|v| v.to_str().ok()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned); CloudflareGeo { country, city, continent, latitude, longitude, + region, } } @@ -793,6 +812,78 @@ mod tests { RequestContext::new(req, PathParams::default()) } + /// Builds a request context carrying multiple headers at once. + fn make_ctx_with_headers(headers: &[(&str, &str)]) -> RequestContext { + let mut builder = request_builder().method("GET").uri("https://example.com/"); + for (name, value) in headers { + builder = builder.header( + *name, + HeaderValue::from_str(value).expect("should parse test header value"), + ); + } + let req = builder + .body(edgezero_core::body::Body::empty()) + .expect("should build test request"); + RequestContext::new(req, PathParams::default()) + } + + #[test] + fn a_us_state_visitor_reaches_the_us_state_jurisdiction_and_its_opt_out() { + // The region header has to reach the privacy outcome. + // `detect_jurisdiction` reaches its US branch only when the country is + // `US` and a region is present. With no region a US visitor falls + // through to `NonRegulated`, where `allows_ec_creation` returns true + // without reading `ctx.gpc`, so a Sec-GPC opt-out would be ignored. + let config = trusted_server_core::consent_config::ConsentConfig::default(); + + let ctx = make_ctx_with_headers(&[("cf-ipcountry", "US"), ("cf-region-code", "CA")]); + let geo = build_geo(&ctx) + .lookup(None) + .expect("should look up without failing") + .expect("a country header should resolve a location"); + assert_eq!( + geo.region.as_deref(), + Some("CA"), + "the ISO 3166-2 subdivision code should reach the geo info" + ); + + let jurisdiction = + trusted_server_core::consent::jurisdiction::detect_jurisdiction(Some(&geo), &config); + assert_eq!( + jurisdiction, + trusted_server_core::consent::jurisdiction::Jurisdiction::UsState("CA".to_owned()), + "a Californian visitor should reach the US state jurisdiction" + ); + + // Reaching that jurisdiction is what this adapter is responsible for, + // and it is the switch every downstream opt-out hangs off. A Sec-GPC + // signal, a GPP US sale opt-out and a US Privacy opt-out are all + // consulted on the US state branch and none of them are consulted on + // the unregulated one, so a visitor who never reaches the US state + // jurisdiction has every one of those signals ignored. Which gate reads + // the jurisdiction is core's business and changes across this stack, so + // it is core that tests the reading. + + // Without the region header nothing can place the visitor in a state, + // so the jurisdiction is the unregulated one and the same opt-out is + // ignored. That is the behavior this fix removes for any deployment + // whose plan supplies the header. + let ctx = make_ctx_with_headers(&[("cf-ipcountry", "US")]); + let geo = build_geo(&ctx) + .lookup(None) + .expect("should look up without failing") + .expect("a country header should resolve a location"); + assert_eq!( + geo.region, None, + "no region header should mean no region, not an invented one" + ); + assert_eq!( + trusted_server_core::consent::jurisdiction::detect_jurisdiction(Some(&geo), &config), + trusted_server_core::consent::jurisdiction::Jurisdiction::NonRegulated, + "with no region a US visitor cannot be placed in a privacy state" + ); + } + fn make_ctx_without_header() -> RequestContext { let req = request_builder() .method("GET") diff --git a/crates/trusted-server-adapter-fastly/Cargo.toml b/crates/trusted-server-adapter-fastly/Cargo.toml index 710523766e..aedfe6f381 100644 --- a/crates/trusted-server-adapter-fastly/Cargo.toml +++ b/crates/trusted-server-adapter-fastly/Cargo.toml @@ -35,6 +35,8 @@ log-fastly = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } trusted-server-core = { workspace = true } +trusted-server-device-fastly = { workspace = true } +trusted-server-geo-fastly = { workspace = true } url = { workspace = true } urlencoding = { workspace = true } diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 69f5083089..c387059250 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -115,7 +115,7 @@ use trusted_server_core::ec::device::DeviceSignals; use trusted_server_core::ec::identify::{cors_preflight_identify, handle_identify}; use trusted_server_core::ec::kv::KvIdentityGraph; use trusted_server_core::ec::module::request_module; -use trusted_server_core::ec::module::{EdgeCookieModule, build_shared_module}; +use trusted_server_core::ec::module::{EdgeCookieModule, build_reusable_module}; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::ec::{EcContext, EidSyncSource}; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; @@ -125,8 +125,10 @@ use trusted_server_core::integrations::{ RequestFilterRegistryOutcome, }; use trusted_server_core::platform::{ - ClientInfo, GeoInfo, PlatformKvStore, RuntimeServices, StoreName, + ClientInfo, GeoInfo, PlatformKvStore, RuntimeServices, StoreName, build_geo_module, }; +use trusted_server_device_fastly::FastlyHostSignals; + use trusted_server_core::proxy::{ AssetProxyCachePolicy, handle_asset_proxy_request, handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, @@ -240,11 +242,22 @@ pub(crate) fn build_state_from_settings( // Composition root: resolve the module selection once, before any request // is served, so a selection this adapter can never supply fails here rather - // than on the first request. Keeping what the resolution produced is what - // stops the request path resolving the same settings again. This adapter + // than on the first request, and keep what the resolution produced so the + // request path does not resolve the same settings again. This adapter // injects no vendor Edge Cookie module, so `None` is the injected // argument, and one is passed here once this adapter supplies it. - let ec_module = build_shared_module(&settings.ec, None)?; + // + // This adapter injects host signals on every request, so a startup instance + // with no captured signals answers the only question the check asks, + // which is whether the service exists at all. That same emptiness is why + // `build_reusable_module` hands back nothing for a module built from + // those signals, leaving it to be resolved per request against the + // signals that request actually carried. + let ec_module = build_reusable_module( + &settings.ec, + Some(Arc::new(FastlyHostSignals::default())), + None, + )?; let plan = Arc::new(compile_auction_plan(&settings)?); plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Fastly)?; @@ -298,6 +311,23 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime ..ClientInfo::default() }); + // The TLS JA4 and HTTP/2 signals arrive as trusted internal headers + // injected by the entry point. They build the host-signal service a + // host-signal module reads. Fastly always supplies the capability, so the + // service is always set even when a request carried no signal. + let tls_ja4 = ctx + .request() + .headers() + .get("x-ts-tls-ja4") + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + let h2_fingerprint = ctx + .request() + .headers() + .get("x-ts-h2-fingerprint") + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + let builder = RuntimeServices::builder() .config_store(Arc::new(FastlyPlatformConfigStore)) .secret_store(Arc::new(FastlyPlatformSecretStore)) @@ -309,14 +339,19 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime .template_assembler(Arc::new(crate::esi_assembly::FastlyTemplateAssembler)) .backend(Arc::new(FastlyPlatformBackend)) .http_client(Arc::new(FastlyPlatformHttpClient)) - .geo(Arc::new(FastlyPlatformGeo)) + .geo(build_geo_module( + &state.settings, + Arc::new(FastlyPlatformGeo), + )) .auction_telemetry_sink(Arc::clone(&state.auction_telemetry_sink)) - .client_info(client_info); + .client_info(client_info) + .host_signals(Arc::new(FastlyHostSignals::new(tls_ja4, h2_fingerprint))); // Hand every request the module resolved at the composition root, so the // request path reuses that instance instead of resolving `[ec] module` - // again. Nothing is set for a deployment that selects no module, which - // resolves to nothing either way. + // again. Nothing is set for a deployment that selects no module, or one + // whose module is built from this request's own host signals, and both + // are resolved on the request path instead. match state.ec_module.clone() { Some(module) => builder.resolved_ec_module(module).build(), None => builder.build(), @@ -442,7 +477,7 @@ fn build_ec_request_state( req: &Request, ) -> EcRequestState { let device_signals = device_signals_for(req); - let is_real_browser = device_signals.looks_like_browser(); + let is_real_browser = device_signals.looks_like_browser; if !is_real_browser { log::info!( "Bot gate: blocking EC operations (ja4={:?}, platform={:?}, is_mobile={})", @@ -776,7 +811,7 @@ async fn run_named_route( /// response finalization. fn run_batch_sync(state: &AppState, services: &RuntimeServices, req: Request) -> Response { let device_signals = device_signals_for(&req); - let is_real_browser = device_signals.looks_like_browser(); + let is_real_browser = device_signals.looks_like_browser; let eids_cookie = crate::extract_cookie_value(&req, COOKIE_TS_EIDS); let sharedid_cookie = crate::extract_cookie_value(&req, COOKIE_SHAREDID); @@ -1625,8 +1660,9 @@ mod tests { Arc::new(crate::platform::UnavailableKvStore) as Arc; // Resolved the same way the composition root resolves it, so this // router behaves like a served one. - let ec_module = trusted_server_core::ec::module::build_shared_module(&settings.ec, None) - .expect("should resolve the Edge Cookie module selection"); + let ec_module = + trusted_server_core::ec::module::build_reusable_module(&settings.ec, None, None) + .expect("should resolve the Edge Cookie module selection"); let state = Arc::new(super::AppState { auction_telemetry_sink: Arc::new( trusted_server_core::auction::NoopAuctionTelemetrySink, diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 5832c33cb3..5da109fb19 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -5,7 +5,9 @@ use edgezero_adapter_fastly::request::into_core_request; use edgezero_core::body::Body as EdgeBody; use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::error::EdgeError; -use edgezero_core::http::{Request as HttpRequest, Response as HttpResponse}; +use edgezero_core::http::{ + HeaderMap, HeaderValue, Request as HttpRequest, Response as HttpResponse, header, +}; use edgezero_core::response::IntoResponse; use error_stack::Report; use fastly::http::Method as FastlyMethod; @@ -13,7 +15,7 @@ use fastly::{Request as FastlyRequest, Response as FastlyResponse}; use trusted_server_core::cache_policy::{EdgeCacheHeader, cache_control_headers_have_directive}; use trusted_server_core::constants::HEADER_X_TS_VERSION; -use trusted_server_core::ec::device::DeviceSignals; +use trusted_server_core::ec::device::{DeviceModule, DeviceSignals, build_device_module}; use trusted_server_core::ec::finalize::ec_finalize_response; use trusted_server_core::ec::kv::KvIdentityGraph; use trusted_server_core::ec::pull_sync::{ @@ -21,12 +23,14 @@ use trusted_server_core::ec::pull_sync::{ }; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::TrustedServerError; +use trusted_server_core::evidence::{BorrowedRequestInfo, HostSignals}; use trusted_server_core::integrations::RequestFilterEffects; -use trusted_server_core::platform::PlatformGeo as _; +use trusted_server_core::platform::build_geo_module; use trusted_server_core::proxy::{AssetProxyCachePolicy, stream_asset_body}; use trusted_server_core::response_privacy::TerminalPrivateResponse; use trusted_server_core::settings::Settings; use trusted_server_core::version_header::{apply_git_version_header, git_version_header_value}; +use trusted_server_device_fastly::{FastlyDeviceModule, FastlyHostSignals}; mod app; mod backend; @@ -454,7 +458,42 @@ fn edgezero_main(mut req: FastlyRequest, sandbox: &mut Sandbox, ordinal: u64, re // request extensions for build_per_request_services and EC bot classification. let client_info = client_info_from_request(&req, resolved_client_ip); let client_ip = client_info.client_ip; - let device_signals = derive_device_signals(&req); + + // Strip and re-inject the TLS JA4 and HTTP/2 signals from the + // authoritative Fastly SDK values, under the same trust model, so the + // EdgeZero app path can build the host-signal service from these internal + // headers (the SDK accessors return real values only on the live client + // request, not on a request rebuilt from EdgeZero HTTP types). + req.remove_header("x-ts-tls-ja4"); + req.remove_header("x-ts-h2-fingerprint"); + // Take ownership before setting: unlike the static TLS protocol/cipher + // names, these accessors borrow the request, which would otherwise conflict + // with the mutable `set_header`. + if let Some(ja4) = req.get_tls_ja4().map(str::to_string) { + req.set_header("x-ts-tls-ja4", ja4); + } + if let Some(h2) = req.get_client_h2_fingerprint().map(str::to_string) { + req.set_header("x-ts-h2-fingerprint", h2); + } + + // Derive device signals from the original FastlyRequest before conversion. + // Fastly's `get_tls_ja4()` and `get_client_h2_fingerprint()` accessors only + // return real values on the client request; a synthetic request rebuilt from + // EdgeZero HTTP types cannot expose them, which would strip the JA4/H2 class + // the EC bot gate needs and misclassify real browsers as bots. Stored in the + // request extensions so `build_ec_request_state` reads the authoritative + // signals instead of re-deriving from the reconstructed request. + // Reuse the settings snapshot already loaded for the app state rather than + // fetching and validating the config-store blob a second time per request. + let device_signals = match settings_snapshot.as_deref() { + Some(settings) => derive_device_signals(settings, &req), + None => { + log::warn!( + "EdgeZero device signals: settings unavailable, using UA-only classification" + ); + DeviceSignals::derive_ua_only(req.get_header_str("user-agent").unwrap_or("")) + } + }; // Dispatch directly through the EdgeZero router without an intermediate // fastly::Response conversion. That preserves duplicate header values such @@ -583,8 +622,12 @@ fn apply_entry_point_finalize_headers( response: &mut HttpResponse, client_ip: Option, ) { + // Route through the [geo] module selector, so a deployment that opts + // out of geolocation makes no host geo call on the entry-point finalize + // path either. + let geo = build_geo_module(settings, Arc::new(FastlyPlatformGeo)); let geo_info = resolve_geo_for_response(response, client_ip, |client_ip| { - FastlyPlatformGeo.lookup(client_ip).unwrap_or_else(|e| { + geo.lookup(client_ip).unwrap_or_else(|e| { log::warn!("entry-point geo lookup failed: {e}"); None }) @@ -830,16 +873,33 @@ pub(crate) fn extract_cookie_value(req: &HttpRequest, name: &str) -> Option DeviceSignals { - let ua = req.get_header_str("user-agent").unwrap_or(""); - let ja4 = req.get_tls_ja4(); - let h2_fp = req.get_client_h2_fingerprint(); - - DeviceSignals::derive(ua, ja4, h2_fp) +/// The modules read request data from injected services. Device +/// classification reads only the User-Agent, borrowed here through a +/// `BorrowedRequestInfo`, unless `fastly` is selected, in which case the Fastly +/// module also reads the TLS and HTTP/2 signals captured into a +/// [`FastlyHostSignals`]. The Fastly entry point still reads those TLS and +/// HTTP/2 signals on every request to build the host-signal service and client +/// info, so the capture is not conditional on the module selection. +pub(crate) fn derive_device_signals(settings: &Settings, req: &FastlyRequest) -> DeviceSignals { + let mut headers = HeaderMap::new(); + if let Some(value) = req + .get_header_str(header::USER_AGENT.as_str()) + .and_then(|user_agent| HeaderValue::from_str(user_agent).ok()) + { + headers.insert(header::USER_AGENT, value); + } + let client_ip = req + .get_client_ip_addr() + .map(|ip| ip.to_string()) + .unwrap_or_default(); + let request_info = BorrowedRequestInfo::new(&client_ip, None).with_headers(&headers); + build_device_module(settings, || { + let host_signals: Arc = Arc::new(FastlyHostSignals::from_request(req)); + Box::new(FastlyDeviceModule::new(host_signals)) as Box + }) + .detect(&request_info) } #[cfg(test)] diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index d0f4ee9d0e..4584c8e4f3 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -3,7 +3,6 @@ use bytes::Bytes; use error_stack::{Report, ResultExt}; -use fastly::geo::{Geo, geo_lookup}; use fastly::{ConfigStore, Request, SecretStore}; use std::io::Read as _; use std::net::IpAddr; @@ -11,12 +10,11 @@ use std::net::IpAddr; use crate::backend::BackendConfig; pub(crate) use trusted_server_core::platform::UnavailableKvStore; use trusted_server_core::platform::{ - BackendNamingPolicy, ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, - PlatformCacheIntent, PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, - PlatformHttpRequest, PlatformImageOptimizerCrop, PlatformImageOptimizerCropMode, - PlatformImageOptimizerOptions, PlatformImageOptimizerParams, PlatformImageOptimizerRegion, - PlatformPendingRequest, PlatformResponse, PlatformSecretStore, PlatformSelectResult, StoreId, - StoreName, + BackendNamingPolicy, ClientInfo, PlatformBackend, PlatformBackendSpec, PlatformCacheIntent, + PlatformConfigStore, PlatformError, PlatformHttpClient, PlatformHttpRequest, + PlatformImageOptimizerCrop, PlatformImageOptimizerCropMode, PlatformImageOptimizerOptions, + PlatformImageOptimizerParams, PlatformImageOptimizerRegion, PlatformPendingRequest, + PlatformResponse, PlatformSecretStore, PlatformSelectResult, StoreId, StoreName, }; use trusted_server_core::settings::TrustedClientIpConfig; @@ -654,33 +652,12 @@ impl PlatformHttpClient for FastlyPlatformHttpClient { // FastlyPlatformGeo // --------------------------------------------------------------------------- -/// Convert a Fastly [`Geo`] value into a platform-neutral [`GeoInfo`]. -/// -/// Shared by `FastlyPlatformGeo::lookup` in `trusted-server-adapter-fastly` so -/// that field mapping is never duplicated. -fn geo_from_fastly(geo: &Geo) -> GeoInfo { - GeoInfo { - city: geo.city().to_string(), - country: geo.country_code().to_string(), - continent: format!("{:?}", geo.continent()), - latitude: geo.latitude(), - longitude: geo.longitude(), - metro_code: geo.metro_code(), - region: geo.region().map(str::to_string), - asn: None, - } -} - -/// Fastly geo-lookup implementation of [`PlatformGeo`]. -pub struct FastlyPlatformGeo; - -impl PlatformGeo for FastlyPlatformGeo { - fn lookup(&self, client_ip: Option) -> Result, Report> { - Ok(client_ip - .and_then(geo_lookup) - .map(|geo| geo_from_fastly(&geo))) - } -} +/// The Fastly host geo module lives in its own crate, +/// `trusted-server-geo-fastly`, as every module implementation sits under +/// `crates//`. It is re-exported here so this module's +/// [`build_runtime_services`] and the adapter's call sites refer to it +/// through `crate::platform`. +pub(crate) use trusted_server_geo_fastly::FastlyPlatformGeo; fn single_utf8_header<'a>(req: &'a Request, name: &str) -> Option<&'a str> { let mut values = req.get_header_all(name); diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index d5cc7a881e..ce40eb5931 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -24,7 +24,7 @@ use trusted_server_core::ec::admin::{ admin_ec_lookup_not_supported as core_admin_ec_lookup_not_supported, deny_admin_diagnostic_fallback, handle_admin_eids_lookup, }; -use trusted_server_core::ec::module::{EdgeCookieModule, build_shared_module}; +use trusted_server_core::ec::module::{EdgeCookieModule, build_reusable_module}; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::sanitize_forwarded_headers; @@ -153,10 +153,12 @@ fn build_state_with_services( // is served, so a selection this adapter can never supply fails here rather // than on the first request. Keeping what the resolution produced is what // stops the request path resolving the same settings again. This adapter - // injects no vendor Edge Cookie module of its own, so the only injected - // module is one a caller put into the services it supplied. - let ec_module = build_shared_module( + // supplies no host signals, so that argument is `None`, and injects no + // vendor Edge Cookie module of its own, so the only injected module is + // one a caller put into the services it supplied. + let ec_module = build_reusable_module( &settings.ec, + None, services .as_ref() .and_then(RuntimeServices::resolved_ec_module), @@ -179,10 +181,12 @@ impl AppState { /// Builds the per-request services, carrying the Edge Cookie module the /// composition root already resolved so the request path does not resolve /// `[ec] module` a second time. + /// Nothing is carried when the composition root found nothing safe to keep, + /// and the request path resolves for itself. fn services_for_request(&self, ctx: &RequestContext) -> RuntimeServices { self.services .clone() - .unwrap_or_else(|| build_runtime_services(ctx)) + .unwrap_or_else(|| build_runtime_services(ctx, &self.settings)) .with_resolved_ec_module(self.ec_module.clone()) } } @@ -1202,7 +1206,7 @@ mod tests { // No resolved module is threaded here, so the request path resolves // the selection itself, which is what an embedder driving core // directly does and where the loud failure has to stay. - let services = build_runtime_services(&ctx); + let services = build_runtime_services(&ctx, &settings); let req = ctx.into_request(); let error = build_ec_context(&settings, &services, &req) diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index b1ced0f70a..cec9732c25 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -704,7 +704,10 @@ impl PlatformSecretStore for SpinSecretStoreAdapter { /// before routing. Secrets are read synchronously from Spin component /// variables because Trusted Server's platform secret trait is sync. #[must_use] -pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> RuntimeServices { +pub fn build_runtime_services( + ctx: &edgezero_core::context::RequestContext, + settings: &trusted_server_core::settings::Settings, +) -> RuntimeServices { let client_ip = extract_client_ip(ctx); #[cfg(all(feature = "spin", target_arch = "wasm32"))] @@ -733,7 +736,13 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R .kv_store(kv_store) .backend(Arc::new(NoopBackend)) .http_client(http_client) - .geo(Arc::new(NullGeo)) + // Routed through the [geo] module selector like the Fastly adapter, + // so the selector behaves the same on every adapter. Spin has no host + // geo service, so the host default resolves nothing either way. + .geo(trusted_server_core::platform::build_geo_module( + settings, + Arc::new(NullGeo), + )) .client_info(ClientInfo { client_ip, tls_protocol: None, @@ -1040,7 +1049,8 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn build_runtime_services_uses_noop_native_stores_without_handles() { let ctx = make_ctx_without_spin_context(); - let services = build_runtime_services(&ctx); + let services = + build_runtime_services(&ctx, &trusted_server_core::settings::Settings::default()); assert!( services.client_info().client_ip.is_none(), diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 7137bca9f6..3548d6006c 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -13,7 +13,7 @@ use error_stack::Report; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use validator::{Validate, ValidationError, ValidationErrors, ValidationErrorsKind}; -use crate::ec::module::HMAC_MODULE_KEY; +use crate::ec::module::{HMAC_MODULE_KEY, HOST_SIGNALS_MODULE_KEY}; use crate::ec::registry::PartnerRegistry; use crate::error::TrustedServerError; use crate::integrations::{ @@ -153,11 +153,18 @@ fn remove_labeled_module_secret_errors(errors: &mut ValidationErrors, ec: &Ec) { let Some(ValidationErrorsKind::Struct(ec_errors)) = errors.errors_mut().get_mut("ec") else { return; }; - for (name, _) in ec + let labeled = ec .module_blocks .hmac_blocks() - .filter(|(name, _)| *name != HMAC_MODULE_KEY) - { + .map(|(name, _)| name) + .filter(|name| *name != HMAC_MODULE_KEY) + .chain( + ec.module_blocks + .host_signals_blocks() + .map(|(name, _)| name) + .filter(|name| *name != HOST_SIGNALS_MODULE_KEY), + ); + for name in labeled { let Some(ValidationErrorsKind::Struct(block_errors)) = ec_errors.errors_mut().get_mut(name) else { continue; @@ -176,17 +183,17 @@ fn remove_labeled_module_secret_errors(errors: &mut ValidationErrors, ec: &Ec) { } impl crate::secret_resolution::ConfiguredSecretFields for TrustedServerAppConfig { - /// The passphrase of every Edge Cookie module block that configures the - /// built-in HMAC module under a label. + /// The passphrase of every Edge Cookie module block that configures a + /// module built into core under a label. /// /// [`secret_fields`](edgezero_core::app_config::AppConfigMeta::secret_fields) - /// lists the passphrase of the `[ec.hmac]` block, the one path this - /// module's block has when its name is its implementation. The same - /// module under a label of the operator's choosing holds that secret at - /// `ec.