diff --git a/bval-jsr/src/main/java/org/apache/bval/constraints/AbstractPatternValidator.java b/bval-jsr/src/main/java/org/apache/bval/constraints/AbstractPatternValidator.java index 34b66e97b2..6b8fba7489 100644 --- a/bval-jsr/src/main/java/org/apache/bval/constraints/AbstractPatternValidator.java +++ b/bval-jsr/src/main/java/org/apache/bval/constraints/AbstractPatternValidator.java @@ -42,6 +42,12 @@ public interface PatternDescriptor { protected Pattern pattern; + /** + * Whether {@link #pattern} is {@code .*} without flags, the default of {@code @Email}, which matches exactly the + * values without a line terminator. + */ + private boolean matchesAnyLine; + protected AbstractPatternValidator(Function toDescriptor) { super(); this.toDescriptor = Validate.notNull(toDescriptor); @@ -60,6 +66,7 @@ public void initialize(A constraintAnnotation) { } try { pattern = Pattern.compile(pd.regexp(), intFlag); + matchesAnyLine = intFlag == 0 && ".*".equals(pd.regexp()); } catch (PatternSyntaxException e) { throw new IllegalArgumentException("Invalid regular expression.", e); } @@ -67,6 +74,25 @@ public void initialize(A constraintAnnotation) { @Override public boolean isValid(T value, ConstraintValidatorContext context) { - return value == null || pattern.matcher(value).matches(); + if (value == null) { + return true; + } + return matchesAnyLine ? !containsLineTerminator(value) : pattern.matcher(value).matches(); + } + + private static boolean containsLineTerminator(CharSequence value) { + for (int i = 0, n = value.length(); i < n; i++) { + switch (value.charAt(i)) { + case '\n': + case '\r': + case '\u0085': + case '\u2028': + case '\u2029': + return true; + default: + break; + } + } + return false; } } diff --git a/bval-jsr/src/main/java/org/apache/bval/el/ELFacade.java b/bval-jsr/src/main/java/org/apache/bval/el/ELFacade.java index 6e53509eac..22fb386334 100644 --- a/bval-jsr/src/main/java/org/apache/bval/el/ELFacade.java +++ b/bval-jsr/src/main/java/org/apache/bval/el/ELFacade.java @@ -34,27 +34,10 @@ import jakarta.el.ValueExpression; import jakarta.el.VariableMapper; -import org.apache.bval.jsr.util.LookBehindRegexHolder; import org.apache.bval.util.Lazy; // ELProcessor or JavaEE 7 would be perfect too but this impl can be used in javaee 6 public final class ELFacade implements MessageEvaluator { - private enum EvaluationType { - IMMEDIATE("\\$"), DEFERRED("#"); - - /** - * {@link LookBehindRegexHolder} to recognize a non-escaped EL - * expression of this evaluation type, hallmarked by a trigger - * character. - */ - private final LookBehindRegexHolder regex; - - private EvaluationType(String trigger) { - this.regex = new LookBehindRegexHolder( - String.format("(? (n - 3) / 2); - } - } - private static final ELResolver RESOLVER = initResolver(); // Lazily initialized: ExpressionFactory.newInstance() performs a relatively expensive @@ -78,9 +61,8 @@ public String interpolate(final String message, final Map annota expressionFactory.createValueExpression(validatedValue, Object.class)); // Java Bean Validation does not support EL expressions that look like JSP "deferred" expressions - return expressionFactory.createValueExpression(context, - EvaluationType.DEFERRED.regex.matcher(message).replaceAll("\\$0"), String.class).getValue(context) - .toString(); + return expressionFactory.createValueExpression(context, disarmDeferredExpressions(message), String.class) + .getValue(context).toString(); } catch (final Exception e) { // no-op } @@ -88,6 +70,31 @@ public String interpolate(final String message, final Map annota return message; } + /** + * Replace each {@code #{} that is not escaped by an odd number of preceding backslashes with {@code $0}, so that + * it is not evaluated as a deferred expression. + */ + private static String disarmDeferredExpressions(String message) { + int trigger = message.indexOf("#{"); + if (trigger < 0) { + return message; + } + final StringBuilder result = new StringBuilder(message.length()); + int copied = 0; + do { + int backslashes = 0; + while (trigger - backslashes > 0 && message.charAt(trigger - backslashes - 1) == '\\') { + backslashes++; + } + if (backslashes % 2 == 0) { + result.append(message, copied, trigger).append("$0"); + copied = trigger + 2; + } + trigger = message.indexOf("#{", trigger + 2); + } while (trigger >= 0); + return result.append(message, copied, message.length()).toString(); + } + private static ELResolver initResolver() { final CompositeELResolver resolver = new CompositeELResolver(); resolver.add(new MapELResolver()); diff --git a/bval-jsr/src/main/java/org/apache/bval/jsr/DefaultMessageInterpolator.java b/bval-jsr/src/main/java/org/apache/bval/jsr/DefaultMessageInterpolator.java index 568a6a71c2..801425cb15 100644 --- a/bval-jsr/src/main/java/org/apache/bval/jsr/DefaultMessageInterpolator.java +++ b/bval-jsr/src/main/java/org/apache/bval/jsr/DefaultMessageInterpolator.java @@ -87,6 +87,8 @@ public Enumeration getKeys() { private final ConcurrentMap interpolatedMessages = new ConcurrentHashMap<>(); private final ConcurrentMap, Method> toStringMethods = new ConcurrentHashMap<>(); private final ConcurrentMap interpolations = new ConcurrentHashMap<>(); + /** Fully interpolated messages that involve no EL evaluation and so do not depend on the validated value. */ + private final ConcurrentMap finalMessages = new ConcurrentHashMap<>(); private final MessageEvaluator evaluator; @@ -134,6 +136,12 @@ public String interpolate(final String message, final Context context) { /** {@inheritDoc} */ @Override public String interpolate(final String message, final Context context, final Locale locale) { + final Map annotationParameters = context.getConstraintDescriptor().getAttributes(); + final FinalMessageKey finalKey = new FinalMessageKey(locale, message, annotationParameters); + final String cached = finalMessages.get(finalKey); + if (cached != null) { + return cached; + } if (!message.contains("{")) { return resolveEscapeSequences(message); } @@ -141,10 +149,14 @@ public String interpolate(final String message, final Context context, final Loc String resolvedMessage = getSharedInterpolatedMessage(message, locale); // resolve annotation attributes (step 4) - final Map annotationParameters = context.getConstraintDescriptor().getAttributes(); resolvedMessage = replaceAnnotationAttributes(resolvedMessage, annotationParameters); - // EL handling + // EL handling; the evaluator leaves messages without "${" untouched + if (!resolvedMessage.contains("${")) { + final String result = resolveEscapeSequences(resolvedMessage); + finalMessages.putIfAbsent(finalKey, result); + return result; + } if (evaluateExpressionLanguage(message, context)) { resolvedMessage = evaluator.interpolate(resolvedMessage, annotationParameters, context.getValidatedValue()); } @@ -323,6 +335,7 @@ private String replaceAnnotationAttributes(final String message, final Map annotationParameters; + private final int hash; + + private FinalMessageKey(final Locale locale, final String message, + final Map annotationParameters) { + this.locale = locale; + this.message = message; + this.annotationParameters = annotationParameters; + this.hash = 31 * (31 * locale.hashCode() + message.hashCode()) + System.identityHashCode(annotationParameters); + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + final FinalMessageKey that = (FinalMessageKey) o; + return annotationParameters == that.annotationParameters && message.equals(that.message) + && locale.equals(that.locale); + } + + @Override + public int hashCode() { + return hash; + } + } + + /** + * Keys the parameter map by identity: a constraint descriptor hands out the same attribute map instance on every + * call, so this avoids hashing and comparing the whole map for each interpolation. + */ private static class MessageWithParamsKey { private final String message; private final Map annotationParameters; @@ -400,7 +453,7 @@ private static class MessageWithParamsKey { private MessageWithParamsKey(final String message, final Map annotationParameters) { this.message = message; this.annotationParameters = annotationParameters; - this.hash = Objects.hash(message, annotationParameters); + this.hash = 31 * message.hashCode() + System.identityHashCode(annotationParameters); } @Override @@ -412,7 +465,7 @@ public boolean equals(final Object o) { return false; } final MessageWithParamsKey that = MessageWithParamsKey.class.cast(o); - return message.equals(that.message) && annotationParameters.equals(that.annotationParameters); + return annotationParameters == that.annotationParameters && message.equals(that.message); } @Override diff --git a/bval-jsr/src/main/java/org/apache/bval/jsr/descriptor/ConstraintD.java b/bval-jsr/src/main/java/org/apache/bval/jsr/descriptor/ConstraintD.java index 4e2dac5ff2..124bafdf11 100644 --- a/bval-jsr/src/main/java/org/apache/bval/jsr/descriptor/ConstraintD.java +++ b/bval-jsr/src/main/java/org/apache/bval/jsr/descriptor/ConstraintD.java @@ -80,6 +80,8 @@ private static Set set(Supplier array) { private final Set> composingConstraints; private final List>> constraintValidatorClasses; + // racy single-check memoization: the annotation is immutable, so concurrent initializations agree + private String messageTemplate; private final Lazy toString = new Lazy<>(() -> String.format("%s: %s", ConstraintD.class.getSimpleName(), getAnnotation())); @@ -142,7 +144,12 @@ public boolean isReportAsSingleViolation() { @Override public String getMessageTemplate() { - return read(ConstraintAnnotationAttributes.MESSAGE, Optionality.REQUIRED); + String result = messageTemplate; + if (result == null) { + result = read(ConstraintAnnotationAttributes.MESSAGE, Optionality.REQUIRED); + messageTemplate = result; + } + return result; } @Override diff --git a/bval-jsr/src/main/java/org/apache/bval/routines/EMailValidationUtils.java b/bval-jsr/src/main/java/org/apache/bval/routines/EMailValidationUtils.java index 0835baeab5..764ab4077a 100644 --- a/bval-jsr/src/main/java/org/apache/bval/routines/EMailValidationUtils.java +++ b/bval-jsr/src/main/java/org/apache/bval/routines/EMailValidationUtils.java @@ -44,7 +44,105 @@ public class EMailValidationUtils { * @return true if the validation passes */ public static boolean isValid(Object value) { - return isValid(value, DEFAULT_EMAIL_PATTERN); + if (value == null) { + return true; + } + if (!(value instanceof CharSequence)) { + return false; + } + final CharSequence seq = (CharSequence) value; + return seq.length() == 0 || matchesDefaultPattern(seq); + } + + /** + * Equivalent to {@code DEFAULT_EMAIL_PATTERN.matcher(seq).matches()} without the regular expression engine: one + * or more dot-separated runs of atom characters, {@code @}, then either such runs or a bracketed dotted quad. + */ + private static boolean matchesDefaultPattern(CharSequence seq) { + final int length = seq.length(); + final int at = matchAtomRuns(seq, 0, length); + if (at < 0 || at >= length || seq.charAt(at) != '@') { + return false; + } + final int domain = at + 1; + return matchAtomRuns(seq, domain, length) == length || matchIpDomain(seq, domain, length); + } + + /** + * Match {@code ATOM+(\.ATOM+)*} starting at {@code start} as far as possible. + * + * @return the index after the match, or {@code -1} if there is none + */ + private static int matchAtomRuns(CharSequence seq, int start, int length) { + int i = start; + while (true) { + final int runStart = i; + while (i < length && isAtom(seq.charAt(i))) { + i++; + } + if (i == runStart) { + // an empty run: fail at the start, otherwise back off the dot that introduced it + return runStart == start ? -1 : runStart - 1; + } + if (i < length && seq.charAt(i) == '.') { + i++; + } else { + return i; + } + } + } + + private static boolean isAtom(char c) { + if (c <= 0x20) { + // control characters, and the space of \s; the other \s characters are control characters + return false; + } + switch (c) { + case '(': + case ')': + case '<': + case '>': + case '@': + case ',': + case ';': + case ':': + case '\\': + case '"': + case '.': + case '[': + case ']': + return false; + default: + return true; + } + } + + /** Match {@code \[d{1,3}\.d{1,3}\.d{1,3}\.d{1,3}\]} spanning exactly {@code [start, length)}. */ + private static boolean matchIpDomain(CharSequence seq, int start, int length) { + if (length - start < 9 || seq.charAt(start) != '[' || seq.charAt(length - 1) != ']') { + return false; + } + int i = start + 1; + for (int part = 0; part < 4; part++) { + if (part > 0) { + if (seq.charAt(i) != '.') { + return false; + } + i++; + } + final int digitsStart = i; + while (i < length - 1 && i - digitsStart < 3 && isDigit(seq.charAt(i))) { + i++; + } + if (i == digitsStart) { + return false; + } + } + return i == length - 1; + } + + private static boolean isDigit(char c) { + return c >= '0' && c <= '9'; } /** diff --git a/bval-jsr/src/test/java/org/apache/bval/el/ELFacadeTest.java b/bval-jsr/src/test/java/org/apache/bval/el/ELFacadeTest.java new file mode 100644 index 0000000000..eb8340dc04 --- /dev/null +++ b/bval-jsr/src/test/java/org/apache/bval/el/ELFacadeTest.java @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 + * + * http://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.apache.bval.el; + +import static org.junit.Assert.assertEquals; + +import java.util.Collections; + +import org.junit.Test; + +public class ELFacadeTest { + private final ELFacade facade = new ELFacade(); + + @Test + public void disarmsUnescapedDeferredExpressions() { + assertInterpolates("a no deferred", "${'a'} no deferred"); + assertInterpolates("a $0x} b", "${'a'} #{x} b"); + assertInterpolates("$0x} a", "#{x} ${'a'}"); + assertInterpolates("a $0x} $0y}", "${'a'} #{x} #{y}"); + } + + @Test + public void keepsDeferredExpressionsEscapedByAnOddNumberOfBackslashes() { + assertInterpolates("a #{x} b", "${'a'} \\#{x} b"); + assertInterpolates("a \\\\$0x} b", "${'a'} \\\\#{x} b"); + assertInterpolates("a \\\\#{x} b", "${'a'} \\\\\\#{x} b"); + } + + @Test + public void leavesMessagesWithoutImmediateExpressionsUntouched() { + assertInterpolates("#{x} without immediate", "#{x} without immediate"); + } + + private void assertInterpolates(String expected, String message) { + assertEquals(expected, facade.interpolate(message, Collections.emptyMap(), null)); + } +} diff --git a/bval-jsr/src/test/java/org/apache/bval/routines/EMailValidationUtilsTest.java b/bval-jsr/src/test/java/org/apache/bval/routines/EMailValidationUtilsTest.java new file mode 100644 index 0000000000..a2eaafd7ca --- /dev/null +++ b/bval-jsr/src/test/java/org/apache/bval/routines/EMailValidationUtilsTest.java @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 + * + * http://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.apache.bval.routines; + +import static org.junit.Assert.assertEquals; + +import java.util.Random; + +import org.junit.Test; + +public class EMailValidationUtilsTest { + private static final String[] TOKENS = + { "ab", "x", ".", "@", "[", "]", "1", "12", "123", "1234", " ", "é", "\\", "\"", ";", "\n", "\u007f" }; + + @Test + public void matchesLikeDefaultPattern() { + final String[] examples = { "a@b", "a.b@c.d", "a..b@c", "a.@b", ".a@b", "@b", "a@", "a@b.", "a@.b", + "a@[1.2.3.4]", "a@[1.2.3.1234]", "a@[1.2.3]", "a@[1.2.3.4]x", "a@b@c", "ä@ö.de", "a b@c", + "a@b c", "a\"b@c", "a@[123.45.6.789]" }; + for (String example : examples) { + assertMatchesLikePattern(example); + } + final Random random = new Random(42); + for (int i = 0; i < 100_000; i++) { + final StringBuilder candidate = new StringBuilder(); + for (int j = 1 + random.nextInt(12); j > 0; j--) { + candidate.append(TOKENS[random.nextInt(TOKENS.length)]); + } + assertMatchesLikePattern(candidate.toString()); + } + } + + private static void assertMatchesLikePattern(String candidate) { + assertEquals(candidate, EMailValidationUtils.isValid(candidate, EMailValidationUtils.DEFAULT_EMAIL_PATTERN), + EMailValidationUtils.isValid(candidate)); + } +}