From 39b0f1100853f674f403fec339dcdd66ba3e077a Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 30 Sep 2026 12:54:13 -0500 Subject: [PATCH 1/3] Reject ambiguous image optimizer names Reject profile and profile-set keys that normalize to the same name before rebuilding either map. Report both original keys and their configuration path instead of silently choosing a value. Preserve existing normalization for valid configuration and propagate failures through runtime and CLI configuration loading. Closes #1211 --- crates/trusted-server-core/src/config.rs | 75 ++++++- crates/trusted-server-core/src/settings.rs | 247 ++++++++++++++++++++- 2 files changed, 316 insertions(+), 6 deletions(-) diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 79f100e2f..4fda698cf 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -109,7 +109,9 @@ impl<'de> Deserialize<'de> for TrustedServerAppConfig { D: Deserializer<'de>, { let mut settings = Settings::deserialize(deserializer)?; - settings.normalize_deserialized(); + settings + .normalize_deserialized() + .map_err(serde::de::Error::custom)?; Ok(Self { settings }) } } @@ -796,6 +798,73 @@ formats = [{ width = 300, height = 250 }] ); } + #[test] + fn app_config_image_optimizer_rejects_trimmed_key_collisions() { + for (section, first, second) in [ + ( + r#" + [image_optimizer.profile_sets.default_images] + default_profile = "medium" + [image_optimizer.profile_sets.default_images.profiles] + medium = "width=100" + " medium" = "width=200" + "#, + "medium", + " medium", + ), + ( + r#" + [image_optimizer.profile_sets.default_images.profiles] + default = "width=100" + [image_optimizer.profile_sets." default_images".profiles] + default = "width=200" + "#, + "default_images", + " default_images", + ), + ] { + let toml = crate_test_settings_str() + section; + let raw: Settings = + toml::from_str(&toml).expect("should deserialize unnormalized settings"); + let value = serde_json::to_value(raw).expect("should serialize unnormalized settings"); + let toml_error = toml::from_str::(&toml) + .expect_err("should reject colliding app-config keys"); + let json_error = serde_json::from_value::(value) + .expect_err("should reject colliding app-config keys"); + + for message in [toml_error.to_string(), json_error.to_string()] { + for expected in [ + "image_optimizer.profile_sets", + "default_images", + "collide after trimming", + &format!("{first:?}"), + &format!("{second:?}"), + ] { + assert!( + message.contains(expected), + "should preserve collision context with {expected:?}: {message}" + ); + } + } + } + } + + #[test] + fn app_config_image_optimizer_normalizes_non_colliding_keys() { + let toml = crate_test_settings_str() + + r#" + [image_optimizer.profile_sets." default_images "] + default_profile = " medium " + [image_optimizer.profile_sets." default_images ".profiles] + " medium " = " width=100 " + "#; + let app_config: TrustedServerAppConfig = + toml::from_str(&toml).expect("should deserialize non-colliding app config"); + let profile_set = &app_config.settings().image_optimizer.profile_sets["default_images"]; + assert_eq!(profile_set.default_profile, "medium"); + assert_eq!(profile_set.profiles["medium"], "width=100"); + } + #[test] fn push_validation_accepts_secret_key_names() { let mut settings = valid_settings(); @@ -919,7 +988,9 @@ formats = [{ width = 300, height = 250 }] })); settings.proxy.asset_routes.push(route); - settings.normalize_deserialized(); + settings + .normalize_deserialized() + .expect("should normalize settings without key collisions"); let serialized = serde_json::to_string(&settings).expect("should serialize settings"); for legacy_store in [ diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index bf4ff7999..1e6f306c6 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -1023,7 +1023,26 @@ pub struct ImageOptimizerSettings { } impl ImageOptimizerSettings { - fn normalize(&mut self) { + /// Normalize image configuration without losing conflicting original keys. + /// + /// # Errors + /// + /// Returns a configuration error if retained profile-set or profile keys + /// collide after trimming. + fn normalize(&mut self) -> Result<(), Report> { + validate_trimmed_image_optimizer_keys( + self.profile_sets.keys(), + "image_optimizer.profile_sets", + )?; + for (name, profile_set) in &self.profile_sets { + if !name.trim().is_empty() { + validate_trimmed_image_optimizer_keys( + profile_set.profiles.keys(), + &format!("image_optimizer.profile_sets {name:?}.profiles"), + )?; + } + } + self.profile_sets = self .profile_sets .drain() @@ -1033,6 +1052,7 @@ impl ImageOptimizerSettings { }) .filter(|(key, _)| !key.is_empty()) .collect(); + Ok(()) } /// Eagerly validate configured image profile sets. @@ -1044,6 +1064,36 @@ impl ImageOptimizerSettings { } } +/// Check retained image configuration keys before normalization discards originals. +/// +/// # Errors +/// +/// Returns a configuration error naming both original keys when they trim to +/// the same non-empty name. +fn validate_trimmed_image_optimizer_keys<'a>( + keys: impl Iterator, + path: &str, +) -> Result<(), Report> { + let mut names: Vec<_> = keys + .map(|key| (key.trim(), key.as_str())) + .filter(|(trimmed, _)| !trimmed.is_empty()) + .collect(); + names.sort_unstable(); + + for pair in names.windows(2) { + let (trimmed, first) = pair[0]; + let (next_trimmed, second) = pair[1]; + if trimmed == next_trimmed { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "{path} keys {first:?} and {second:?} collide after trimming to {trimmed:?}" + ), + })); + } + } + Ok(()) +} + /// Named set of profile-table Image Optimizer mappings. /// /// Each profile value is a URL-encoded parameter string using the strict @@ -2985,22 +3035,28 @@ impl Settings { Self::finalize_deserialized(settings, "Build-time configuration") } - pub(crate) fn normalize_deserialized(&mut self) { + /// Normalize settings after deserialization. + /// + /// # Errors + /// + /// Returns a configuration error if image optimizer keys collide after trimming. + pub(crate) fn normalize_deserialized(&mut self) -> Result<(), Report> { self.cache.normalize(); self.proxy.normalize(); - self.image_optimizer.normalize(); + self.image_optimizer.normalize()?; self.debug.auction_html_comment_options.normalize(); self.tinybird.normalize(); self.integrations .remove_legacy_static_secret_store_selectors(); self.consent.validate(); + Ok(()) } pub(crate) fn finalize_deserialized( mut settings: Self, validation_label: &str, ) -> Result> { - settings.normalize_deserialized(); + settings.normalize_deserialized()?; settings.prepare_runtime()?; settings.validate().map_err(|err| { @@ -6947,6 +7003,189 @@ source_domain = "partner.example.com" ); } + #[test] + fn image_optimizer_rejects_profile_keys_that_collide_after_trimming() { + for (first, second, second_params) in [ + ("medium", " medium", "width=200"), + ("medium ", "\tmedium", "width=200"), + (" medium ", "medium", "width=100"), + ] { + let toml = format!( + "{}\n[image_optimizer.profile_sets.default_images]\n\ + default_profile = \"medium\"\n\ + [image_optimizer.profile_sets.default_images.profiles]\n\ + {first:?} = \"width=100\"\n{second:?} = {second_params:?}\n", + crate_test_settings_str() + ); + let raw: Settings = + toml::from_str(&toml).expect("should deserialize unnormalized settings"); + let value = serde_json::to_value(raw).expect("should serialize unnormalized settings"); + let mut messages = Vec::new(); + + for result in [Settings::from_toml(&toml), Settings::from_json_value(value)] { + let error = result.expect_err("should reject colliding profile keys"); + assert!( + matches!( + error.current_context(), + TrustedServerError::Configuration { .. } + ), + "should return a configuration error" + ); + let message = error.to_string(); + for expected in [ + "image_optimizer.profile_sets", + "default_images", + "profiles", + "collide after trimming", + &format!("{first:?}"), + &format!("{second:?}"), + "\"medium\"", + ] { + assert!( + message.contains(expected), + "should identify the collision with {expected:?}: {message}" + ); + } + messages.push(message); + } + assert_eq!(messages[0], messages[1], "should report a stable collision"); + } + } + + #[test] + fn image_optimizer_rejects_profile_set_keys_that_collide_after_trimming() { + for (first, second, second_params) in [ + ("default_images", " default_images", "width=200"), + ("default_images ", "\tdefault_images", "width=200"), + (" default_images ", "default_images", "width=100"), + ] { + let toml = format!( + "{}\n[image_optimizer.profile_sets.{first:?}.profiles]\n\ + default = \"width=100\"\n\ + [image_optimizer.profile_sets.{second:?}.profiles]\n\ + default = {second_params:?}\n", + crate_test_settings_str() + ); + let raw: Settings = + toml::from_str(&toml).expect("should deserialize unnormalized settings"); + let value = serde_json::to_value(raw).expect("should serialize unnormalized settings"); + let mut messages = Vec::new(); + + for result in [Settings::from_toml(&toml), Settings::from_json_value(value)] { + let error = result.expect_err("should reject colliding profile-set keys"); + assert!( + matches!( + error.current_context(), + TrustedServerError::Configuration { .. } + ), + "should return a configuration error" + ); + let message = error.to_string(); + for expected in [ + "image_optimizer.profile_sets", + "collide after trimming", + &format!("{first:?}"), + &format!("{second:?}"), + "\"default_images\"", + ] { + assert!( + message.contains(expected), + "should identify the collision with {expected:?}: {message}" + ); + } + messages.push(message); + } + assert_eq!(messages[0], messages[1], "should report a stable collision"); + } + } + + #[test] + fn image_optimizer_normalization_preserves_non_colliding_configuration() { + let toml = crate_test_settings_str() + + r#" + [image_optimizer.profile_sets." default_images "] + base_params = " quality=70 " + default_profile = " medium " + profile_param = " profile " + aspect_ratio_param = " ratio " + debug_param = " debug " + + [image_optimizer.profile_sets." default_images ".profiles] + " medium " = " width=100 " + large = "width=200" + "" = "ignored" + " " = "also ignored" + + [image_optimizer.profile_sets." default_images ".aspect_ratios] + allowed = [" 1-1 ", ""] + profiles = [" medium ", ""] + + [image_optimizer.profile_sets." default_images ".crop_offsets] + x_param = " x " + y_param = " y " + buckets = [90, 10, 10] + + [image_optimizer.profile_sets.other_images.profiles] + medium = "width=300" + default = "width=400" + + [image_optimizer.profile_sets."".profiles] + default = "ignored" + " default " = "also ignored" + + [image_optimizer.profile_sets." "] + + [[proxy.asset_routes]] + prefix = "/images/" + origin_url = "https://assets.example.com" + + [proxy.asset_routes.image_optimizer] + region = " us_east " + profile_set = " default_images " + "#; + let raw: Settings = + toml::from_str(&toml).expect("should deserialize unnormalized settings"); + let value = serde_json::to_value(raw).expect("should serialize unnormalized settings"); + + for result in [Settings::from_toml(&toml), Settings::from_json_value(value)] { + let settings = result.expect("should accept non-colliding keys"); + assert_eq!(settings.image_optimizer.profile_sets.len(), 2); + let profile_set = &settings.image_optimizer.profile_sets["default_images"]; + assert_eq!(profile_set.base_params, "quality=70"); + assert_eq!(profile_set.default_profile, "medium"); + assert_eq!(profile_set.profile_param, "profile"); + assert_eq!(profile_set.aspect_ratio_param, "ratio"); + assert_eq!(profile_set.debug_param, "debug"); + assert_eq!(profile_set.profiles.len(), 2); + assert_eq!(profile_set.profiles["medium"], "width=100"); + assert_eq!(profile_set.profiles["large"], "width=200"); + assert_eq!( + settings.image_optimizer.profile_sets["other_images"].profiles["medium"], + "width=300", + "should allow the same profile name in distinct sets" + ); + let ratios = profile_set + .aspect_ratios + .as_ref() + .expect("should retain ratios"); + assert_eq!(ratios.allowed, ["1-1"]); + assert_eq!(ratios.profiles, ["medium"]); + let offsets = profile_set + .crop_offsets + .as_ref() + .expect("should retain offsets"); + assert_eq!(offsets.x_param, "x"); + assert_eq!(offsets.y_param, "y"); + assert_eq!(offsets.buckets, [10, 90]); + let route = settings.proxy.asset_routes[0] + .image_optimizer + .as_ref() + .expect("should retain the route's image optimizer"); + assert_eq!(route.region, "us_east"); + assert_eq!(route.profile_set, "default_images"); + } + } + #[test] fn image_optimizer_validation_rejects_unknown_aspect_ratio_profile() { let toml_str = crate_test_settings_str() From 0a99ad10cbcfa258df5e482e029cbe80a755f697 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 30 Sep 2026 13:18:25 -0500 Subject: [PATCH 2/3] Extract shared image key collision test assertions --- crates/trusted-server-core/src/settings.rs | 86 +++++++++------------- 1 file changed, 36 insertions(+), 50 deletions(-) diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 1e6f306c6..c1c587b54 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -7003,6 +7003,32 @@ source_domain = "partner.example.com" ); } + fn assert_image_optimizer_key_collision(toml: &str, expected_fragments: &[&str]) { + let raw: Settings = toml::from_str(toml).expect("should deserialize unnormalized settings"); + let value = serde_json::to_value(raw).expect("should serialize unnormalized settings"); + let mut messages = Vec::new(); + + for result in [Settings::from_toml(toml), Settings::from_json_value(value)] { + let error = result.expect_err("should reject colliding image optimizer keys"); + assert!( + matches!( + error.current_context(), + TrustedServerError::Configuration { .. } + ), + "should return a configuration error" + ); + let message = error.to_string(); + for expected in expected_fragments { + assert!( + message.contains(expected), + "should identify the collision with {expected:?}: {message}" + ); + } + messages.push(message); + } + assert_eq!(messages[0], messages[1], "should report a stable collision"); + } + #[test] fn image_optimizer_rejects_profile_keys_that_collide_after_trimming() { for (first, second, second_params) in [ @@ -7017,22 +7043,9 @@ source_domain = "partner.example.com" {first:?} = \"width=100\"\n{second:?} = {second_params:?}\n", crate_test_settings_str() ); - let raw: Settings = - toml::from_str(&toml).expect("should deserialize unnormalized settings"); - let value = serde_json::to_value(raw).expect("should serialize unnormalized settings"); - let mut messages = Vec::new(); - - for result in [Settings::from_toml(&toml), Settings::from_json_value(value)] { - let error = result.expect_err("should reject colliding profile keys"); - assert!( - matches!( - error.current_context(), - TrustedServerError::Configuration { .. } - ), - "should return a configuration error" - ); - let message = error.to_string(); - for expected in [ + assert_image_optimizer_key_collision( + &toml, + &[ "image_optimizer.profile_sets", "default_images", "profiles", @@ -7040,15 +7053,8 @@ source_domain = "partner.example.com" &format!("{first:?}"), &format!("{second:?}"), "\"medium\"", - ] { - assert!( - message.contains(expected), - "should identify the collision with {expected:?}: {message}" - ); - } - messages.push(message); - } - assert_eq!(messages[0], messages[1], "should report a stable collision"); + ], + ); } } @@ -7066,36 +7072,16 @@ source_domain = "partner.example.com" default = {second_params:?}\n", crate_test_settings_str() ); - let raw: Settings = - toml::from_str(&toml).expect("should deserialize unnormalized settings"); - let value = serde_json::to_value(raw).expect("should serialize unnormalized settings"); - let mut messages = Vec::new(); - - for result in [Settings::from_toml(&toml), Settings::from_json_value(value)] { - let error = result.expect_err("should reject colliding profile-set keys"); - assert!( - matches!( - error.current_context(), - TrustedServerError::Configuration { .. } - ), - "should return a configuration error" - ); - let message = error.to_string(); - for expected in [ + assert_image_optimizer_key_collision( + &toml, + &[ "image_optimizer.profile_sets", "collide after trimming", &format!("{first:?}"), &format!("{second:?}"), "\"default_images\"", - ] { - assert!( - message.contains(expected), - "should identify the collision with {expected:?}: {message}" - ); - } - messages.push(message); - } - assert_eq!(messages[0], messages[1], "should report a stable collision"); + ], + ); } } From ce4aace38ee8d24e2bbaa49d67ace2437fb7bb5d Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 9 Oct 2026 11:34:08 -0500 Subject: [PATCH 3/3] Address image optimizer collision review feedback --- CHANGELOG.md | 1 + crates/trusted-server-core/src/config.rs | 10 +- crates/trusted-server-core/src/settings.rs | 116 +++++++++++++++++---- docs/guide/configuration.md | 2 + 4 files changed, 108 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c3316e42..801359bbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Image Optimizer configuration now rejects profile and profile-set names within the same map that collide after trimming, even when their values match, instead of silently overwriting an entry. Empty names continue to be discarded. - Browser integration bundles now share context providers and the configured log level with TSJS core (#1196). With Permutive enabled and cohorts available, `tsjs.requestAds()` now sends `config.permutive_segments` to `/auction`; the server retains that key only when `auction.allowed_context_keys` includes `"permutive_segments"`. This restores a previously inactive data flow, not a consent gate: review publisher consent handling before relying on it. Integration logs now follow `tsjs.setConfig` and `tsjs.log.setLevel`, and creative no longer raises the default `warn` level to `info`. The `?tsdebug=1` query parameter or `localStorage['tsdebug'] = '1'` intentionally enables `debug` logging across all bundles when creative installs, overriding any log level set earlier through `tsjs.setConfig` or `tsjs.log.setLevel`. Later calls to either API can change the level again. - `[auction].allowed_context_keys` now serializes in sorted, deduplicated order, so ESI template-cache fingerprints and `ts config diff`/`push` envelope hashes are stable across loads. Template fingerprints also sort object keys independently of `serde_json/preserve_order`. Existing envelopes may show a one-time allowlist reorder after upgrading; push once to settle it. The updated fingerprint format causes one template-cache miss per cached page after deployment. - TSJS-generated envelopes now send `trustedServer.params.storedRequest: false`, preventing accidental PBS stored lookups without suppressing eligible non-PBS demand. PBS filters unusable impressions after overrides; explicit `true` and omission in valid envelopes retain inline-first stored fallback. A malformed envelope disables stored fallback for the entire slot, including independent direct demand left unusable after overrides. Publisher intent survives repeated and refresh auctions. Deploy compatible server admission everywhere before serving the new JS, and retain it during rollback while cached clients remain. See the Prebid deployment guide. diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 4fda698cf..e48f5c2f0 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -861,8 +861,14 @@ formats = [{ width = 300, height = 250 }] let app_config: TrustedServerAppConfig = toml::from_str(&toml).expect("should deserialize non-colliding app config"); let profile_set = &app_config.settings().image_optimizer.profile_sets["default_images"]; - assert_eq!(profile_set.default_profile, "medium"); - assert_eq!(profile_set.profiles["medium"], "width=100"); + assert_eq!( + profile_set.default_profile, "medium", + "should trim the default profile reference" + ); + assert_eq!( + profile_set.profiles["medium"], "width=100", + "should trim profile keys and values" + ); } #[test] diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index c1c587b54..0f6e41fc6 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -1023,7 +1023,7 @@ pub struct ImageOptimizerSettings { } impl ImageOptimizerSettings { - /// Normalize image configuration without losing conflicting original keys. + /// Trim image configuration keys and values, rejecting keys that collide after trimming. /// /// # Errors /// @@ -1034,11 +1034,13 @@ impl ImageOptimizerSettings { self.profile_sets.keys(), "image_optimizer.profile_sets", )?; - for (name, profile_set) in &self.profile_sets { + let mut profile_sets: Vec<_> = self.profile_sets.iter().collect(); + profile_sets.sort_unstable_by_key(|(name, _)| *name); + for (name, profile_set) in profile_sets { if !name.trim().is_empty() { validate_trimmed_image_optimizer_keys( profile_set.profiles.keys(), - &format!("image_optimizer.profile_sets {name:?}.profiles"), + &format!("image_optimizer.profile_sets.{name:?}.profiles"), )?; } } @@ -7085,6 +7087,29 @@ source_domain = "partner.example.com" } } + #[test] + fn image_optimizer_reports_first_profile_key_collision_in_stable_set_order() { + let toml = crate_test_settings_str() + + r#" + [image_optimizer.profile_sets." alpha_images ".profiles] + medium = "width=100" + " medium" = "width=200" + + [image_optimizer.profile_sets.zeta_images.profiles] + medium = "width=300" + " medium" = "width=400" + "#; + + for _ in 0..32 { + assert_image_optimizer_key_collision( + &toml, + &[ + r#"image_optimizer.profile_sets." alpha_images ".profiles keys " medium" and "medium" collide after trimming to "medium""#, + ], + ); + } + } + #[test] fn image_optimizer_normalization_preserves_non_colliding_configuration() { let toml = crate_test_settings_str() @@ -7135,16 +7160,45 @@ source_domain = "partner.example.com" for result in [Settings::from_toml(&toml), Settings::from_json_value(value)] { let settings = result.expect("should accept non-colliding keys"); - assert_eq!(settings.image_optimizer.profile_sets.len(), 2); + assert_eq!( + settings.image_optimizer.profile_sets.len(), + 2, + "should discard empty profile-set names" + ); let profile_set = &settings.image_optimizer.profile_sets["default_images"]; - assert_eq!(profile_set.base_params, "quality=70"); - assert_eq!(profile_set.default_profile, "medium"); - assert_eq!(profile_set.profile_param, "profile"); - assert_eq!(profile_set.aspect_ratio_param, "ratio"); - assert_eq!(profile_set.debug_param, "debug"); - assert_eq!(profile_set.profiles.len(), 2); - assert_eq!(profile_set.profiles["medium"], "width=100"); - assert_eq!(profile_set.profiles["large"], "width=200"); + assert_eq!( + profile_set.base_params, "quality=70", + "should trim base parameters" + ); + assert_eq!( + profile_set.default_profile, "medium", + "should trim the default profile reference" + ); + assert_eq!( + profile_set.profile_param, "profile", + "should trim the profile query parameter name" + ); + assert_eq!( + profile_set.aspect_ratio_param, "ratio", + "should trim the aspect ratio query parameter name" + ); + assert_eq!( + profile_set.debug_param, "debug", + "should trim the debug query parameter name" + ); + assert_eq!( + profile_set.profiles.len(), + 2, + "should discard empty profile names" + ); + assert_eq!( + profile_set.profiles["medium"], "width=100", + "should trim profile keys and values" + ); + assert_eq!( + profile_set.profiles["large"], "width=200", + "should preserve unpadded profile keys and values" + ); assert_eq!( settings.image_optimizer.profile_sets["other_images"].profiles["medium"], "width=300", @@ -7154,21 +7208,45 @@ source_domain = "partner.example.com" .aspect_ratios .as_ref() .expect("should retain ratios"); - assert_eq!(ratios.allowed, ["1-1"]); - assert_eq!(ratios.profiles, ["medium"]); + assert_eq!( + ratios.allowed, + ["1-1"], + "should trim allowed aspect ratios and discard empty entries" + ); + assert_eq!( + ratios.profiles, + ["medium"], + "should trim aspect ratio profile references and discard empty entries" + ); let offsets = profile_set .crop_offsets .as_ref() .expect("should retain offsets"); - assert_eq!(offsets.x_param, "x"); - assert_eq!(offsets.y_param, "y"); - assert_eq!(offsets.buckets, [10, 90]); + assert_eq!( + offsets.x_param, "x", + "should trim the horizontal crop offset parameter name" + ); + assert_eq!( + offsets.y_param, "y", + "should trim the vertical crop offset parameter name" + ); + assert_eq!( + offsets.buckets, + [10, 90], + "should sort and deduplicate crop offset buckets" + ); let route = settings.proxy.asset_routes[0] .image_optimizer .as_ref() .expect("should retain the route's image optimizer"); - assert_eq!(route.region, "us_east"); - assert_eq!(route.profile_set, "default_images"); + assert_eq!( + route.region, "us_east", + "should trim the image optimizer region" + ); + assert_eq!( + route.profile_set, "default_images", + "should trim the route's profile-set reference" + ); } } diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 98e375627..13c55a6f5 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1246,6 +1246,8 @@ Profile sets convert small request query controls into a closed set of Image Opt Profile values live under `[image_optimizer.profile_sets..profiles]` and use query-string syntax. +Profile and profile-set names are trimmed. Names within the same map that become identical after trimming are rejected, even when their values match. Empty names continue to be discarded. + ```toml [image_optimizer.profile_sets.default_images] base_params = "quality=70&resize-filter=bicubic"