diff --git a/spring-beans/src/main/java/org/springframework/beans/factory/config/YamlProcessor.java b/spring-beans/src/main/java/org/springframework/beans/factory/config/YamlProcessor.java index 96ce40db01a1..214475146344 100644 --- a/spring-beans/src/main/java/org/springframework/beans/factory/config/YamlProcessor.java +++ b/spring-beans/src/main/java/org/springframework/beans/factory/config/YamlProcessor.java @@ -340,7 +340,19 @@ private void buildFlattenedMap(Map result, Map s source.forEach((key, value) -> { if (StringUtils.hasText(path)) { if (key.startsWith("[")) { - key = path + key; + if (containsKeyPathSeparator(key)) { + // User-supplied YAML key whose literal brackets must be preserved + // when flattened to property paths (for example a YAML key such as + // "[domain.test:8080]" must round-trip as "[[domain.test:8080]]" so + // that consumers like the Spring Boot binder treat the original + // brackets as part of the key rather than as a map-key marker). + key = path + '[' + key + ']'; + } + else { + // Bracket-wrapped key generated internally — collection index or + // the toString() of a non-CharSequence map key. No dot separator. + key = path + key; + } } else { key = path + '.' + key; @@ -372,6 +384,25 @@ else if (value instanceof Collection collection) { }); } + /** + * Detect whether the given bracket-prefixed key contains characters that downstream + * consumers of the flattened properties would interpret as path separators. Keys + * generated internally by this class for collection indices and for the + * {@code toString()} of non-CharSequence map keys do not contain such characters, + * so the presence of a {@code '.'} or {@code ':'} indicates a user-supplied key + * whose brackets are part of the key itself and need additional wrapping to be + * preserved. + */ + private static boolean containsKeyPathSeparator(String key) { + for (int i = 0; i < key.length(); i++) { + char c = key.charAt(i); + if (c == '.' || c == ':') { + return true; + } + } + return false; + } + /** * Callback interface used to process the YAML parsing results. diff --git a/spring-beans/src/test/java/org/springframework/beans/factory/config/YamlPropertiesFactoryBeanTests.java b/spring-beans/src/test/java/org/springframework/beans/factory/config/YamlPropertiesFactoryBeanTests.java index b8874a13766e..9d70ab6e6cfc 100644 --- a/spring-beans/src/test/java/org/springframework/beans/factory/config/YamlPropertiesFactoryBeanTests.java +++ b/spring-beans/src/test/java/org/springframework/beans/factory/config/YamlPropertiesFactoryBeanTests.java @@ -226,6 +226,40 @@ void loadArrayOfObject() { assertThat(properties.get("foo")).isNull(); } + @Test // gh-27020 + void loadNestedMapWithBracketedKeyContainingDotAndColon() { + YamlPropertiesFactoryBean factory = new YamlPropertiesFactoryBean(); + factory.setResources(new ByteArrayResource(( + "root:\n" + + " webservices:\n" + + " \"[domain.test:8080]\":\n" + + " - username: me\n" + + " password: mypassword\n").getBytes())); + Properties properties = factory.getObject(); + assertThat(properties.getProperty("root.webservices[[domain.test:8080]][0].username")) + .isEqualTo("me"); + assertThat(properties.getProperty("root.webservices[[domain.test:8080]][0].password")) + .isEqualTo("mypassword"); + } + + @Test // gh-27020 + void loadNestedMapWithBracketedKeyContainingDot() { + YamlPropertiesFactoryBean factory = new YamlPropertiesFactoryBean(); + factory.setResources(new ByteArrayResource( + "root:\n \"[my.dotted.key]\": value\n".getBytes())); + Properties properties = factory.getObject(); + assertThat(properties.getProperty("root[[my.dotted.key]]")).isEqualTo("value"); + } + + @Test // gh-27020 + void loadNestedMapWithBracketedKeyContainingColon() { + YamlPropertiesFactoryBean factory = new YamlPropertiesFactoryBean(); + factory.setResources(new ByteArrayResource( + "root:\n \"[host:8080]\": value\n".getBytes())); + Properties properties = factory.getObject(); + assertThat(properties.getProperty("root[[host:8080]]")).isEqualTo("value"); + } + @Test @SuppressWarnings("unchecked") void yaml() {