Introduce Spring property for default escape character for placeholders

Spring Framework 6.2 introduced support for an escape character for
property placeholders (by default '\'). However, as of Spring Framework
6.2.6, there was no way to either escape the escape character or disable
escape character support.

For example, given a `username` property configured with the value of
`Jane.Smith` and a `DOMAIN\${username}` configuration string, property
placeholder replacement used to result in `DOMAIN\Jane.Smith` prior to
6.2 but now results in `DOMAIN${username}`. Similarly, an attempt to
escape the escape character via `DOMAIN\\${username}` results in
`DOMAIN\${username}`.

In theory, one should be able to disable use of an escape character
altogether, and that is currently possible by invoking
setEscapeCharacter(null) on AbstractPropertyResolver and
PlaceholderConfigurerSupport (the superclass of
PropertySourcesPlaceholderConfigurer).

However, in reality, there are two hurdles.

- As of 6.2.6, an invocation of setEscapeCharacter(null) on a
  PropertySourcesPlaceholderConfigurer applied to its internal
  top-level PropertySourcesPropertyResolver but not to any nested
  PropertySourcesPropertyResolver, which means that the `null` escape
  character could not be effectively applied.

- Users may not have an easy way to explicitly set the escape character
  to `null` for a PropertyResolver or
  PropertySourcesPlaceholderConfigurer. For example, Spring Boot
  auto-configures a PropertySourcesPlaceholderConfigurer with the
  default escape character enabled.

This first issue above has recently been addressed by gh-34861.

This commit therefore addresses the second issue as follows.

- To allow developers to easily revert to the pre-6.2 behavior without
  changes to code or configuration strings, this commit introduces a
  `spring.placeholder.escapeCharacter.default` property for use with
  SpringProperties which globally sets the default escape character that
  is automatically configured in AbstractPropertyResolver and
  PlaceholderConfigurerSupport.

- Setting the property to an empty string sets the default escape
  character to `null`, effectively disabling the default support for
  escape characters.

    spring.placeholder.escapeCharacter.default =

- Setting the property to any other character sets the default escape
  character to that specific character.

    spring.placeholder.escapeCharacter.default = ~

- Setting the property to a string containing more than one character
  results in an exception.

- Developers are still able to configure an explicit escape character
  in AbstractPropertyResolver and PlaceholderConfigurerSupport if they
  choose to do so.

- Third-party components that wish to rely on the same feature can
  invoke AbstractPropertyResolver.getDefaultEscapeCharacter() to obtain
  the globally configured default escape character.

See gh-9628
See gh-34315
See gh-34861
Closes gh-34865
This commit is contained in:
Sam Brannen
2025-05-10 17:04:30 +02:00
parent e34cdc2a55
commit 03ae97b2eb
8 changed files with 373 additions and 17 deletions

View File

@@ -23,6 +23,7 @@ import java.util.Set;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.core.SpringProperties;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.support.ConfigurableConversionService;
import org.springframework.core.convert.support.DefaultConversionService;
@@ -37,10 +38,52 @@ import org.springframework.util.SystemPropertyUtils;
*
* @author Chris Beams
* @author Juergen Hoeller
* @author Sam Brannen
* @since 3.1
*/
public abstract class AbstractPropertyResolver implements ConfigurablePropertyResolver {
/**
* JVM system property used to change the <em>default</em> escape character
* for property placeholder support: {@value}.
* <p>To configure a custom escape character, supply a string containing a
* single character (other than {@link Character#MIN_VALUE}). For example,
* supplying the following JVM system property via the command line sets the
* default escape character to {@code '@'}.
* <pre style="code">-Dspring.placeholder.escapeCharacter.default=@</pre>
* <p>To disable escape character support, set the value to an empty string
* &mdash; for example, by supplying the following JVM system property via
* the command line.
* <pre style="code">-Dspring.placeholder.escapeCharacter.default=</pre>
* <p>If the property is not set, {@code '\'} will be used as the default
* escape character.
* <p>May alternatively be configured via a
* {@link org.springframework.core.SpringProperties spring.properties} file
* in the root of the classpath.
* @since 6.2.7
* @see #getDefaultEscapeCharacter()
*/
public static final String DEFAULT_PLACEHOLDER_ESCAPE_CHARACTER_PROPERTY_NAME =
"spring.placeholder.escapeCharacter.default";
/**
* Since {@code null} is a valid value for {@link #defaultEscapeCharacter},
* this constant provides a way to represent an undefined (or not yet set)
* value. Consequently, {@link #getDefaultEscapeCharacter()} prevents the use
* of {@link Character#MIN_VALUE} as the actual escape character.
* @since 6.2.7
*/
static final Character UNDEFINED_ESCAPE_CHARACTER = Character.MIN_VALUE;
/**
* Cached value for the default escape character.
* @since 6.2.7
*/
@Nullable
static volatile Character defaultEscapeCharacter = UNDEFINED_ESCAPE_CHARACTER;
protected final Log logger = LogFactory.getLog(getClass());
@Nullable
@@ -62,7 +105,7 @@ public abstract class AbstractPropertyResolver implements ConfigurablePropertyRe
private String valueSeparator = SystemPropertyUtils.VALUE_SEPARATOR;
@Nullable
private Character escapeCharacter = SystemPropertyUtils.ESCAPE_CHARACTER;
private Character escapeCharacter = getDefaultEscapeCharacter();
private final Set<String> requiredProperties = new LinkedHashSet<>();
@@ -124,9 +167,8 @@ public abstract class AbstractPropertyResolver implements ConfigurablePropertyRe
/**
* {@inheritDoc}
* <p>The default is {@code '\'}.
* <p>The default is determined by {@link #getDefaultEscapeCharacter()}.
* @since 6.2
* @see SystemPropertyUtils#ESCAPE_CHARACTER
*/
@Override
public void setEscapeCharacter(@Nullable Character escapeCharacter) {
@@ -287,4 +329,60 @@ public abstract class AbstractPropertyResolver implements ConfigurablePropertyRe
@Nullable
protected abstract String getPropertyAsRawString(String key);
/**
* Get the default {@linkplain #setEscapeCharacter(Character) escape character}
* to use when parsing strings for property placeholder resolution.
* <p>This method attempts to retrieve the default escape character configured
* via the {@value #DEFAULT_PLACEHOLDER_ESCAPE_CHARACTER_PROPERTY_NAME} JVM system
* property or Spring property.
* <p>Falls back to {@code '\'} if the property has not been set.
* @return the configured default escape character, {@code null} if escape character
* support has been disabled, or {@code '\'} if the property has not been set
* @throws IllegalArgumentException if the property is configured with an
* invalid value, such as {@link Character#MIN_VALUE} or a string containing
* more than one character
* @since 6.2.7
* @see #DEFAULT_PLACEHOLDER_ESCAPE_CHARACTER_PROPERTY_NAME
* @see SystemPropertyUtils#ESCAPE_CHARACTER
* @see SpringProperties
*/
@Nullable
public static Character getDefaultEscapeCharacter() throws IllegalArgumentException {
Character escapeCharacter = defaultEscapeCharacter;
if (UNDEFINED_ESCAPE_CHARACTER.equals(escapeCharacter)) {
String value = SpringProperties.getProperty(DEFAULT_PLACEHOLDER_ESCAPE_CHARACTER_PROPERTY_NAME);
if (value != null) {
if (value.isEmpty()) {
// Disable escape character support by default.
escapeCharacter = null;
}
else if (value.length() == 1) {
try {
// Use custom default escape character.
escapeCharacter = value.charAt(0);
}
catch (Exception ex) {
throw new IllegalArgumentException("Failed to process value [%s] for property [%s]: %s"
.formatted(value, DEFAULT_PLACEHOLDER_ESCAPE_CHARACTER_PROPERTY_NAME, ex.getMessage()), ex);
}
Assert.isTrue(!escapeCharacter.equals(Character.MIN_VALUE),
() -> "Value for property [%s] must not be Character.MIN_VALUE"
.formatted(DEFAULT_PLACEHOLDER_ESCAPE_CHARACTER_PROPERTY_NAME));
}
else {
throw new IllegalArgumentException(
"Value [%s] for property [%s] must be a single character or an empty string"
.formatted(value, DEFAULT_PLACEHOLDER_ESCAPE_CHARACTER_PROPERTY_NAME));
}
}
else {
// Use standard default value for the escape character.
escapeCharacter = SystemPropertyUtils.ESCAPE_CHARACTER;
}
defaultEscapeCharacter = escapeCharacter;
}
return escapeCharacter;
}
}

View File

@@ -0,0 +1,120 @@
/*
* 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.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.core.env;
import java.util.stream.IntStream;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.core.SpringProperties;
import org.springframework.lang.Nullable;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.springframework.core.env.AbstractPropertyResolver.DEFAULT_PLACEHOLDER_ESCAPE_CHARACTER_PROPERTY_NAME;
import static org.springframework.core.env.AbstractPropertyResolver.UNDEFINED_ESCAPE_CHARACTER;
/**
* Unit tests for {@link AbstractPropertyResolver}.
*
* @author Sam Brannen
* @since 6.2.7
*/
class AbstractPropertyResolverTests {
@BeforeEach
void resetStateBeforeEachTest() {
resetState();
}
@AfterAll
static void resetState() {
AbstractPropertyResolver.defaultEscapeCharacter = UNDEFINED_ESCAPE_CHARACTER;
setSpringProperty(null);
}
@Test
void getDefaultEscapeCharacterWithSpringPropertySetToCharacterMinValue() {
setSpringProperty("" + Character.MIN_VALUE);
assertThatIllegalArgumentException()
.isThrownBy(AbstractPropertyResolver::getDefaultEscapeCharacter)
.withMessage("Value for property [%s] must not be Character.MIN_VALUE",
DEFAULT_PLACEHOLDER_ESCAPE_CHARACTER_PROPERTY_NAME);
assertThat(AbstractPropertyResolver.defaultEscapeCharacter).isEqualTo(UNDEFINED_ESCAPE_CHARACTER);
}
@Test
void getDefaultEscapeCharacterWithSpringPropertySetToXyz() {
setSpringProperty("XYZ");
assertThatIllegalArgumentException()
.isThrownBy(AbstractPropertyResolver::getDefaultEscapeCharacter)
.withMessage("Value [XYZ] for property [%s] must be a single character or an empty string",
DEFAULT_PLACEHOLDER_ESCAPE_CHARACTER_PROPERTY_NAME);
assertThat(AbstractPropertyResolver.defaultEscapeCharacter).isEqualTo(UNDEFINED_ESCAPE_CHARACTER);
}
@Test
void getDefaultEscapeCharacterWithSpringPropertySetToEmptyString() {
setSpringProperty("");
assertEscapeCharacter(null);
}
@Test
void getDefaultEscapeCharacterWithoutSpringPropertySet() {
assertEscapeCharacter('\\');
}
@Test
void getDefaultEscapeCharacterWithSpringPropertySetToBackslash() {
setSpringProperty("\\");
assertEscapeCharacter('\\');
}
@Test
void getDefaultEscapeCharacterWithSpringPropertySetToTilde() {
setSpringProperty("~");
assertEscapeCharacter('~');
}
@Test
void getDefaultEscapeCharacterFromMultipleThreads() {
setSpringProperty("~");
IntStream.range(1, 32).parallel().forEach(__ ->
assertThat(AbstractPropertyResolver.getDefaultEscapeCharacter()).isEqualTo('~'));
assertThat(AbstractPropertyResolver.defaultEscapeCharacter).isEqualTo('~');
}
private static void setSpringProperty(String value) {
SpringProperties.setProperty(DEFAULT_PLACEHOLDER_ESCAPE_CHARACTER_PROPERTY_NAME, value);
}
private static void assertEscapeCharacter(@Nullable Character expected) {
assertThat(AbstractPropertyResolver.getDefaultEscapeCharacter()).isEqualTo(expected);
assertThat(AbstractPropertyResolver.defaultEscapeCharacter).isEqualTo(expected);
}
}