From c281894101eeac60704fdf79567c81101b2c5f04 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 11 Oct 2026 15:37:10 +0100 Subject: [PATCH 1/8] A vendor's modules share one table `[shared.]` holds what every module of one vendor reads, such as an endpoint and a license key, so the key is written once. Core declares the tables and reads none of them. A vendor's crates read their own as typed settings through `Settings::shared_config`, which refuses a table that cannot be read as the type or fails its validation, naming the table. A table whose name is not a folder name is refused when the settings load. A builder marked with `with_shared_settings` is run by its vendor's table rather than by a selection, so a document without the table runs none of that vendor's modules, and a selection naming one of them is refused with the absent table named. A table that no builder reads is refused when the registry is built, the one place every builder is known. A secret in the table is declared with the builder's secret settings at its path inside the table and resolved when the table is present, as a module's own secrets are. The seam probe gains a second builder its table runs, whose route answers the table's marker, so the Axum adapter proves the table from outside core. --- crates/testing/seam-probe/src/lib.rs | 114 ++++++++ .../tests/seam_probe.rs | 42 +++ .../src/integrations/mod.rs | 19 ++ .../src/integrations/registry.rs | 165 +++++++++++- .../trusted-server-core/src/module_secrets.rs | 70 ++++- crates/trusted-server-core/src/settings.rs | 244 ++++++++++++++++++ 6 files changed, 647 insertions(+), 7 deletions(-) diff --git a/crates/testing/seam-probe/src/lib.rs b/crates/testing/seam-probe/src/lib.rs index c836a78c3d..3de40faab2 100644 --- a/crates/testing/seam-probe/src/lib.rs +++ b/crates/testing/seam-probe/src/lib.rs @@ -753,6 +753,120 @@ pub fn builder() -> IntegrationBuilder { .with_adserver(&SEAM_PROBE_ADSERVER) } +/// The vendor's folder under `crates/shared`, which names the table +/// `[shared.seam-probe]` that runs [`shared_builder`]. +pub const SEAM_PROBE_SHARED_VENDOR: &str = "seam-probe"; + +/// The id of the builder the shared table runs. +pub const SEAM_PROBE_SHARED_ID: &str = "seam_probe_shared"; + +/// The route the shared builder answers with the table's marker. +pub const SEAM_PROBE_SHARED_PATH: &str = "/integrations/seam_probe_shared/marker"; + +/// The shared route's suffix under the builder's proxy prefix. +const SHARED_ROUTE_SUFFIX: &str = "/marker"; + +/// What `[shared.seam-probe]` carries. +#[derive(Debug, Clone, Deserialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct SeamProbeShared { + /// The text the shared route answers with. + #[validate(length(min = 1))] + pub marker: String, +} + +/// A route answering the shared table's marker, which proves the table ran +/// the builder and reached what it registered. +#[derive(Debug)] +struct SharedMarkerRoute { + marker: String, +} + +impl SharedMarkerRoute { + async fn route( + &self, + _req: Request, + ) -> Result, Report> { + Response::builder() + .status(200) + .header( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ) + .body(EdgeBody::from(self.marker.clone().into_bytes())) + .map_err(|error| { + Report::new(TrustedServerError::Integration { + integration: SEAM_PROBE_SHARED_ID.to_string(), + message: error.to_string(), + }) + }) + } +} + +#[async_trait(?Send)] +impl IntegrationProxy for SharedMarkerRoute { + fn integration_name(&self) -> &'static str { + SEAM_PROBE_SHARED_ID + } + + fn routes(&self) -> Vec { + vec![self.get(SHARED_ROUTE_SUFFIX)] + } + + async fn handle( + &self, + call: ModuleCall<'_>, + req: Request, + ) -> Result, Report> { + call.inject_with(self, req, Self::route)?.await + } +} + +/// Registers the shared route when `[shared.seam-probe]` is present. +/// +/// # Errors +/// +/// Returns an error when the table cannot be read or fails its validation. +pub fn register_shared( + settings: &Settings, +) -> Result, Report> { + let Some(shared) = settings.shared_config::(SEAM_PROBE_SHARED_VENDOR)? else { + return Ok(None); + }; + Ok(Some( + IntegrationRegistration::builder(SEAM_PROBE_SHARED_ID) + .without_js() + .with_proxy(Arc::new(SharedMarkerRoute { + marker: shared.marker, + })) + .build(), + )) +} + +/// Reports whether the shared table is present, validating it. +/// +/// # Errors +/// +/// Returns an error when the table cannot be read or fails its validation. +pub fn validate_shared(settings: &Settings) -> Result> { + Ok(settings + .shared_config::(SEAM_PROBE_SHARED_VENDOR)? + .is_some()) +} + +/// The builder `[shared.seam-probe]` runs, which proves a vendor's shared +/// table from outside core. +#[must_use] +pub fn shared_builder() -> IntegrationBuilder { + IntegrationBuilder::new( + SEAM_PROBE_SHARED_ID, + SEAM_PROBE_SOURCE, + register_shared, + validate_shared, + ) + .with_shared_settings(SEAM_PROBE_SHARED_VENDOR) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/trusted-server-adapter-axum/tests/seam_probe.rs b/crates/trusted-server-adapter-axum/tests/seam_probe.rs index 02c9c1a113..877ed3972f 100644 --- a/crates/trusted-server-adapter-axum/tests/seam_probe.rs +++ b/crates/trusted-server-adapter-axum/tests/seam_probe.rs @@ -256,6 +256,48 @@ async fn proxy_route_reports_the_modules_geo_and_that_the_preparer_ran() { ); } +/// `[shared.seam-probe]` runs the builder the probe shares, whose route +/// answers the table's marker. Without the table the builder is not built, +/// and the table with no builder reading it is refused at startup. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_vendors_shared_table_runs_its_builder() { + let mut with = service_with( + settings_with(&format!( + "[shared.seam-probe]\nmarker = \"from the shared table\"\n{PROBE_BLOCK}" + )), + &[seam_probe::builder(), seam_probe::shared_builder()], + ); + let response = get(&mut with, seam_probe::SEAM_PROBE_SHARED_PATH).await; + assert_eq!( + response.status().as_u16(), + 200, + "the table runs the builder" + ); + assert_eq!(body_text(response).await, "from the shared table"); + + let mut without = service_with( + settings_with(PROBE_BLOCK), + &[seam_probe::builder(), seam_probe::shared_builder()], + ); + let response = get(&mut without, seam_probe::SEAM_PROBE_SHARED_PATH).await; + assert_ne!( + response.status().as_u16(), + 200, + "without the table the builder is not built, so the route is not this server's" + ); + + let refused = TrustedServerApp::routes_with_registrations( + settings_with("[shared.seam-probe]\nmarker = \"x\"\n"), + &[seam_probe::builder()], + ) + .err() + .expect("nothing reads the table"); + assert!( + format!("{refused:?}").contains("[shared.seam-probe] is read by no module"), + "{refused:?}" + ); +} + /// A name `[robots-txt] modules` gives that nothing the deployment runs /// supplies stops the adapter at startup, so the file does not fail on the /// first crawler to ask for it. diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index c1780a4512..bf0f991a78 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -424,6 +424,7 @@ pub struct IntegrationBuilder { module: Option<&'static str>, section: Option<&'static str>, loading_cache: Option, + shared: Option<&'static str>, } /// Gives the loading cache a module supplies, see @@ -469,6 +470,7 @@ impl IntegrationBuilder { module: None, section: None, loading_cache: None, + shared: None, } } @@ -497,6 +499,7 @@ impl IntegrationBuilder { module: None, section: None, loading_cache: None, + shared: None, } } @@ -639,6 +642,22 @@ impl IntegrationBuilder { self } + /// Runs this builder when `[shared.]` is present, for a vendor + /// whose modules all read that one table and cannot be built without it. + /// A builder the table runs is selected by no section. + #[must_use] + pub const fn with_shared_settings(mut self, vendor: &'static str) -> Self { + self.shared = Some(vendor); + self + } + + /// The vendor whose `[shared.]` table runs this builder, when one + /// does. + #[must_use] + pub const fn shared_settings(&self) -> Option<&'static str> { + self.shared + } + /// The loading cache this builder offers, when it offers one. #[must_use] pub const fn loading_cache(&self) -> Option { diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index c768b0eec3..8b310e8763 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -912,6 +912,9 @@ struct IntegrationRegistryInner { // Every builder's module name, with whether a section selects it, so the // refusal of a name a selector wrote can say what that name is. builder_modules: Vec<(&'static str, bool)>, + /// The vendors whose `[shared.]` table a builder is run by and + /// the settings do not carry, so none of that vendor's modules runs. + absent_shared: Vec<&'static str>, // The module `[geo] module` resolved to, or `None` when the selector // is `platform` and the adapter's own host lookup stands. Unset and `none` // both resolve the disabled module. @@ -948,6 +951,7 @@ impl Default for IntegrationRegistryInner { device_modules: Vec::new(), robots_txt_contributors: Vec::new(), builder_modules: Vec::new(), + absent_shared: Vec::new(), geo_module: None, ec_module: None, device_module: None, @@ -1014,6 +1018,27 @@ impl IntegrationRegistryInner { /// nothing it supplies is running, or one that is selected and supplies no /// module of this kind. Empty when no builder's module answers to the /// name. + /// A sentence naming the shared tables whose absence kept their vendors' + /// modules from running, to follow a refusal of a module nothing supplies, + /// or nothing when every builder a shared table runs is running. + fn absent_shared_note(&self) -> String { + if self.absent_shared.is_empty() { + return String::new(); + } + let tables = self + .absent_shared + .iter() + .map(|vendor| format!("[shared.{vendor}]")) + .collect::>() + .join(" or "); + let those = if self.absent_shared.len() == 1 { + "that table runs" + } else { + "those tables run" + }; + format!(". There is no {tables}, so the modules {those} are not running") + } + fn builder_note(&self, type_folder: &str, kind: &str, written: &str) -> String { let names: Vec<&str> = self.builder_modules.iter().map(|(name, _)| *name).collect(); let Some(name) = crate::module_name::resolve(type_folder, written, &names) else { @@ -1096,8 +1121,9 @@ fn named_module( Err(Report::new(TrustedServerError::Configuration { message: format!( "`[{section}] module` names `{written}`, which no module this deployment runs \ - supplies. {runs}{}", - inner.builder_note(type_folder, section, written) + supplies. {runs}{}{}", + inner.builder_note(type_folder, section, written), + inner.absent_shared_note() ), })) } @@ -1588,6 +1614,33 @@ impl IntegrationRegistry { let mut plan_registrations: Vec = Vec::new(); let mut registrations: Vec = Vec::new(); + // A vendor's shared table is read by that vendor's builder alone, so + // one no builder reads is a table the deployment cannot use. + let unread: Vec<&str> = settings + .shared + .vendors() + .filter(|vendor| { + !crate::integrations::all_builders(extra) + .any(|builder| builder.shared_settings() == Some(*vendor)) + }) + .collect(); + if !unread.is_empty() { + let read: Vec<&str> = crate::integrations::all_builders(extra) + .filter_map(|builder| builder.shared_settings()) + .collect(); + let reads = if read.is_empty() { + "It reads none".to_owned() + } else { + format!("The shared tables it reads are [{}]", read.join(", ")) + }; + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "[shared.{}] is read by no module this deployment supplies. {reads}", + unread.join("] and [shared."), + ), + })); + } + for builder in crate::integrations::all_builders(extra) { if let Some((_, first_source)) = inner.builder_ids.iter().find(|(id, _)| *id == builder.id()) @@ -1633,7 +1686,19 @@ impl IntegrationRegistry { if let Some(name) = builder.module_name() { inner.builder_modules.push((name, selected)); } - if !selected { + // A builder a vendor's shared table runs is built when the table + // is present, whatever any section selects. + let runs = match builder.shared_settings() { + Some(vendor) => { + let present = settings.shared.contains(vendor); + if !present && !inner.absent_shared.contains(&vendor) { + inner.absent_shared.push(vendor); + } + present + } + None => selected, + }; + if !runs { continue; } @@ -2405,6 +2470,7 @@ impl IntegrationRegistry { device_modules: Vec::new(), robots_txt_contributors: Vec::new(), builder_modules: Vec::new(), + absent_shared: Vec::new(), geo_module: None, ec_module: None, device_module: None, @@ -2483,6 +2549,7 @@ impl IntegrationRegistry { device_modules: Vec::new(), robots_txt_contributors: Vec::new(), builder_modules: Vec::new(), + absent_shared: Vec::new(), geo_module: None, ec_module: None, device_module: None, @@ -4686,6 +4753,98 @@ mod tests { ); } + const SHARED_VENDOR: &str = "example"; + + fn shared_probe_registration( + _settings: &Settings, + ) -> Result, Report> { + Ok(Some( + IntegrationRegistration::builder("shared_probe").build(), + )) + } + + /// The `shared_probe` builder, run by `[shared.example]`. + fn shared_probe_builder() -> crate::integrations::IntegrationBuilder { + crate::integrations::IntegrationBuilder::new( + "shared_probe", + "seam-probe", + shared_probe_registration, + validate_nothing, + ) + .with_shared_settings(SHARED_VENDOR) + } + + fn with_shared_table(mut settings: Settings) -> Settings { + settings.shared = + serde_json::from_value(serde_json::json!({ SHARED_VENDOR: { "key": "value" } })) + .expect("should read the shared tables"); + settings + } + + #[test] + fn a_builder_its_shared_table_runs_is_built_only_when_the_table_is_present() { + let extra = [shared_probe_builder()]; + + let without = IntegrationRegistry::with_registrations( + &crate::test_support::tests::create_test_settings(), + &extra, + ) + .expect("nothing is built, which is not an error"); + assert!(!without.integration_runs("shared_probe")); + + let with = IntegrationRegistry::with_registrations( + &with_shared_table(crate::test_support::tests::create_test_settings()), + &extra, + ) + .expect("should build the builder its table runs"); + assert!(with.integration_runs("shared_probe")); + } + + #[test] + fn a_shared_table_no_builder_reads_is_refused() { + let refused = IntegrationRegistry::with_registrations( + &with_shared_table(crate::test_support::tests::create_test_settings()), + &[], + ) + .err() + .expect("nothing reads the table") + .to_string(); + + assert!( + refused.contains("[shared.example] is read by no module"), + "{refused}" + ); + } + + #[test] + fn a_builder_its_shared_table_runs_answers_to_no_selection() { + let refused = IntegrationRegistry::with_registrations( + &with_shared_table(settings_naming("shared_probe")), + &[shared_probe_builder()], + ) + .err() + .expect("the table runs the builder, so no section can select it") + .to_string(); + + assert!(refused.contains("selects `shared_probe`"), "{refused}"); + } + + #[test] + fn a_selection_nothing_supplies_names_the_shared_table_that_is_absent() { + let mut settings = crate::test_support::tests::create_test_settings(); + settings.geo.module = Some("example".to_owned()); + + let refused = IntegrationRegistry::with_registrations(&settings, &[shared_probe_builder()]) + .err() + .expect("no geo module is supplied") + .to_string(); + + assert!( + refused.contains("There is no [shared.example]"), + "the message has to name the table to add: {refused}" + ); + } + #[test] fn with_registrations_rejects_a_duplicate_integration_id_naming_both_sources() { let settings = crate::test_support::tests::create_test_settings(); diff --git a/crates/trusted-server-core/src/module_secrets.rs b/crates/trusted-server-core/src/module_secrets.rs index b075d3fa98..ce6ee7b3ca 100644 --- a/crates/trusted-server-core/src/module_secrets.rs +++ b/crates/trusted-server-core/src/module_secrets.rs @@ -15,10 +15,14 @@ use serde_json::{Map, Value}; use crate::integrations::IntegrationBuilder; use crate::module_name::short_form; -/// The section a module is selected in and the names its table can sit -/// under there: the name with the section's type folder left off, and the -/// name in full where the two differ. +/// The section a module's table sits under and the names it can be written +/// at there, being the name with the section's type folder left off and the +/// name in full where the two differ, or a vendor's shared table under +/// `shared`, at the vendor's name alone. fn written_forms(builder: &IntegrationBuilder) -> Option<(&'static str, Vec<&'static str>)> { + if let Some(vendor) = builder.shared_settings() { + return Some((crate::settings::SHARED_TYPE, vec![vendor])); + } let name = builder.module_name()?; let section = builder.section()?; let short = short_form(section, name); @@ -100,7 +104,13 @@ pub(crate) fn clear_unused(data: &mut Value, builders: &[IntegrationBuilder]) { let Some((section, forms)) = written_forms(builder) else { continue; }; - let selected = selected_as(data, section, &forms); + // A vendor's table runs its builder, so a secret in the table counts + // as in use by the table's own rules, and an absent table has nothing + // to clear. + let selected = match builder.shared_settings() { + Some(vendor) => Some(vendor), + None => selected_as(data, section, &forms), + }; for form in &forms { let Some(table) = table_mut(data, section, form) else { continue; @@ -189,6 +199,58 @@ mod tests { .with_secret_settings(SECRETS) } + const VENDOR: &str = "example"; + + fn shared_builder() -> IntegrationBuilder { + IntegrationBuilder::new( + "shared_probe", + "seam-probe", + probe_registration, + validate_nothing, + ) + .with_shared_settings(VENDOR) + .with_secret_settings(SECRETS) + } + + #[test] + fn a_secret_in_a_shared_table_is_kept_when_the_table_is_present_and_in_use() { + let mut data = json!({ "shared": { VENDOR: table(true, false) } }); + + clear_unused(&mut data, &[shared_builder()]); + + assert_eq!( + data.pointer("/shared/example/key_name"), + Some(&json!("main-key")), + "the table runs the builder, so its setting in use stays" + ); + assert_eq!(data.pointer("/shared/example/spare/key_name"), None); + } + + #[test] + fn a_secret_in_a_shared_table_is_declared_under_the_vendors_name() { + let paths: Vec = secret_fields(&[shared_builder()]) + .iter() + .map(|field| { + field + .path + .iter() + .map(|segment| match segment { + SecretPathSegment::Field(name) | SecretPathSegment::OptionalField(name) => { + name.to_string() + } + _ => "*".to_owned(), + }) + .collect::>() + .join(".") + }) + .collect(); + + assert_eq!( + paths, + vec!["shared.example.key_name", "shared.example.spare.key_name"] + ); + } + fn table(lock: bool, spare: bool) -> Value { json!({ "lock": lock, diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index aef91fc5f5..19e82c4edc 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -326,6 +326,103 @@ impl TypeSections { /// The type states which settings the module takes and how they are /// validated, and nothing else. Whether the module runs is not its business, /// because the section's selection names what runs. +/// The section, and the folder under `crates`, that holds what one vendor's +/// modules share. +pub const SHARED_TYPE: &str = "shared"; + +/// The `[shared]` section, one `[shared.]` table per vendor, holding +/// the settings every module of that vendor reads, such as an endpoint and a +/// license key. +/// +/// Core reads none of it. A vendor's crates read their own table through +/// [`Settings::shared_config`], and a builder marked with +/// [`IntegrationBuilder::with_shared_settings`](crate::integrations::IntegrationBuilder::with_shared_settings) +/// runs when its vendor's table is present. A table that no builder reads is +/// refused where the registry is built, the only place every builder is known. +#[derive(Clone, Default, Deserialize, Serialize)] +#[serde(transparent)] +pub struct SharedTables(BTreeMap>); + +impl std::fmt::Debug for SharedTables { + // The tables hold secrets, so only the vendors are shown. + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("SharedTables") + .field("vendors", &self.vendors().collect::>()) + .finish() + } +} + +impl SharedTables { + /// Whether no vendor has a table. + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// The vendors with a table, in name order. + pub fn vendors(&self) -> impl Iterator { + self.0.keys().map(String::as_str) + } + + /// Whether `[shared.]` is present. + #[must_use] + pub fn contains(&self, vendor: &str) -> bool { + self.0.contains_key(vendor) + } + + /// Reads and validates `[shared.]` as `T`, or `None` when the table + /// is absent. + /// + /// # Errors + /// + /// When the table cannot be read as `T` or fails its validation, naming + /// the table and the underlying error. + pub fn get_typed(&self, vendor: &str) -> Result, Report> + where + T: DeserializeOwned + Validate, + { + let Some(table) = self.0.get(vendor) else { + return Ok(None); + }; + let config: T = + serde_json::from_value(JsonValue::Object(table.clone())).map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: format!("[{SHARED_TYPE}.{vendor}] could not be read: {error}"), + }) + })?; + config.validate().map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: format!("[{SHARED_TYPE}.{vendor}] failed validation: {error}"), + }) + })?; + Ok(Some(config)) + } + + /// Refuses a table whose name is not one folder name, being lower case + /// letters, digits, `_` or `-`, because a vendor's table is named by its + /// folder under `crates/shared`. + /// + /// # Errors + /// + /// Naming the first table at fault. + pub fn validate_names(&self) -> Result<(), Report> { + match self + .vendors() + .find(|vendor| vendor.contains('.') || !crate::module_name::is_valid(vendor)) + { + Some(vendor) => Err(Report::new(TrustedServerError::Configuration { + message: format!( + "[{SHARED_TYPE}] has a table named `{vendor}`, which is not a vendor's \ + folder name. A vendor's shared table is named by its folder under \ + crates/{SHARED_TYPE}, of lower case letters, digits, `_` or `-`" + ), + })), + None => Ok(()), + } + } +} + pub trait IntegrationConfig: DeserializeOwned + Validate {} /// A partner (SSP, DSP, identity vendor) configured in `[[ec.partners]]`. @@ -4053,6 +4150,9 @@ pub struct Settings { pub consent: ConsentConfig, #[serde(default)] pub cache: CacheSettings, + /// What one vendor's modules share, a table per vendor, `[shared.]`. + #[serde(default, skip_serializing_if = "SharedTables::is_empty")] + pub shared: SharedTables, #[serde(default)] pub proxy: Proxy, #[serde(default)] @@ -4288,6 +4388,7 @@ impl Settings { .auction_html_comment_options .validate_metadata_keys()?; self.validate_asset_image_optimizer_profile_sets()?; + self.shared.validate_names()?; if let Some(robots_txt) = &self.robots_txt { robots_txt.prepare_runtime()?; self.require_robots_txt_placed()?; @@ -4560,6 +4661,20 @@ impl Settings { Ok(Some(config)) } + /// Reads `[shared.]`, the settings every module of `vendor` + /// shares, or `None` when the table is absent. + /// + /// # Errors + /// + /// When the table cannot be read as `T` or fails its validation, naming + /// the table. + pub fn shared_config(&self, vendor: &str) -> Result, Report> + where + T: DeserializeOwned + Validate, + { + self.shared.get_typed(vendor) + } + /// The modules `section` selects, to change, with the section created /// empty when it is a module type's and absent. fn section_modules_mut(&mut self, section: &str) -> &mut SectionModules { @@ -10484,4 +10599,133 @@ mod permission_signal_config_tests { "the refusal names the block and says why it is refused: {message}" ); } + + mod shared_tables { + use super::*; + + #[derive(Debug, Deserialize, Validate)] + #[serde(deny_unknown_fields)] + struct ExampleShared { + #[validate(url)] + endpoint: String, + } + + fn with(toml: &str) -> Result> { + Settings::from_toml(&format!("{}\n{toml}", crate_test_settings_str())) + } + + #[test] + fn a_vendor_reads_its_own_shared_table_and_nothing_else() { + let settings = with( + r#" + [shared.example] + endpoint = "https://api.example.com/v1" + "#, + ) + .expect("should load"); + + let read: ExampleShared = settings + .shared_config("example") + .expect("should read") + .expect("the table is present"); + assert_eq!(read.endpoint, "https://api.example.com/v1"); + assert!( + settings + .shared_config::("another") + .expect("an absent table reads as nothing") + .is_none() + ); + } + + #[test] + fn a_shared_table_its_vendor_cannot_read_is_refused_naming_the_table() { + let settings = with( + r#" + [shared.example] + endpoint = "https://api.example.com/v1" + unknown = true + "#, + ) + .expect("core reads none of the table, so it loads"); + + let refused = format!( + "{:?}", + settings + .shared_config::("example") + .expect_err("the vendor does not know the key") + ); + assert!( + refused.contains("[shared.example] could not be read") + && refused.contains("unknown"), + "{refused}" + ); + } + + #[test] + fn a_shared_table_failing_its_validation_is_refused_naming_the_table() { + let settings = with( + r#" + [shared.example] + endpoint = "not-a-url" + "#, + ) + .expect("core reads none of the table, so it loads"); + + let refused = format!( + "{:?}", + settings + .shared_config::("example") + .expect_err("the endpoint is not a URL") + ); + assert!( + refused.contains("[shared.example] failed validation"), + "{refused}" + ); + } + + #[test] + fn a_shared_table_not_named_as_a_folder_is_refused() { + for table in ["[shared.Example]", "[shared.\"an.example\"]"] { + let refused = with(&format!("{table}\nendpoint = \"https://api.example.com\"")) + .expect_err("a vendor's table is named by its folder"); + + assert!( + format!("{refused:?}").contains("which is not a vendor's folder name"), + "{table}: {refused:?}" + ); + } + } + + /// The tables hold secrets, so a settings dump shows the vendors alone, + /// and the serialized settings carry the tables whole. + #[test] + fn a_shared_table_is_shown_by_its_vendor_and_serialized_whole() { + let settings = with( + r#" + [shared.example] + endpoint = "https://api.example.com/v1" + license_key = "a-secret-value" + "#, + ) + .expect("should load"); + + let shown = format!("{:?}", settings.shared); + assert!(shown.contains("example"), "{shown}"); + assert!(!shown.contains("a-secret-value"), "{shown}"); + + let serialized = serde_json::to_value(&settings).expect("should serialize"); + assert_eq!( + serialized.pointer("/shared/example/endpoint"), + Some(&serde_json::json!("https://api.example.com/v1")) + ); + } + + #[test] + fn settings_without_a_shared_section_are_written_without_one() { + let settings = with("").expect("should load"); + + let serialized = serde_json::to_value(&settings).expect("should serialize"); + assert!(serialized.get("shared").is_none()); + } + } } From ad011ff711ac7b5eb422af087dc9b3d3f7b6b437 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 11 Oct 2026 15:37:11 +0100 Subject: [PATCH 2/8] The guides describe a vendor's shared table The configuration guide lists `[shared]` among the sections and describes the tables, what runs a builder, the refusals and how a secret in a table is written. The integration guide's builder table gains `.with_shared_settings(...)` and a section on reading the table as typed settings. The example configuration notes the table beside its list of sections, and the changelog records the addition. --- CHANGELOG.md | 1 + docs/guide/configuration.md | 15 +++++++++++++++ docs/guide/integration-guide.md | 14 ++++++++++++++ trusted-server.example.toml | 5 ++++- 4 files changed, 34 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0f61e91c5..b6c573809d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `[shared.]`, one table per vendor holding what every module of that vendor shares, read by the vendor's crates through `Settings::shared_config` and by nothing in core. A builder marked with `IntegrationBuilder::with_shared_settings` runs when its vendor's table is present and is selected by no section, a table no builder reads is refused when the registry is built, a table not named as a folder is refused when the settings load, a refusal of a module nothing supplies names the absent table, and a secret in the table is declared and resolved as a module's own are. - `robots_txt`, core's own middleware for the file `[robots-txt]` describes, named in a `[[fetch]]` or `[[serve]]` entry for `text/plain` on `/robots.txt`. Its preparation makes the file from the selected modules through their holding, and its action replaces whatever the origin sent with it, so the file replaces the origin's own and the creating rule makes it where the origin has none. A module that cannot answer with nothing held refuses the request with `503` and `Retry-After` before any document is processed. A section with no entry naming the middleware is refused when the settings load. - A middleware may prepare, and may refuse. `Middleware::prepare` runs in the request's handler before the document is processed, as the middleware's module with the request's services, and what it answers is handed to `create` under the middleware's name, so a decision that needs a store or the network is made there while `create` stays synchronous. A refusal answers the request with its status and `Retry-After`, with no document, and nothing is stored. - A loading cache. `PlatformLoadingCache` in core loads a missing value once per key however many requests ask for it at the same moment, gives every waiting request the value or the error, and holds nothing that failed. A crate offers one through `IntegrationBuilder::with_loading_cache`, each feature that holds answers selects the module whose cache holds them in its own `[cache.]` table, and an adapter takes the selections with `loading_caches_from`. A feature that selects none gets `NoLoadingCache`, which loads every time. The new `crates/loading-cache/51degrees`, the module `loading-cache.51degrees`, implements it with the published `fiftyone-caching` package as a brief copy in process memory over the host's value cache or key-value store, and the stock list ships it, so every adapter takes it. A load that may finish apart from its request runs on a pool of threads on Axum and as a task of its own on Cloudflare, where it keeps running after the response only when the Worker's handling of the request is wrapped in the crate's `scope_request`, and every other load runs in the caller. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 175073a01a..8b97e4a55c 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -242,6 +242,7 @@ fail and the service will return its startup-error response. | `[rewrite]` | nothing | First-party URL rewrite exclusions | | `[robots-txt]` | several modules | What `/robots.txt` answers, and `X-Robots-Tag` on its responses when every crawler is refused | | `[[serve]]` | nothing | Which page changes run on each reader's copy of a page, and in what order | +| `[shared]` | nothing | What one vendor's modules share, a table per vendor, which runs that vendor's builder | | `[tester_cookie]` | nothing | Optional tester-cookie endpoints | | `[trusted_client_ip]` | nothing | Authenticated front-door client-IP forwarding | | `[]`, such as `[cmp]`, `[tag]`, `[ad-tag]`, `[bot-protection]`, `[identity]`, `[audience]` or `[framework]` | one module or several | The section of a module type, selecting the modules of that type that run | @@ -1128,6 +1129,20 @@ path = "/ads.txt" middleware = ["ads-txt.sellers"] ``` +## Shared Settings + +### `[shared]` + +What every module of one vendor shares, in one `[shared.]` table per vendor, named by the vendor's folder under `crates/shared`. Core reads none of a table. The vendor's modules read their own through `Settings::shared_config`, and the table's presence is what runs the builder that registers them, so a document without it runs none of that vendor's modules and a selection naming one is refused with the table to add. A builder a table runs is selected by no section. A table that no module in the deployment reads is refused at startup, and so is a table whose name is not a folder name. + +A secret in a shared table is written as the name of its entry in the secret store, declared by the vendor's builder as a module's own secret settings are, and resolved when the table is present. + +```toml +[shared.example] +endpoint = "https://api.example.com/v1" +license_key = "example_license_key" +``` + ## Request Signing Configuration for Ed25519 request signing. diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index e15cca028e..a77b35a65e 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -360,6 +360,7 @@ points at the functions that do the work. | `.with_plan_validator(...)` | Optional. Checks the module's settings against the compiled auction plan, when a deployment is validated and as the settings load | | `.with_demand(...)` and `.with_adserver(...)` | Optional. Registers an auction implementation that `[demand]` or `[ad-server]` can name. A crate that supplies nothing else starts from `IntegrationBuilder::implementations(...)` | | `.with_loading_cache(...)` | Optional. Offers the loading cache the crate supplies, which a feature selects by the module's name in its `[cache.]` table. An adapter takes the selections with `loading_caches_from` | +| `.with_shared_settings(...)` | Optional. Runs the builder when its vendor's `[shared.]` table is present, for a vendor whose modules all read that one table. A builder the table runs is selected by no section | ```rust pub fn module_name() -> &'static str { @@ -788,6 +789,19 @@ the list does not order page changes, which run in the order an entry names them. Offering a module does not run it, because the registry builds a module only when a section of the settings selects it. +### A vendor's shared table + +What every module of one vendor needs, such as an endpoint and a license key, +goes in one `[shared.]` table, named by the vendor's folder under +`crates/shared`. Core reads none of it. The vendor's crates read it as their +own typed settings with `Settings::shared_config::(vendor)`, which refuses +a table that cannot be read as `T` or fails its validation, naming the table. +The vendor's builder marks itself with `.with_shared_settings(vendor)`, and +the registry builds it when the table is present and refuses a table that no +builder reads. A secret in the table is declared with +`.with_secret_settings(...)` at its path inside the table, as a module's own +secrets are, and is resolved from the secret store when the table is present. + ### Supplying a loading cache Some answers cost a call to make and are the same for many requests, such as diff --git a/trusted-server.example.toml b/trusted-server.example.toml index b544b80330..54cc2f265b 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -31,7 +31,10 @@ # robots-txt, analytics and integration. A settings table its type's selector # does not name refuses startup, so there is no stale block to misread. The # complete rules, and what is checked when, are in -# docs/guide/configuration-rules.md. +# docs/guide/configuration-rules.md. [shared.] holds what one vendor's +# modules share, such as an endpoint and a license key. That vendor's crates +# alone read it, its presence runs the builder they share, and a table no +# module reads is refused. # # `TRUSTED_SERVER__` env vars can override values when the `ts` CLI builds and # validates this config for push (e.g. TRUSTED_SERVER__PUBLISHER__DOMAIN=...; From 1f320b346271bba1c6738dc1bebcdf9a542b03b2 Mon Sep 17 00:00:00 2001 From: James Rogers Date: Mon, 12 Oct 2026 15:18:02 +0100 Subject: [PATCH 3/8] A builder its shared table runs takes no module name The registry refuses a builder marked with with_shared_settings that also has a module name, since a section could otherwise select what the table runs. A refusal of a list selection now names the absent shared table, as the single selector refusal does. Two misplaced doc comments are back on their items, and no comment or guide names a crates/shared folder. --- CHANGELOG.md | 4 +- crates/testing/seam-probe/src/lib.rs | 4 +- .../src/integrations/mod.rs | 3 +- .../src/integrations/registry.rs | 94 ++++++++++++++----- .../trusted-server-core/src/module_secrets.rs | 3 +- crates/trusted-server-core/src/settings.rs | 36 +++---- docs/guide/configuration.md | 2 +- docs/guide/integration-guide.md | 18 ++-- trusted-server.example.toml | 9 +- 9 files changed, 108 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38a8720456..89cfa305c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,8 +81,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `[shared.]`, one table per vendor holding what every module of that vendor shares, read by the vendor's crates through `Settings::shared_config` and by nothing in core. A builder marked with `IntegrationBuilder::with_shared_settings` runs when its vendor's table is present and is selected by no section, a table no builder reads is refused when the registry is built, a table not named as a folder is refused when the settings load, a refusal of a module nothing supplies names the absent table, and a secret in the table is declared and resolved as a module's own are. -- `robots_txt`, core's own middleware for the file `[robots-txt]` describes, named in a `[[fetch]]` entry for `text/plain` on `/robots.txt`. Its preparation makes the file from the selected modules through their holding, and its action replaces the origin's file with it, and the creating rule makes it where the origin answers `404`, or `200` with a page. A module that cannot answer with nothing held refuses the request with `503` and `Retry-After` before any document is processed. A section with no such entry, or with an entry naming the middleware on another path or media type, is refused when the settings load. +- `[shared.]`, one table per vendor holding what every module of that vendor shares, read by the vendor's crates through `Settings::shared_config` and by nothing in core. A builder marked with `IntegrationBuilder::with_shared_settings` runs when its vendor's table is present and is selected by no section, a table no builder reads is refused when the registry is built, a table whose name is not lower case letters, digits, `_` or `-` is refused when the settings load, a refusal of a module nothing supplies names the absent table, and a secret in the table is declared and resolved as a module's own are. +- `robots_txt`, core's own middleware for the file `[robots-txt]` describes, named in a `[[fetch]]` or `[[serve]]` entry for `text/plain` on `/robots.txt`. Its preparation makes the file from the selected modules through their holding, and its action replaces whatever the origin sent with it, so the file replaces the origin's own and the creating rule makes it where the origin has none. A module that cannot answer with nothing held refuses the request with `503` and `Retry-After` before any document is processed. A section with no entry naming the middleware is refused when the settings load. - A middleware may prepare, and may refuse. `Middleware::prepare` runs in the request's handler before the document is processed, as the middleware's module with the request's services, and what it answers is handed to `create` under the middleware's name, so a decision that needs a store or the network is made there while `create` stays synchronous. A refusal answers the request with its status and `Retry-After`, with no document, and nothing is stored. - A loading cache. `PlatformLoadingCache` in core loads a missing value once per key however many requests ask for it at the same moment, gives every waiting request the value or the error, and holds nothing that failed. A crate offers one through `IntegrationBuilder::with_loading_cache`, each feature that holds answers selects the module whose cache holds them in its own `[cache.]` table, and an adapter takes the selections with `loading_caches_from`. A feature that selects none gets `NoLoadingCache`, which loads every time. The new `crates/loading-cache/51degrees`, the module `loading-cache.51degrees`, implements it with the published `fiftyone-caching` package as a brief copy in process memory over the host's value cache or key-value store, and the stock list ships it, so every adapter takes it. Every load runs in its caller. - `[cache.robots-txt]` selects, with `module`, the loading cache that holds each robots.txt module's answer, and sets the lifetimes of its copies, in seconds: `memory_seconds` (default 60), `store_seconds` (default 604800) and `store_idle_seconds` (default 2592000). A memory lifetime may be 0, the others must be at least 1, and none may be more than a year. diff --git a/crates/testing/seam-probe/src/lib.rs b/crates/testing/seam-probe/src/lib.rs index 3850d902d6..45e83bab59 100644 --- a/crates/testing/seam-probe/src/lib.rs +++ b/crates/testing/seam-probe/src/lib.rs @@ -751,8 +751,8 @@ pub fn builder() -> IntegrationBuilder { .with_adserver(&SEAM_PROBE_ADSERVER) } -/// The vendor's folder under `crates/shared`, which names the table -/// `[shared.seam-probe]` that runs [`shared_builder`]. +/// The vendor's name, which names the table `[shared.seam-probe]` that runs +/// [`shared_builder`]. pub const SEAM_PROBE_SHARED_VENDOR: &str = "seam-probe"; /// The id of the builder the shared table runs. diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index bb9ba66bc7..f10e6c8465 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -644,7 +644,8 @@ impl IntegrationBuilder { /// Runs this builder when `[shared.]` is present, for a vendor /// whose modules all read that one table and cannot be built without it. - /// A builder the table runs is selected by no section. + /// A builder the table runs is selected by no section, so the registry + /// refuses one that also has a module name. #[must_use] pub const fn with_shared_settings(mut self, vendor: &'static str) -> Self { self.shared = Some(vendor); diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 2da25c1b0d..fb71daa815 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -906,8 +906,7 @@ struct IntegrationRegistryInner { // Every builder's module name, with whether a section selects it, so the // refusal of a name a selector wrote can say what that name is. builder_modules: Vec<(&'static str, bool)>, - /// The vendors whose `[shared.]` table a builder is run by and - /// the settings do not carry, so none of that vendor's modules runs. + // Vendors whose shared table is absent, for the refusal note. absent_shared: Vec<&'static str>, // The module `[geo] module` resolved to, or `None` when the selector // is `platform` and the adapter's own host lookup stands. Unset and `none` @@ -1012,6 +1011,24 @@ impl IntegrationRegistryInner { /// nothing it supplies is running, or one that is selected and supplies no /// module of this kind. Empty when no builder's module answers to the /// name. + fn builder_note(&self, type_folder: &str, kind: &str, written: &str) -> String { + let names: Vec<&str> = self.builder_modules.iter().map(|(name, _)| *name).collect(); + let Some(name) = crate::module_name::resolve(type_folder, written, &names) else { + return String::new(); + }; + let selected = self + .builder_modules + .iter() + .any(|(module, selected)| *module == name && *selected); + if selected { + format!(". `{written}` is selected and supplies no {kind} module") + } else { + format!( + ". `{written}` is a module no section selects, so nothing it supplies is running" + ) + } + } + /// A sentence naming the shared tables whose absence kept their vendors' /// modules from running, to follow a refusal of a module nothing supplies, /// or nothing when every builder a shared table runs is running. @@ -1032,24 +1049,6 @@ impl IntegrationRegistryInner { }; format!(". There is no {tables}, so the modules {those} are not running") } - - fn builder_note(&self, type_folder: &str, kind: &str, written: &str) -> String { - let names: Vec<&str> = self.builder_modules.iter().map(|(name, _)| *name).collect(); - let Some(name) = crate::module_name::resolve(type_folder, written, &names) else { - return String::new(); - }; - let selected = self - .builder_modules - .iter() - .any(|(module, selected)| *module == name && *selected); - if selected { - format!(". `{written}` is selected and supplies no {kind} module") - } else { - format!( - ". `{written}` is a module no section selects, so nothing it supplies is running" - ) - } - } } /// The module a name written in a section means, among those the running @@ -1479,6 +1478,7 @@ pub struct IntegrationRegistry { fn check_section_selections( settings: &Settings, extra: &[crate::integrations::IntegrationBuilder], + inner: &IntegrationRegistryInner, ) -> Result<(), Report> { let offered: Vec<&'static str> = crate::integrations::all_builders(extra) .filter_map(|builder| builder.module_name()) @@ -1513,7 +1513,8 @@ fn check_section_selections( return Err(Report::new(TrustedServerError::Configuration { message: format!( "[{section}] selects `{written}`, which no module this deployment \ - supplies. {supplied}" + supplies. {supplied}{}", + inner.absent_shared_note() ), })); } @@ -1643,6 +1644,19 @@ impl IntegrationRegistry { })); } inner.builder_ids.push((builder.id(), builder.source())); + // A builder its shared table runs answers to no section, so a + // module name would let a section select what it does not run. + if let (Some(vendor), Some(name)) = (builder.shared_settings(), builder.module_name()) { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "integration `{}` from `{}` is run by [shared.{vendor}] and also names \ + the module `{name}`. A builder its shared table runs is selected by \ + no section, so it takes no module name", + builder.id(), + builder.source() + ), + })); + } // Preparers are collected before the selection check, so an // integration can sanitize its own reserved query or cookie in a // deployment that does not run it. @@ -1706,7 +1720,7 @@ impl IntegrationRegistry { // the selections are checked against them rather than in the // settings. Deploy validation deliberately does not make this check, // because a vendor crate the CLI never links may supply the name. - check_section_selections(settings, extra)?; + check_section_selections(settings, extra, &inner)?; // The geo, Edge Cookie and device module names taken so far, with the // integration that supplies each. @@ -4815,6 +4829,42 @@ mod tests { ); } + #[test] + fn a_list_selection_nothing_supplies_names_the_shared_table_that_is_absent() { + let mut settings = crate::test_support::tests::create_test_settings(); + settings.select_module("identity", "example.id"); + + let refused = IntegrationRegistry::with_registrations(&settings, &[shared_probe_builder()]) + .err() + .expect("no identity module is supplied") + .to_string(); + + assert!( + refused.contains("[identity] selects `example.id`") + && refused.contains("There is no [shared.example]"), + "the message has to name the table to add: {refused}" + ); + } + + #[test] + fn a_builder_its_shared_table_runs_is_refused_a_module_name() { + let mut settings = with_shared_table(crate::test_support::tests::create_test_settings()); + settings.geo.module = Some("example".to_owned()); + + let refused = IntegrationRegistry::with_registrations( + &settings, + &[shared_probe_builder().with_module_name("geo.example")], + ) + .err() + .expect("a section could select what the table runs") + .to_string(); + + assert!( + refused.contains("is run by [shared.example] and also names the module `geo.example`"), + "{refused}" + ); + } + #[test] fn with_registrations_rejects_a_duplicate_integration_id_naming_both_sources() { let settings = crate::test_support::tests::create_test_settings(); diff --git a/crates/trusted-server-core/src/module_secrets.rs b/crates/trusted-server-core/src/module_secrets.rs index ce6ee7b3ca..24e9653fc6 100644 --- a/crates/trusted-server-core/src/module_secrets.rs +++ b/crates/trusted-server-core/src/module_secrets.rs @@ -105,8 +105,7 @@ pub(crate) fn clear_unused(data: &mut Value, builders: &[IntegrationBuilder]) { continue; }; // A vendor's table runs its builder, so a secret in the table counts - // as in use by the table's own rules, and an absent table has nothing - // to clear. + // as in use by the table's own rules. let selected = match builder.shared_settings() { Some(vendor) => Some(vendor), None => selected_as(data, section, &forms), diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 9317cb4dc4..cb761e94a1 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -320,25 +320,12 @@ impl TypeSections { } } -/// The settings type a module reads from its table, beneath the section that -/// selects it. -/// -/// The type states which settings the module takes and how they are -/// validated, and nothing else. Whether the module runs is not its business, -/// because the section's selection names what runs. -/// The section, and the folder under `crates`, that holds what one vendor's -/// modules share. +/// The section that holds what one vendor's modules share. pub const SHARED_TYPE: &str = "shared"; /// The `[shared]` section, one `[shared.]` table per vendor, holding /// the settings every module of that vendor reads, such as an endpoint and a -/// license key. -/// -/// Core reads none of it. A vendor's crates read their own table through -/// [`Settings::shared_config`], and a builder marked with -/// [`IntegrationBuilder::with_shared_settings`](crate::integrations::IntegrationBuilder::with_shared_settings) -/// runs when its vendor's table is present. A table that no builder reads is -/// refused where the registry is built, the only place every builder is known. +/// license key, read through [`Settings::shared_config`]. #[derive(Clone, Default, Deserialize, Serialize)] #[serde(transparent)] pub struct SharedTables(BTreeMap>); @@ -399,9 +386,8 @@ impl SharedTables { Ok(Some(config)) } - /// Refuses a table whose name is not one folder name, being lower case - /// letters, digits, `_` or `-`, because a vendor's table is named by its - /// folder under `crates/shared`. + /// Refuses a table whose name is not a vendor's name, being lower case + /// letters, digits, `_` or `-`. /// /// # Errors /// @@ -414,8 +400,8 @@ impl SharedTables { Some(vendor) => Err(Report::new(TrustedServerError::Configuration { message: format!( "[{SHARED_TYPE}] has a table named `{vendor}`, which is not a vendor's \ - folder name. A vendor's shared table is named by its folder under \ - crates/{SHARED_TYPE}, of lower case letters, digits, `_` or `-`" + name. A vendor's shared table is named in lower case letters, \ + digits, `_` or `-`" ), })), None => Ok(()), @@ -423,6 +409,12 @@ impl SharedTables { } } +/// The settings type a module reads from its table, beneath the section that +/// selects it. +/// +/// The type states which settings the module takes and how they are +/// validated, and nothing else. Whether the module runs is not its business, +/// because the section's selection names what runs. pub trait IntegrationConfig: DeserializeOwned + Validate {} /// A partner (SSP, DSP, identity vendor) configured in `[[ec.partners]]`. @@ -10771,13 +10763,13 @@ mod permission_signal_config_tests { } #[test] - fn a_shared_table_not_named_as_a_folder_is_refused() { + fn a_shared_table_not_named_as_a_vendor_is_refused() { for table in ["[shared.Example]", "[shared.\"an.example\"]"] { let refused = with(&format!("{table}\nendpoint = \"https://api.example.com\"")) .expect_err("a vendor's table is named by its folder"); assert!( - format!("{refused:?}").contains("which is not a vendor's folder name"), + format!("{refused:?}").contains("which is not a vendor's name"), "{table}: {refused:?}" ); } diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index f09b4bda28..2da1852af0 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1130,7 +1130,7 @@ middleware = ["ads-txt.sellers"] ### `[shared]` -What every module of one vendor shares, in one `[shared.]` table per vendor, named by the vendor's folder under `crates/shared`. Core reads none of a table. The vendor's modules read their own through `Settings::shared_config`, and the table's presence is what runs the builder that registers them, so a document without it runs none of that vendor's modules and a selection naming one is refused with the table to add. A builder a table runs is selected by no section. A table that no module in the deployment reads is refused at startup, and so is a table whose name is not a folder name. +What every module of one vendor shares, in one `[shared.]` table per vendor. Core reads none of a table. The vendor's modules read their own through `Settings::shared_config`, and the table's presence is what runs the builder that registers them, so settings without it run none of that vendor's modules and a selection naming one is refused with the table to add. A builder a table runs is selected by no section. A table that no module in the deployment reads is refused at startup, and so is a table whose name is not lower case letters, digits, `_` or `-`. A secret in a shared table is written as the name of its entry in the secret store, declared by the vendor's builder as a module's own secret settings are, and resolved when the table is present. diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index a0861f9e59..55ac0eb2c7 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -783,15 +783,15 @@ only when a section of the settings selects it. ### A vendor's shared table What every module of one vendor needs, such as an endpoint and a license key, -goes in one `[shared.]` table, named by the vendor's folder under -`crates/shared`. Core reads none of it. The vendor's crates read it as their -own typed settings with `Settings::shared_config::(vendor)`, which refuses -a table that cannot be read as `T` or fails its validation, naming the table. -The vendor's builder marks itself with `.with_shared_settings(vendor)`, and -the registry builds it when the table is present and refuses a table that no -builder reads. A secret in the table is declared with -`.with_secret_settings(...)` at its path inside the table, as a module's own -secrets are, and is resolved from the secret store when the table is present. +goes in one `[shared.]` table. Core reads none of it. The vendor's +crates read it as their own typed settings with +`Settings::shared_config::(vendor)`, which refuses a table that cannot be +read as `T` or fails its validation, naming the table. The vendor's builder +marks itself with `.with_shared_settings(vendor)`, and the registry builds it +when the table is present and refuses a table that no builder reads. A secret +in the table is declared with `.with_secret_settings(...)` at its path inside +the table, as a module's own secrets are, and is resolved from the secret +store when the table is present. ### Supplying a loading cache diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 54cc2f265b..08e6e7f319 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -31,10 +31,11 @@ # robots-txt, analytics and integration. A settings table its type's selector # does not name refuses startup, so there is no stale block to misread. The # complete rules, and what is checked when, are in -# docs/guide/configuration-rules.md. [shared.] holds what one vendor's -# modules share, such as an endpoint and a license key. That vendor's crates -# alone read it, its presence runs the builder they share, and a table no -# module reads is refused. +# docs/guide/configuration-rules.md. +# +# [shared.] holds what one vendor's modules share, such as an endpoint +# and a license key. That vendor's crates alone read it, its presence runs the +# builder they share, and a table no module reads is refused. # # `TRUSTED_SERVER__` env vars can override values when the `ts` CLI builds and # validates this config for push (e.g. TRUSTED_SERVER__PUBLISHER__DOMAIN=...; From 9a272a30b7c652f119cffc4825e65ba177ec3e5f Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 09:24:51 +0100 Subject: [PATCH 4/8] A refused shared table names the setting and never its value By the time a vendor reads and validates its shared table at startup, the table holds each of its secrets resolved from the secret store. A table that could not be read or failed its validation was refused with the underlying error, which can quote the value at fault. The serde crate quotes a string it cannot read as the type asked for, and the validator crate prints each failed rule's parameters, the checked value among them. Each adapter logs the whole failure when its state fails to build. The refusal now names the table and the setting at fault and never the setting's value. A read error gives the path of the setting, found with serde_path_to_error, which edgezero-core already depends on, and keeps serde's own message only when it names keys alone, being a key missing, unknown or written twice. A validation error goes through validation_error_summary, as core's own settings do, which names the field, the rule and the rule's own message. --- Cargo.lock | 1 + Cargo.toml | 1 + crates/trusted-server-core/Cargo.toml | 1 + crates/trusted-server-core/src/settings.rs | 109 ++++++++++++++++++--- 4 files changed, 99 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 52cd43d6e9..6c50177179 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6971,6 +6971,7 @@ dependencies = [ "regex", "serde", "serde_json", + "serde_path_to_error", "serde_yaml_ng", "sha2 0.10.9", "subtle", diff --git a/Cargo.toml b/Cargo.toml index 66ce690e29..f15a4c7340 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -137,6 +137,7 @@ rustls-pemfile = "2" scraper = "0.24.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.149" +serde_path_to_error = "0.1.20" serde_yaml_ng = "0.10" sha2 = "0.10.9" similar = "2.7" diff --git a/crates/trusted-server-core/Cargo.toml b/crates/trusted-server-core/Cargo.toml index e68b1eca70..db4278f5b3 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -42,6 +42,7 @@ rand = { workspace = true } regex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +serde_path_to_error = { workspace = true } serde_yaml_ng = { workspace = true } sha2 = { workspace = true } subtle = { workspace = true } diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 12f9d7d00a..6c925ee506 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -364,7 +364,7 @@ impl SharedTables { /// # Errors /// /// When the table cannot be read as `T` or fails its validation, naming - /// the table and the underlying error. + /// the table and the setting at fault but never the setting's value. pub fn get_typed(&self, vendor: &str) -> Result, Report> where T: DeserializeOwned + Validate, @@ -372,18 +372,7 @@ impl SharedTables { let Some(table) = self.0.get(vendor) else { return Ok(None); }; - let config: T = - serde_json::from_value(JsonValue::Object(table.clone())).map_err(|error| { - Report::new(TrustedServerError::Configuration { - message: format!("[{SHARED_TYPE}.{vendor}] could not be read: {error}"), - }) - })?; - config.validate().map_err(|error| { - Report::new(TrustedServerError::Configuration { - message: format!("[{SHARED_TYPE}.{vendor}] failed validation: {error}"), - }) - })?; - Ok(Some(config)) + read_table(&format!("{SHARED_TYPE}.{vendor}"), table.clone()).map(Some) } /// Refuses a table whose name is not a vendor's name, being lower case @@ -409,6 +398,57 @@ impl SharedTables { } } +/// Reads and validates a table as `T`, where `place` is the table's name as +/// written, such as `shared.example`. +/// +/// A message names the setting at fault and the rule it broke, and never +/// quotes the setting's value, because at startup a table holds each of its +/// secrets resolved from the secret store. +fn read_table( + place: &str, + table: serde_json::Map, +) -> Result> +where + T: DeserializeOwned + Validate, +{ + let config: T = + serde_path_to_error::deserialize(JsonValue::Object(table)).map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: format!( + "[{place}] could not be read: {}", + read_error_summary(&error) + ), + }) + })?; + config.validate().map_err(|errors| { + Report::new(TrustedServerError::Configuration { + message: format!( + "[{place}] failed validation: {}", + validation_error_summary(&errors) + ), + }) + })?; + Ok(config) +} + +/// Why a table could not be read, by the path of the setting at fault. +/// +/// `serde` quotes the value it could not read, so its message is kept only +/// when it names keys alone: a key missing, unknown or written twice. +fn read_error_summary(error: &serde_path_to_error::Error) -> String { + let message = error.inner().to_string(); + let names_keys_only = ["missing field `", "unknown field `", "duplicate field `"] + .iter() + .any(|start| message.starts_with(start)); + let path = error.path().to_string(); + match (path.as_str(), names_keys_only) { + (".", true) => message, + (".", false) => "a setting holds a value of the wrong type or form".to_owned(), + (path, true) => format!("{path}: {message}"), + (path, false) => format!("`{path}` holds a value of the wrong type or form"), + } +} + /// The settings type a module reads from its table, beneath the section that /// selects it. /// @@ -11120,6 +11160,49 @@ mod permission_signal_config_tests { ); } + /// A vendor's settings with a secret in them, as the table holds them + /// at startup, once the secret is resolved from the store. + #[derive(Debug, Deserialize, Validate)] + #[serde(deny_unknown_fields)] + struct SecretShared { + #[validate(length(max = 8))] + license_key: String, + #[validate(range(max = 10))] + retries: Option, + } + + const SECRET: &str = "resolved-secret-value-4f7a"; + + #[test] + fn a_shared_table_that_is_refused_names_the_setting_and_never_its_value() { + for (table, setting) in [ + // Fails a rule, whose parameters carry the value checked. + (format!("license_key = '{SECRET}'"), "license_key"), + // Cannot be read, where `serde` quotes the value it found. + ( + format!("license_key = 'short'\nretries = '{SECRET}'"), + "retries", + ), + ] { + let settings = with(&format!("[shared.example]\n{table}")) + .expect("core reads none of the table, so it loads"); + + let refused = settings + .shared_config::("example") + .expect_err("the table breaks the vendor's rules"); + + let shown = format!("{refused}\n{refused:?}"); + assert!( + !shown.contains(SECRET), + "printed a setting's value: {shown}" + ); + assert!( + shown.contains("[shared.example]") && shown.contains(setting), + "should name the table and the setting: {shown}" + ); + } + } + #[test] fn a_shared_table_its_vendor_cannot_read_is_refused_naming_the_table() { let settings = with( From 31b0fc561f045f71d3c1498f8826e839cbb62f9e Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 09:24:55 +0100 Subject: [PATCH 5/8] A refused module table names the setting and never its value A module's own table holds the module's secrets resolved from the store at startup in the same way, and module_config refused it with the underlying error in the same way. It now reads the table through the same function as a shared table, so its refusals name the setting at fault and never its value. --- crates/trusted-server-core/src/settings.rs | 70 +++++++++++++++++----- 1 file changed, 56 insertions(+), 14 deletions(-) diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 6c925ee506..2facf0f3df 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -4780,13 +4780,13 @@ impl Settings { /// A selected module with no table reads an empty one, so a module that /// takes no settings runs on its selection alone and one that requires a /// setting reports the setting it is missing. The parse and validation - /// messages carry the underlying error, because a report renders only its - /// outermost message in `ts config validate`. + /// messages name the setting at fault in the outermost message, because a + /// report renders only that in `ts config validate`. /// /// # Errors /// /// When the table cannot be read as `T` or fails its validation, naming the - /// table. + /// table and the setting at fault but never the setting's value. pub fn module_config(&self, name: &str) -> Result, Report> where T: IntegrationConfig, @@ -4800,17 +4800,7 @@ impl Settings { .and_then(|(_, modules)| modules.settings_of(written)) .cloned() .unwrap_or_default(); - let config: T = serde_json::from_value(JsonValue::Object(table)).map_err(|error| { - Report::new(TrustedServerError::Configuration { - message: format!("[{section}.{written}] could not be read: {error}"), - }) - })?; - config.validate().map_err(|error| { - Report::new(TrustedServerError::Configuration { - message: format!("[{section}.{written}] failed validation: {error}"), - }) - })?; - Ok(Some(config)) + read_table(&format!("{section}.{written}"), table).map(Some) } /// Reads `[shared.]`, the settings every module of `vendor` @@ -9253,6 +9243,58 @@ source_domain = "partner.example.com" ); } + /// An example module's settings with a secret in them, as its table holds + /// them at startup, once the secret is resolved from the store. + #[derive(Debug, Deserialize, Validate)] + #[serde(deny_unknown_fields)] + struct SecretSetting { + #[validate(url)] + endpoint: String, + #[validate(range(max = 10))] + retries: Option, + } + + impl IntegrationConfig for SecretSetting {} + + #[test] + fn a_module_table_that_is_refused_names_the_setting_and_never_its_value() { + const SECRET: &str = "resolved-secret-value-4f7a"; + for (table, setting) in [ + // Fails a rule, whose parameters carry the value checked. + (serde_json::json!({ "endpoint": SECRET }), "endpoint"), + // Cannot be read, where `serde` quotes the value it found. + ( + serde_json::json!({ "endpoint": "https://example.com", "retries": SECRET }), + "retries", + ), + ] { + let mut settings = create_test_settings(); + settings + .insert_module_config(EXAMPLE_SECTION, ENDPOINT_MODULE, &table) + .expect("should insert the module's table"); + + let error = settings + .module_config::(ENDPOINT_MODULE) + .expect_err("the table breaks the module's rules"); + + let shown = format!("{error}\n{error:?}"); + assert!( + !shown.contains(SECRET), + "printed a setting's value: {shown}" + ); + // The module's name holds `endpoint` too, so the setting is looked + // for after the table's name. + let rendered = error.to_string(); + let reason = rendered + .split_once("[example.endpoint]") + .map_or("", |(_, reason)| reason); + assert!( + reason.contains(setting), + "should name the table and the setting: {rendered}" + ); + } + } + /// A table written for a module its section does not select is refused, /// rather than sitting in the configuration doing nothing. #[test] From a38f95e9034d72cbfaca1871139d2b2006d0bc6c Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 09:24:59 +0100 Subject: [PATCH 6/8] Push checks that a shared table's secret settings name a key The push check that each secret setting in use names a key found a builder's secrets through its module name and the section selecting it. A builder its vendor's shared table runs has neither, so a secret setting in a shared table was never checked, although the configuration rules say every secret setting is. The check now reads such a builder's secrets from its vendor's table when the table is present, with the rule the loader applies for whether the table uses a setting, and names the setting as shared... --- crates/trusted-server-core/src/config.rs | 91 +++++++++++++++++++--- crates/trusted-server-core/src/settings.rs | 7 ++ 2 files changed, 89 insertions(+), 9 deletions(-) diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 6e939ac7db..d5321c0422 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -502,20 +502,36 @@ fn validate_secret_key_references( )?; } - // Each setting a selected module declares as naming a secret, where the - // module's table puts it to use. + // Each setting a running builder declares as naming a secret, where the + // builder's table puts it to use. A vendor's shared table runs the + // builders that carry its mark, and a section runs the module it selects. for builder in crate::integrations::all_builders(extra_integrations) { let secrets = builder.secret_settings(); - let Some((section, written)) = builder - .module_name() - .filter(|_| !secrets.is_empty()) - .and_then(|name| settings.module_selection(name)) - else { + if secrets.is_empty() { continue; + } + let (place, table) = if let Some(vendor) = builder.shared_settings() { + let Some(table) = settings.shared.table(vendor) else { + continue; + }; + ( + format!("{}.{vendor}", crate::settings::SHARED_TYPE), + table.clone(), + ) + } else { + let Some((section, written)) = builder + .module_name() + .and_then(|name| settings.module_selection(name)) + else { + continue; + }; + ( + format!("{section}.{written}"), + settings.section_table(section, written), + ) }; - let table = settings.section_table(section, written); for secret in secrets.iter().filter(|secret| (secret.in_use)(&table)) { - let path = format!("{section}.{written}.{}", secret.path.join(".")); + let path = format!("{place}.{}", secret.path.join(".")); let reference = crate::module_secrets::value_at(&table, secret.path) .and_then(serde_json::Value::as_str) .ok_or_else(|| missing_secret_key_reference(&path))?; @@ -1695,6 +1711,63 @@ passphrase = "production-secret-key-32-bytes-min" } } + /// A builder its vendor's shared table runs, which declares a secret in + /// that table. + fn shared_builder_with_a_secret() -> IntegrationBuilder { + IntegrationBuilder::new( + "probe", + "example-crate", + crate::integrations::registry_test_support::probe_registration, + crate::integrations::registry_test_support::validate_nothing, + ) + .with_shared_settings("probe") + .with_secret_settings(MODULE_SECRETS) + } + + #[test] + fn deploy_validation_refuses_a_shared_table_s_secret_setting_in_use_that_names_no_key() { + for (table, expected) in [ + ( + Some(serde_json::json!({ "lock": true })), + Some("shared.probe.spare.key_name"), + ), + ( + Some(serde_json::json!({ "lock": true, "spare": { "key_name": " " } })), + Some("shared.probe.spare.key_name"), + ), + ( + Some(serde_json::json!({ "lock": true, "spare": { "key_name": "vendor_key" } })), + None, + ), + // A setting the table does not put to use is not checked. + (Some(serde_json::json!({ "lock": false })), None), + // Without its table the builder does not run. + (None, None), + ] { + let mut settings = valid_settings(); + if let Some(table) = &table { + settings.shared = serde_json::from_value(serde_json::json!({ "probe": table })) + .expect("should read the shared table"); + } + + let result = + validate_settings_for_deploy_with(&settings, &[shared_builder_with_a_secret()]); + + match expected { + Some(path) => { + let error = result.expect_err("should refuse a setting that names no key"); + assert!( + format!("{error:?}").contains(path), + "should name the setting for {table:?}: {error:?}" + ); + } + None => result.unwrap_or_else(|error| { + panic!("should accept {table:?}: {error:?}"); + }), + } + } + } + const PLAN_RULE_MESSAGE: &str = "probe module refuses an enabled auction"; fn refuse_an_enabled_auction( diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 2facf0f3df..81b69e95c9 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -358,6 +358,13 @@ impl SharedTables { self.0.contains_key(vendor) } + /// `[shared.]` as written, for a check that reads a setting by its + /// path, such as that a secret setting names a key. + #[must_use] + pub(crate) fn table(&self, vendor: &str) -> Option<&serde_json::Map> { + self.0.get(vendor) + } + /// Reads and validates `[shared.]` as `T`, or `None` when the table /// is absent. /// From 2abfb320569ac9ecc85c17fb80cd83a97e052f74 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 09:25:03 +0100 Subject: [PATCH 7/8] The changelog and guides describe the two shared table fixes --- CHANGELOG.md | 2 +- docs/guide/configuration.md | 2 +- docs/guide/integration-guide.md | 13 +++++++------ 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1efa1ec12..1b3bc9a3af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,7 +85,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `[shared.]`, one table per vendor holding what every module of that vendor shares, read by the vendor's crates through `Settings::shared_config` and by nothing in core. A builder marked with `IntegrationBuilder::with_shared_settings` runs when its vendor's table is present and is selected by no section, a table no builder reads is refused when the registry is built, a table whose name is not lower case letters, digits, `_` or `-` is refused when the settings load, a refusal of a module nothing supplies names the absent table, and a secret in the table is declared and resolved as a module's own are. +- `[shared.]`, one table per vendor holding what every module of that vendor shares, read by the vendor's crates through `Settings::shared_config` and by nothing in core. A builder marked with `IntegrationBuilder::with_shared_settings` runs when its vendor's table is present and is selected by no section, a table no builder reads is refused when the registry is built, a table whose name is not lower case letters, digits, `_` or `-` is refused when the settings load, a refusal of a module nothing supplies names the absent table, and a secret in the table is declared, resolved and checked at push to name a key as a module's own are. A shared table, or a module's own, that cannot be read or fails its validation is refused naming the setting at fault and never its value. - `robots_txt`, core's own middleware for the file `[robots-txt]` describes, named in a `[[fetch]]` or `[[serve]]` entry for `text/plain` on `/robots.txt`. Its preparation makes the file from the selected modules through their holding, and its action replaces whatever the origin sent with it, so the file replaces the origin's own and the creating rule makes it where the origin has none. A module that cannot answer with nothing held refuses the request with `503` and `Retry-After` before any document is processed. A section with no entry naming the middleware is refused when the settings load. - A middleware may prepare, and may refuse. `Middleware::prepare` runs in the request's handler before the document is processed, as the middleware's module with the request's services, and what it answers is handed to `create` under the middleware's name, so a decision that needs a store or the network is made there while `create` stays synchronous. A refusal answers the request with its status and `Retry-After`, with no document, and nothing is stored. - A loading cache. `PlatformLoadingCache` in core loads a missing value once per key however many requests ask for it at the same moment, gives every waiting request the value or the error, and holds nothing that failed. A crate offers one through `IntegrationBuilder::with_loading_cache`, each feature that holds answers selects the module whose cache holds them in its own `[cache.]` table, and an adapter takes the selections with `loading_caches_from`. A feature that selects none takes the one loading cache module the build links, and gets `NoLoadingCache`, which loads every time, when the build links none or several. The new `crates/loading-cache/51degrees`, the module `loading-cache.51degrees`, implements it with the published `fiftyone-caching` package as a brief copy in process memory over the host's value cache or key-value store, and the stock list ships it, so every adapter takes it. Every load runs in its caller. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 4e414bae81..8f3fe94550 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1148,7 +1148,7 @@ middleware = ["ads-txt.sellers"] What every module of one vendor shares, in one `[shared.]` table per vendor. Core reads none of a table. The vendor's modules read their own through `Settings::shared_config`, and the table's presence is what runs the builder that registers them, so settings without it run none of that vendor's modules and a selection naming one is refused with the table to add. A builder a table runs is selected by no section. A table that no module in the deployment reads is refused at startup, and so is a table whose name is not lower case letters, digits, `_` or `-`. -A secret in a shared table is written as the name of its entry in the secret store, declared by the vendor's builder as a module's own secret settings are, and resolved when the table is present. +A secret in a shared table is written as the name of its entry in the secret store, declared by the vendor's builder as a module's own secret settings are, checked at push to name a key, and resolved when the table is present. ```toml [shared.example] diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index 08987be197..f597d7b5d9 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -787,12 +787,13 @@ What every module of one vendor needs, such as an endpoint and a license key, goes in one `[shared.]` table. Core reads none of it. The vendor's crates read it as their own typed settings with `Settings::shared_config::(vendor)`, which refuses a table that cannot be -read as `T` or fails its validation, naming the table. The vendor's builder -marks itself with `.with_shared_settings(vendor)`, and the registry builds it -when the table is present and refuses a table that no builder reads. A secret -in the table is declared with `.with_secret_settings(...)` at its path inside -the table, as a module's own secrets are, and is resolved from the secret -store when the table is present. +read as `T` or fails its validation, naming the table and the setting at fault +and never the setting's value. The vendor's builder marks itself with +`.with_shared_settings(vendor)`, and the registry builds it when the table is +present and refuses a table that no builder reads. A secret in the table is +declared with `.with_secret_settings(...)` at its path inside the table, as a +module's own secrets are, is checked at push to name a key, and is resolved +from the secret store when the table is present. ### Supplying a loading cache From d33668fa9c022dfb728c3fabb7bbdb30d9adba61 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Fri, 16 Oct 2026 18:55:54 +0100 Subject: [PATCH 8/8] The CMP id test expects the setting named and its value never quoted From this branch a refused module table names the setting at fault and never its value, so loading settings with an out of range cmp_id says that cmp_id in [cmp.inmobi] holds a value of the wrong type or form, and no longer the range message the module's reader gives, which quotes the value. The InMobi test of the CMP id range checks each of its six values for the table and the setting, and that the value is not quoted. --- crates/cmp/inmobi/src/lib.rs | 38 ++++++++++++++---------------------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/crates/cmp/inmobi/src/lib.rs b/crates/cmp/inmobi/src/lib.rs index ad5ea528cf..cf19f02dfa 100644 --- a/crates/cmp/inmobi/src/lib.rs +++ b/crates/cmp/inmobi/src/lib.rs @@ -1140,7 +1140,7 @@ mod tests { /// A CMP id is a whole number the IAB assigned, which a TC string holds /// in 12 bits, so any other value is refused when the settings load, with - /// a message naming the setting. + /// a message naming the setting and never its value. #[test] fn a_cmp_id_outside_1_to_4095_is_refused_naming_the_setting() { // The test settings name the template's placeholder origin, which the @@ -1160,28 +1160,16 @@ mod tests { ) }; - // (case, the value as written, what the refusal must say) + // (case, the value as written) let cases = [ - ( - "zero, which a stub reports before it has an id", - "0", - "`cmp_id` is 0,", - ), - ( - "past the 12 bits a TC string holds", - "4096", - "`cmp_id` is 4096,", - ), - ( - "past what the field could hold", - "70000", - "`cmp_id` is 70000,", - ), - ("below zero", "-1", "`cmp_id` is -1,"), - ("a string", "\"10\"", "`cmp_id` is not a whole number"), - ("a fraction", "10.5", "`cmp_id` is not a whole number"), + ("zero, which a stub reports before it has an id", "0"), + ("past the 12 bits a TC string holds", "4096"), + ("past what the field could hold", "70000"), + ("below zero", "-1"), + ("a string", "\"10\""), + ("a fraction", "10.5"), ]; - for (case, value, reason) in cases { + for (case, value) in cases { let settings = with_cmp_id(value); for (call, refusal) in [ ("loading the settings", load(&settings).err()), @@ -1193,8 +1181,12 @@ mod tests { .current_context() .to_string(); assert!( - message.contains("[cmp.inmobi]") && message.contains(reason), - "{case}: {call} should say {reason:?}, and said {message:?}" + message.contains("[cmp.inmobi]") && message.contains("`cmp_id`"), + "{case}: {call} should name the table and the setting, and said {message:?}" + ); + assert!( + !message.contains(value), + "{case}: {call} should not quote the value, and said {message:?}" ); } }