diff --git a/spring-context/src/main/java/org/springframework/validation/beanvalidation/SpringValidatorAdapter.java b/spring-context/src/main/java/org/springframework/validation/beanvalidation/SpringValidatorAdapter.java index 78348e3c1942..94bf95ea275e 100644 --- a/spring-context/src/main/java/org/springframework/validation/beanvalidation/SpringValidatorAdapter.java +++ b/spring-context/src/main/java/org/springframework/validation/beanvalidation/SpringValidatorAdapter.java @@ -351,11 +351,77 @@ protected MessageSourceResolvable getResolvableField(String objectName, String f * @see #getArgumentsForConstraint */ protected boolean requiresMessageFormat(ConstraintViolation violation) { - return containsSpringStylePlaceholder(violation.getMessage()); + return containsSpringStylePlaceholder( + violation.getMessage(), violation.getConstraintDescriptor().getAttributes()); } - private static boolean containsSpringStylePlaceholder(@Nullable String message) { - return (message != null && message.contains("{0}")); + /** + * Determine the default message to expose for the given constraint violation. + *

The default implementation escapes interpolated constraint attribute values + * before applying {@link java.text.MessageFormat}, since such values may contain + * curly braces from regular expressions or similar syntax. + * @param violation the Bean Validation constraint violation, including + * BV-defined interpolation of named attribute references in its message + * @return the default message for the corresponding Spring error + * @since 7.1 + * @see #requiresMessageFormat + */ + protected String determineDefaultMessage(ConstraintViolation violation) { + String message = violation.getMessage(); + return (requiresMessageFormat(violation) ? + escapeConstraintAttributeValues(message, violation.getConstraintDescriptor().getAttributes()) : + message); + } + + private static boolean containsSpringStylePlaceholder(@Nullable String message, Map attributes) { + if (message == null) { + return false; + } + int index = message.indexOf("{0}"); + while (index != -1) { + if (!isWithinConstraintAttributeValue(message, index, attributes)) { + return true; + } + index = message.indexOf("{0}", index + 3); + } + return false; + } + + private static boolean isWithinConstraintAttributeValue( + String message, int index, Map attributes) { + + for (Map.Entry attribute : attributes.entrySet()) { + if (INTERNAL_ANNOTATION_ATTRIBUTES.contains(attribute.getKey())) { + continue; + } + if (attribute.getValue() instanceof String value && value.contains("{0}")) { + int valueIndex = message.indexOf(value); + while (valueIndex != -1) { + if (valueIndex <= index && index < valueIndex + value.length()) { + return true; + } + valueIndex = message.indexOf(value, valueIndex + value.length()); + } + } + } + return false; + } + + private static String escapeConstraintAttributeValues(String message, Map attributes) { + String result = message; + for (Map.Entry attribute : attributes.entrySet()) { + if (INTERNAL_ANNOTATION_ATTRIBUTES.contains(attribute.getKey())) { + continue; + } + if (attribute.getValue() instanceof String value && !value.isEmpty()) { + result = result.replace(value, escapeMessageFormatPattern(value)); + } + } + return result; + } + + private static String escapeMessageFormatPattern(String value) { + return value.replace("'", "''").replace("{", "'{'").replace("}", "'}'"); } @@ -453,24 +519,19 @@ public String toString() { @SuppressWarnings("serial") private static class ValidationObjectError extends ObjectError implements Serializable { - private @Nullable transient SpringValidatorAdapter adapter; - - private @Nullable transient ConstraintViolation violation; + private final boolean shouldRenderDefaultMessage; public ValidationObjectError(String objectName, String[] codes, Object[] arguments, ConstraintViolation violation, SpringValidatorAdapter adapter) { - super(objectName, codes, arguments, violation.getMessage()); - this.adapter = adapter; - this.violation = violation; + super(objectName, codes, arguments, adapter.determineDefaultMessage(violation)); + this.shouldRenderDefaultMessage = adapter.requiresMessageFormat(violation); wrap(violation); } @Override public boolean shouldRenderDefaultMessage() { - return (this.adapter != null && this.violation != null ? - this.adapter.requiresMessageFormat(this.violation) : - containsSpringStylePlaceholder(getDefaultMessage())); + return this.shouldRenderDefaultMessage; } } @@ -481,24 +542,19 @@ public boolean shouldRenderDefaultMessage() { @SuppressWarnings("serial") private static class ValidationFieldError extends FieldError implements Serializable { - private @Nullable transient SpringValidatorAdapter adapter; - - private @Nullable transient ConstraintViolation violation; + private final boolean shouldRenderDefaultMessage; public ValidationFieldError(String objectName, String field, @Nullable Object rejectedValue, String[] codes, Object[] arguments, ConstraintViolation violation, SpringValidatorAdapter adapter) { - super(objectName, field, rejectedValue, false, codes, arguments, violation.getMessage()); - this.adapter = adapter; - this.violation = violation; + super(objectName, field, rejectedValue, false, codes, arguments, adapter.determineDefaultMessage(violation)); + this.shouldRenderDefaultMessage = adapter.requiresMessageFormat(violation); wrap(violation); } @Override public boolean shouldRenderDefaultMessage() { - return (this.adapter != null && this.violation != null ? - this.adapter.requiresMessageFormat(this.violation) : - containsSpringStylePlaceholder(getDefaultMessage())); + return this.shouldRenderDefaultMessage; } } diff --git a/spring-context/src/test/java/org/springframework/validation/beanvalidation/SpringValidatorAdapterTests.java b/spring-context/src/test/java/org/springframework/validation/beanvalidation/SpringValidatorAdapterTests.java index 69673345a29d..74b9fc7fd327 100644 --- a/spring-context/src/test/java/org/springframework/validation/beanvalidation/SpringValidatorAdapterTests.java +++ b/spring-context/src/test/java/org/springframework/validation/beanvalidation/SpringValidatorAdapterTests.java @@ -191,6 +191,24 @@ void patternMessage() { assertThat(error.unwrap(ConstraintViolation.class).getPropertyPath().toString()).isEqualTo("email"); } + @Test // gh-21750 + void patternMessageWithRangeQuantifier() throws Exception { + BeanWithPatternContainingRangeQuantifier bean = new BeanWithPatternContainingRangeQuantifier(); + bean.setCode("1234"); + + BeanPropertyBindingResult errors = new BeanPropertyBindingResult(bean, "bean"); + validatorAdapter.validate(bean, errors); + + assertThat(errors.getFieldErrorCount("code")).isEqualTo(1); + FieldError error = errors.getFieldError("code"); + assertThat(error).isNotNull(); + assertThat(messageSource.getMessage(error, Locale.ENGLISH)).isEqualTo("code must match \"\\d{1,3}\"."); + assertThat(messageSource.getMessage(SerializationTestUtils.serializeAndDeserialize(error), Locale.ENGLISH)) + .isEqualTo("code must match \"\\d{1,3}\"."); + assertThat(error.contains(ConstraintViolation.class)).isTrue(); + assertThat(error.unwrap(ConstraintViolation.class).getPropertyPath().toString()).isEqualTo("code"); + } + @Test // SPR-16177 void withList() { Parent parent = new Parent(); @@ -525,6 +543,21 @@ public boolean isValid(Object value, ConstraintValidatorContext context) { } + static class BeanWithPatternContainingRangeQuantifier { + + @Pattern(regexp = "\\d{1,3}", message = "{0} must match \"{regexp}\".") + private String code; + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + } + + public class BeanWithListElementConstraint { @Valid