From 82bc4ff71d06c5d2998c5cc6c586c0ea43ec36fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Nicoll?= Date: Mon, 20 Jan 2025 12:49:18 +0100 Subject: [PATCH] Handle TextPart with escaped separator This commit harmonizes how a candidate value is parsed to extract its key and default, if any. Rather than returning {@code null} if no default is available, `splitKeyAndValue` now consistently returns a non-null array. This prevents an escaped separator character to be mistakenly identified as a placeholder in certain cases. Closes gh-34289 --- .../util/PlaceholderParser.java | 47 +++++++++++-------- .../util/PlaceholderParserTests.java | 22 ++++++++- 2 files changed, 48 insertions(+), 21 deletions(-) diff --git a/spring-core/src/main/java/org/springframework/util/PlaceholderParser.java b/spring-core/src/main/java/org/springframework/util/PlaceholderParser.java index ac157bb8bd..5b538cdcb8 100644 --- a/spring-core/src/main/java/org/springframework/util/PlaceholderParser.java +++ b/spring-core/src/main/java/org/springframework/util/PlaceholderParser.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -175,9 +175,8 @@ final class PlaceholderParser { } private SimplePlaceholderPart createSimplePlaceholderPart(String text) { - String[] keyAndDefault = splitKeyAndDefault(text); - return ((keyAndDefault != null) ? new SimplePlaceholderPart(text, keyAndDefault[0], keyAndDefault[1]) : - new SimplePlaceholderPart(text, text, null)); + ParsedSection section = parseSection(text); + return new SimplePlaceholderPart(text, section.key(), section.fallback()); } private NestedPlaceholderPart createNestedPlaceholderPart(String text, List parts) { @@ -193,28 +192,32 @@ final class PlaceholderParser { } else { String candidate = part.text(); - String[] keyAndDefault = splitKeyAndDefault(candidate); - if (keyAndDefault != null) { - keyParts.add(new TextPart(keyAndDefault[0])); - if (keyAndDefault[1] != null) { - defaultParts.add(new TextPart(keyAndDefault[1])); - } + ParsedSection section = parseSection(candidate); + keyParts.add(new TextPart(section.key())); + if (section.fallback() != null) { + defaultParts.add(new TextPart(section.fallback())); defaultParts.addAll(parts.subList(i + 1, parts.size())); return new NestedPlaceholderPart(text, keyParts, defaultParts); } - else { - keyParts.add(part); - } } } - // No separator found - return new NestedPlaceholderPart(text, parts, null); + return new NestedPlaceholderPart(text, keyParts, null); } - @Nullable - private String[] splitKeyAndDefault(String value) { + /** + * Parse an input value that may contain a separator character and return a + * {@link ParsedValue}. If a valid separator character has been identified, the + * given {@code value} is split between a {@code key} and a {@code fallback}. If not, + * only the {@code key} is set. + *

+ * The returned key may be different from the original value as escaped + * separators, if any, are resolved. + * @param value the value to parse + * @return the parsed section + */ + private ParsedSection parseSection(String value) { if (this.separator == null || !value.contains(this.separator)) { - return null; + return new ParsedSection(value, null); } int position = 0; int index = value.indexOf(this.separator, position); @@ -231,11 +234,11 @@ final class PlaceholderParser { buffer.append(value, position, index); String key = buffer.toString(); String fallback = value.substring(index + this.separator.length()); - return new String[] { key, fallback }; + return new ParsedSection(key, fallback); } } buffer.append(value, position, value.length()); - return new String[] { buffer.toString(), null }; + return new ParsedSection(buffer.toString(), null); } private static void addText(String value, int start, int end, LinkedList parts) { @@ -293,6 +296,10 @@ final class PlaceholderParser { return (this.escape != null && index > 0 && value.charAt(index - 1) == this.escape); } + record ParsedSection(String key, @Nullable String fallback) { + + } + /** * Provide the necessary context to handle and resolve underlying placeholders. diff --git a/spring-core/src/test/java/org/springframework/util/PlaceholderParserTests.java b/spring-core/src/test/java/org/springframework/util/PlaceholderParserTests.java index 3968d49f69..53182acf88 100644 --- a/spring-core/src/test/java/org/springframework/util/PlaceholderParserTests.java +++ b/spring-core/src/test/java/org/springframework/util/PlaceholderParserTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -278,6 +278,26 @@ class PlaceholderParserTests { private final PlaceholderParser parser = new PlaceholderParser("${", "}", ":", '\\', true); + @ParameterizedTest(name = "{0} -> {1}") + @MethodSource("escapedInNestedPlaceholders") + void escapedSeparatorInNestedPlaceholder(String text, String expected) { + Properties properties = new Properties(); + properties.setProperty("app.environment", "qa"); + properties.setProperty("app.service", "protocol"); + properties.setProperty("protocol://host/qa/name", "protocol://example.com/qa/name"); + properties.setProperty("service/host/qa/name", "https://example.com/qa/name"); + properties.setProperty("service/host/qa/name:value", "https://example.com/qa/name-value"); + assertThat(this.parser.replacePlaceholders(text, properties::getProperty)).isEqualTo(expected); + } + + static Stream escapedInNestedPlaceholders() { + return Stream.of( + Arguments.of("${protocol\\://host/${app.environment}/name}", "protocol://example.com/qa/name"), + Arguments.of("${${app.service}\\://host/${app.environment}/name}", "protocol://example.com/qa/name"), + Arguments.of("${service/host/${app.environment}/name:\\value}", "https://example.com/qa/name"), + Arguments.of("${service/host/${name\\:value}/}", "${service/host/${name:value}/}")); + } + @ParameterizedTest(name = "{0} -> {1}") @MethodSource("escapedPlaceholders") void escapedPlaceholderIsNotReplaced(String text, String expected) {