This is an automated email from the ASF dual-hosted git repository.

lukaszlenart pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/struts.git


The following commit(s) were added to refs/heads/main by this push:
     new bb5761dff WW-5702 fix(core): tighten the HTML5 constraint provider's 
attribute hygiene (#1935)
bb5761dff is described below

commit bb5761dff0c0c18cd7e5a859a2c20bee208d1045
Author: Lukasz Lenart <[email protected]>
AuthorDate: Mon Sep 14 08:45:34 2026 +0200

    WW-5702 fix(core): tighten the HTML5 constraint provider's attribute 
hygiene (#1935)
    
    * WW-5702 fix(core): tighten the HTML5 constraint provider's attribute 
hygiene
    
    Items 2, 5, 6, 8, 9, 10 and 11 of the review follow-up to WW-5695.
    
    - data-msg-* is emitted only for a control that submits a value; s:label
      and unrecognised types get none. Select, checkbox and hidden keep the
      documented script hook.
    - max goes through a finiteness guard like min: a Date-typed range on a
      numeric control no longer renders Date.toString(), a NaN or infinite
      double bound is omitted.
    - Integrality of min is decided on the decimal representation, which is
      what gets rendered; a BigDecimal that only rounds to a whole number as
      a double no longer shifts the step base.
    - A type entry from a provider is discarded before rendering, and the
      HtmlConstraintProvider javadoc no longer advertises the type="email"
      override that produced a duplicate attribute.
    - Dynamic attribute names are compared case-insensitively, as HTML does,
      so a developer's MAXLENGTH wins over the derived maxlength.
    - A validator type that is not a plain attribute name gets no data-msg
      attribute; the name is outside FreeMarker's escaping.
    - EcmaScriptSafeRegex admits \- only inside a character class, where the
      browser's Unicode-mode compiler accepts it, and rejects a class opening
      with a literal ] that the two engines read differently.
    
    HtmlControlType.OTHER is renamed UNSUPPORTED now that the constant is
    load-bearing: it names what happens (no constraint, no message), where
    OTHER conflated unknown type values with non-submitting components.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
    
    * WW-5702 fix(core): align EcmaScriptSafeRegex with the browser's 
unicode-sets mode
    
    Review of the hygiene change showed the allowlist still admitted patterns
    the browser silently drops: HTML compiles `pattern` with the `v` flag,
    under which ( ) { } / | must be escaped inside a class, a hyphen there is
    only a range operator between two plain literals, doubled punctuators
    are reserved, and a lone ] or } outside a class is an error. All read as
    literals by Java, so `[a-z0-9._%+-]+@` passed and did nothing. Verified
    on node 24.
    
    Also from review: a provider's `Type`/`Maxlength` is dropped regardless
    of case like the dynamic-attribute check already was, and a dotted
    validator type (`acme.required`) keeps its data-msg attribute — only the
    colon stays out, as an XML namespace prefix.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
    
    * WW-5702 fix(core): treat [^ as the class opening and reject stacked 
quantifiers
    
    Second review pass on the unicode-sets alignment: the negation marker
    was scanned as a literal a range could start from, so [^-a] passed while
    the browser throws; and a{2}{3} passed although only Java stacks
    quantifiers. Verified against Java and node 24 over a corpus of typical
    validator regexes — every accepted pattern now compiles in both.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
    
    * WW-5702 refactor(core): split the regex allowlist scan into a stateful 
Scanner
    
    Sonar S3776: isSafe reached cognitive complexity 43 once the unicode-sets
    rules landed in one loop. The state (position, in-class, range start,
    after-quantifier) moves into a private Scanner with one method per
    branch; verdicts over the differential corpus are unchanged.
    Sonar S1117: the lambda parameter in addConstraintAttributes hid the
    UIBean.name field.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
    
    * WW-5702 refactor(core): make the regex allowlist helpers Scanner methods
    
    Sonar S3398 on the previous split: the static helpers were called only
    from Scanner. They are instance methods now and read the regex and
    position from the scanner instead of threading them through; corpus
    verdicts are unchanged.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
 .../struts2/components/EcmaScriptSafeRegex.java    | 244 ++++++++++++++++-----
 .../struts2/components/HtmlConstraintProvider.java |   9 +-
 .../apache/struts2/components/HtmlControlType.java |  15 +-
 .../components/StrutsHtmlConstraintProvider.java   |  43 +++-
 .../java/org/apache/struts2/components/UIBean.java |  24 +-
 .../components/ConstraintAttributesTest.java       |  46 ++++
 .../apache/struts2/components/ControlTypeTest.java |   2 +-
 .../components/EcmaScriptSafeRegexTest.java        |  73 ++++++
 .../struts2/components/HtmlControlTypeTest.java    |  14 +-
 .../StrutsHtmlConstraintProviderTest.java          |  80 ++++++-
 10 files changed, 474 insertions(+), 76 deletions(-)

diff --git 
a/core/src/main/java/org/apache/struts2/components/EcmaScriptSafeRegex.java 
b/core/src/main/java/org/apache/struts2/components/EcmaScriptSafeRegex.java
index 2614c5c57..8400680ea 100644
--- a/core/src/main/java/org/apache/struts2/components/EcmaScriptSafeRegex.java
+++ b/core/src/main/java/org/apache/struts2/components/EcmaScriptSafeRegex.java
@@ -47,7 +47,15 @@ public final class EcmaScriptSafeRegex {
      * every browser. On the Java 17 baseline that is a false reject, and no 
version check could fix
      * it: one {@code validation.xml} would have to mean two different things 
depending on the JVM.
      */
-    private static final String ALLOWED_ESCAPES = "dDwWnrtf\\.*+?()[]{}|^$/-";
+    private static final String ALLOWED_ESCAPES = "dDwWnrtf\\.*+?()[]{}|^$/";
+
+    /**
+     * Browsers compile {@code pattern} with the {@code v} (unicode sets) 
flag. Inside a character
+     * class that mode reserves these unescaped, and any doubled {@link 
#CLASS_PUNCTUATORS}, as syntax;
+     * Java reads them as literals.
+     */
+    private static final String CLASS_SYNTAX = "(){}/|";
+    private static final String CLASS_PUNCTUATORS = "&!#$%*+,.:;<=>?@^`~";
 
     private EcmaScriptSafeRegex() {
     }
@@ -56,69 +64,205 @@ public final class EcmaScriptSafeRegex {
         if (regex == null || regex.isEmpty()) {
             return false;
         }
-        boolean inCharClass = false;
-        int i = 0;
-        while (i < regex.length()) {
-            char current = regex.charAt(i);
-            if (!isPortable(regex, i, current, inCharClass)) {
+        Scanner scanner = new Scanner(regex);
+        while (scanner.hasMore()) {
+            if (!scanner.scanNext()) {
                 return false;
             }
-            if (current == '[') {
-                inCharClass = true;
-            } else if (current == ']') {
-                inCharClass = false;
-            }
-            // an escape consumes the character it escapes, which must not be 
scanned again
-            i += (current == '\\') ? 2 : 1;
         }
-        return !inCharClass;
+        return !scanner.inCharClass;
     }
 
     /**
-     * Whether the construct starting at {@code index} means the same thing to 
both engines. This is
-     * the whole allowlist: anything that reaches {@code default} is a 
character with no special
-     * meaning in either engine, or one whose meaning is shared.
+     * Walks the regex one unit at a time — a character, an escape pair, or a 
whole {@code {n,m}}
+     * quantifier — and refuses the first one the two engines disagree on. The 
allowlist lives in
+     * {@link #isPortable} and {@link #isPortableInClass}: anything that 
reaches their default branch is
+     * a character with no special meaning in either engine, or one whose 
meaning is shared.
      */
-    private static boolean isPortable(String regex, int index, char current, 
boolean inCharClass) {
-        switch (current) {
-            case '\\':
-                return isAllowedEscape(regex, index);
-            case '[':
-                // Java allows nested classes and POSIX names; ECMAScript 
allows neither
-                return !inCharClass && !regex.startsWith("[:", index);
-            case '&':
-                // Java character-class intersection
-                return !inCharClass || !isFollowedBy(regex, index, '&');
-            case '(':
-                return isPortableGroup(regex, index);
-            case '*', '+', '?', '}':
-                // possessive quantifier
-                return !isFollowedBy(regex, index, '+');
-            default:
+    private static final class Scanner {
+        private final String regex;
+        private int index;
+        private boolean inCharClass;
+        // true while the previous unit is a plain literal a range can start 
from
+        private boolean rangeStartAvailable;
+        // true right after a quantifier: Java stacks them (a{2}{3}), the 
browser has nothing to repeat
+        private boolean afterQuantifier;
+
+        Scanner(String regex) {
+            this.regex = regex;
+        }
+
+        boolean hasMore() {
+            return index < regex.length();
+        }
+
+        boolean scanNext() {
+            char current = regex.charAt(index);
+            if (current == '\\') {
+                return scanEscape();
+            }
+            if (inCharClass) {
+                return scanInClass(current);
+            }
+            if (current == '{') {
+                return scanQuantifier();
+            }
+            return scanOutsideClass(current);
+        }
+
+        private boolean scanEscape() {
+            if (!isAllowedEscape()) {
+                return false;
+            }
+            // an escape consumes the character it escapes, which must not be 
scanned again;
+            // in unicode-sets mode a class escape cannot bound a range either
+            rangeStartAvailable = false;
+            afterQuantifier = false;
+            index += 2;
+            return true;
+        }
+
+        private boolean scanInClass(char current) {
+            if (current == ']') {
+                inCharClass = false;
+                index++;
                 return true;
+            }
+            if (current == '-') {
+                if (!isRangeOperator()) {
+                    return false;
+                }
+                rangeStartAvailable = false;
+                index += 2;
+                return true;
+            }
+            if (!isPortableInClass(current)) {
+                return false;
+            }
+            rangeStartAvailable = true;
+            index++;
+            return true;
         }
-    }
 
-    private static boolean isAllowedEscape(String regex, int index) {
-        return index + 1 < regex.length() && 
ALLOWED_ESCAPES.indexOf(regex.charAt(index + 1)) >= 0;
-    }
+        private boolean scanQuantifier() {
+            int close = endOfQuantifier();
+            if (afterQuantifier || close < 0 || isFollowedBy(close, '+')) {
+                return false;
+            }
+            afterQuantifier = true;
+            index = close + 1;
+            return true;
+        }
 
-    /**
-     * Only non-capturing groups and lookahead are portable; named groups, 
lookbehind, atomic groups
-     * and inline flags are not. A plain capturing group is always fine.
-     */
-    private static boolean isPortableGroup(String regex, int index) {
-        if (!isFollowedBy(regex, index, '?')) {
+        private boolean scanOutsideClass(char current) {
+            boolean stacked = afterQuantifier && (current == '*' || current == 
'+');
+            if (stacked || !isPortable(current)) {
+                return false;
+            }
+            if (current == '[') {
+                inCharClass = true;
+                rangeStartAvailable = false;
+                // the negation marker is part of the class opening, not a 
literal
+                index += isFollowedBy(index, '^') ? 2 : 1;
+            } else {
+                index++;
+            }
+            afterQuantifier = current == '*' || current == '+' || current == 
'?';
             return true;
         }
-        if (index + 2 >= regex.length()) {
-            return false;
+
+        /**
+         * Whether the construct at the current position, outside a character 
class, means the same
+         * thing to both engines.
+         */
+        private boolean isPortable(char current) {
+            switch (current) {
+                case '[':
+                    // Java allows POSIX names and a leading literal ]; 
ECMAScript allows neither
+                    return !regex.startsWith("[:", index) && 
!opensWithLiteralBracket();
+                case ']', '}':
+                    // a literal in Java, "lone quantifier brackets" in the 
browser
+                    return false;
+                case '(':
+                    return isPortableGroup();
+                case '*', '+', '?':
+                    // possessive quantifier
+                    return !isFollowedBy(index, '+');
+                default:
+                    return true;
+            }
+        }
+
+        private boolean isPortableInClass(char current) {
+            if (current == '[' || CLASS_SYNTAX.indexOf(current) >= 0) {
+                // nested classes are Java-only; the rest are unicode-sets 
syntax characters
+                return false;
+            }
+            return CLASS_PUNCTUATORS.indexOf(current) < 0 || 
!isFollowedBy(index, current);
+        }
+
+        /**
+         * Inside a class an unescaped hyphen is portable only as a range 
operator between two plain
+         * literals: {@code [a-z]}. Anywhere else Java reads it as a literal 
and the browser throws.
+         */
+        private boolean isRangeOperator() {
+            if (!rangeStartAvailable || index + 1 >= regex.length()) {
+                return false;
+            }
+            char end = regex.charAt(index + 1);
+            return end != ']' && end != '\\' && end != '-' && end != '[' && 
CLASS_SYNTAX.indexOf(end) < 0;
         }
-        char kind = regex.charAt(index + 2);
-        return kind == ':' || kind == '=' || kind == '!';
-    }
 
-    private static boolean isFollowedBy(String regex, int index, char 
expected) {
-        return index + 1 < regex.length() && regex.charAt(index + 1) == 
expected;
+        /**
+         * Index of the {@code }} closing the {@code {n}}, {@code {n,}} or 
{@code {n,m}} quantifier that
+         * opens at the current position, or -1 when the braces do not form 
one — which Java rejects as well.
+         */
+        private int endOfQuantifier() {
+            int close = regex.indexOf('}', index);
+            if (close < 0 || !regex.substring(index + 1, 
close).matches("\\d+(,\\d*)?")) {
+                return -1;
+            }
+            return close;
+        }
+
+        private boolean isAllowedEscape() {
+            if (index + 1 >= regex.length()) {
+                return false;
+            }
+            char escaped = regex.charAt(index + 1);
+            // in unicode mode \- is only legal inside a class
+            if (escaped == '-') {
+                return inCharClass;
+            }
+            return ALLOWED_ESCAPES.indexOf(escaped) >= 0;
+        }
+
+        /**
+         * Java reads a {@code ]} directly after {@code [} or {@code [^} as a 
literal member of the
+         * class; the browser's unicode-mode compiler rejects it.
+         */
+        private boolean opensWithLiteralBracket() {
+            int first = isFollowedBy(index, '^') ? index + 2 : index + 1;
+            return first < regex.length() && regex.charAt(first) == ']';
+        }
+
+        /**
+         * Only non-capturing groups and lookahead are portable; named groups, 
lookbehind, atomic
+         * groups and inline flags are not. A plain capturing group is always 
fine.
+         */
+        private boolean isPortableGroup() {
+            if (!isFollowedBy(index, '?')) {
+                return true;
+            }
+            if (index + 2 >= regex.length()) {
+                return false;
+            }
+            char kind = regex.charAt(index + 2);
+            return kind == ':' || kind == '=' || kind == '!';
+        }
+
+        private boolean isFollowedBy(int at, char expected) {
+            return at + 1 < regex.length() && regex.charAt(at + 1) == expected;
+        }
     }
 }
diff --git 
a/core/src/main/java/org/apache/struts2/components/HtmlConstraintProvider.java 
b/core/src/main/java/org/apache/struts2/components/HtmlConstraintProvider.java
index 6fdeaa52b..926065984 100644
--- 
a/core/src/main/java/org/apache/struts2/components/HtmlConstraintProvider.java
+++ 
b/core/src/main/java/org/apache/struts2/components/HtmlConstraintProvider.java
@@ -27,8 +27,13 @@ import java.util.Map;
  * Maps a field's validators onto the HTML attributes a theme should render 
for it.
  * <p>
  * The default implementation is deliberately conservative — see {@link 
StrutsHtmlConstraintProvider}.
- * Applications wanting a best-effort mapping (an {@code email} validator 
becoming
- * {@code type="email"}, say) should register their own implementation instead.
+ * Applications wanting a less conservative mapping (a rewritten {@code 
pattern} for a
+ * case-insensitive regex, say) should register their own implementation 
instead.
+ * <p>
+ * A {@code type} entry in the returned map is discarded: the templates have 
already written the
+ * input's {@code type} by the time the map renders, so changing it needs a 
template override, not a
+ * provider. Any entry whose name matches an attribute the developer set on 
the tag is discarded too —
+ * the developer's own value always wins.
  *
  * @since 7.4.0
  */
diff --git 
a/core/src/main/java/org/apache/struts2/components/HtmlControlType.java 
b/core/src/main/java/org/apache/struts2/components/HtmlControlType.java
index 6e69617b9..c432042f7 100644
--- a/core/src/main/java/org/apache/struts2/components/HtmlControlType.java
+++ b/core/src/main/java/org/apache/struts2/components/HtmlControlType.java
@@ -38,7 +38,12 @@ public enum HtmlControlType {
     DATE, MONTH, WEEK, TIME, DATETIME_LOCAL,
     CHECKBOX, RADIO, FILE, HIDDEN, SELECT,
     TEXTAREA,
-    OTHER;
+    /**
+     * A control no constraint applies to and no {@code data-msg-*} message is 
rendered for: a
+     * {@code type} the framework does not recognise, or a component with no
+     * {@code getControlType()} override ({@code <s:label>}, {@code 
<s:submit>} and the like).
+     */
+    UNSUPPORTED;
 
     private static final Set<HtmlControlType> TEXT_ENTRY = EnumSet.of(TEXT, 
SEARCH, TEL, PASSWORD, EMAIL, URL);
     private static final Set<HtmlControlType> NUMERIC = EnumSet.of(NUMBER, 
RANGE);
@@ -46,21 +51,21 @@ public enum HtmlControlType {
 
     /**
      * Resolves a raw {@code type} attribute value. Never throws: the 
attribute is OGNL-evaluated, so at
-     * runtime it can be any string. Anything unrecognised becomes {@link 
#OTHER}, which supports no
+     * runtime it can be any string. Anything unrecognised becomes {@link 
#UNSUPPORTED}, which supports no
      * constraints at all — so an unknown control degrades to emitting nothing.
      */
     public static HtmlControlType from(String type) {
         if (type == null) {
-            return OTHER;
+            return UNSUPPORTED;
         }
         String normalised = type.trim().toUpperCase(Locale.ROOT).replace('-', 
'_');
         if (normalised.isEmpty()) {
-            return OTHER;
+            return UNSUPPORTED;
         }
         try {
             return valueOf(normalised);
         } catch (IllegalArgumentException e) {
-            return OTHER;
+            return UNSUPPORTED;
         }
     }
 
diff --git 
a/core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java
 
b/core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java
index 79790aabb..4f538037c 100644
--- 
a/core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java
+++ 
b/core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java
@@ -28,9 +28,11 @@ import 
org.apache.struts2.validator.validators.RequiredFieldValidator;
 import org.apache.struts2.validator.validators.RequiredStringValidator;
 import org.apache.struts2.validator.validators.StringLengthFieldValidator;
 
+import java.math.BigDecimal;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.regex.Pattern;
 
 /**
  * Default {@link HtmlConstraintProvider}.
@@ -50,6 +52,11 @@ public class StrutsHtmlConstraintProvider implements 
HtmlConstraintProvider {
      * The HTML5 boolean attribute; its canonical serialisation repeats the 
attribute name as the value.
      */
     private static final String REQUIRED = "required";
+    /**
+     * What a validator type may contain to become part of a {@code 
data-msg-*} name: no character that
+     * ends or splits an attribute name, and no colon, which an XML parser 
reads as a namespace prefix.
+     */
+    private static final Pattern ATTRIBUTE_NAME = 
Pattern.compile("[A-Za-z0-9_.-]+");
 
     @Override
     public Map<String, String> constraintsFor(List<Validator> validators, 
HtmlControlType control, Object action) {
@@ -59,7 +66,9 @@ public class StrutsHtmlConstraintProvider implements 
HtmlConstraintProvider {
         }
         for (Validator validator : validators) {
             addConstraints(attributes, validator, control);
-            addMessage(attributes, validator, action);
+            if (control != HtmlControlType.UNSUPPORTED) {
+                addMessage(attributes, validator, action);
+            }
         }
         return attributes;
     }
@@ -154,7 +163,10 @@ public class StrutsHtmlConstraintProvider implements 
HtmlConstraintProvider {
         if (isIntegral(min)) {
             putIfPresent(attributes, "min", min);
         }
-        putIfPresent(attributes, "max", validator.getMax());
+        Object max = validator.getMax();
+        if (isFiniteNumber(max)) {
+            putIfPresent(attributes, "max", max);
+        }
     }
 
     protected void addDoubleRange(Map<String, String> attributes, 
DoubleRangeFieldValidator validator, HtmlControlType control) {
@@ -170,7 +182,10 @@ public class StrutsHtmlConstraintProvider implements 
HtmlConstraintProvider {
         if (isIntegral(minInclusive)) {
             putIfPresent(attributes, "min", minInclusive);
         }
-        putIfPresent(attributes, "max", validator.getMaxInclusive());
+        Double maxInclusive = validator.getMaxInclusive();
+        if (isFiniteNumber(maxInclusive)) {
+            putIfPresent(attributes, "max", maxInclusive);
+        }
     }
 
     private boolean isNumericRange(HtmlControlType control) {
@@ -183,20 +198,38 @@ public class StrutsHtmlConstraintProvider implements 
HtmlConstraintProvider {
      * step base, so only {@code min} needs this guard.
      */
     private boolean isIntegral(Object value) {
+        if (!isFiniteNumber(value)) {
+            return false;
+        }
+        // decided on the decimal representation, which is also what gets 
rendered: a BigDecimal
+        // such as 1.0000000000000000001 rounds to 1.0 as a double yet renders 
with its fraction
+        try {
+            return new 
BigDecimal(value.toString()).stripTrailingZeros().scale() <= 0;
+        } catch (NumberFormatException e) {
+            return false;
+        }
+    }
+
+    private boolean isFiniteNumber(Object value) {
         if (!(value instanceof java.lang.Number number)) {
             return false;
         }
         double asDouble = number.doubleValue();
-        return !Double.isNaN(asDouble) && !Double.isInfinite(asDouble) && 
asDouble == Math.floor(asDouble);
+        return !Double.isNaN(asDouble) && !Double.isInfinite(asDouble);
     }
 
     protected void addMessage(Map<String, String> attributes, Validator 
validator, Object action) {
         if (action == null) {
             return;
         }
+        // the type becomes part of the attribute name, which FreeMarker's 
auto-escaping does not cover
+        String type = validator.getValidatorType();
+        if (type == null || !ATTRIBUTE_NAME.matcher(type).matches()) {
+            return;
+        }
         String message = validator.getMessage(action);
         if (message != null && !message.isEmpty()) {
-            attributes.put("data-msg-" + validator.getValidatorType(), 
message);
+            attributes.put("data-msg-" + type, message);
         }
     }
 
diff --git a/core/src/main/java/org/apache/struts2/components/UIBean.java 
b/core/src/main/java/org/apache/struts2/components/UIBean.java
index af466d386..089950fbe 100644
--- a/core/src/main/java/org/apache/struts2/components/UIBean.java
+++ b/core/src/main/java/org/apache/struts2/components/UIBean.java
@@ -50,6 +50,7 @@ import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.function.Function;
 
 import static java.util.Collections.emptyMap;
@@ -959,7 +960,10 @@ public abstract class UIBean extends Component {
                 return;
             }
             constraints = new LinkedHashMap<>(constraints);
-            constraints.keySet().removeIf(this::isAlreadyRendered);
+            // the template has already written type by the time the map 
renders; a second one is a
+            // duplicate attribute, of which the browser keeps the first
+            constraints.keySet().removeIf(attributeName ->
+                "type".equalsIgnoreCase(attributeName) || 
isAlreadyRendered(attributeName));
             if (!constraints.isEmpty()) {
                 addParameter("constraints", constraints);
             }
@@ -1039,10 +1043,20 @@ public abstract class UIBean extends Component {
      * {@code required} attribute the developer typed by hand as a dynamic 
attribute still wins.
      */
     private boolean isAlreadyRendered(String attributeName) {
-        if (dynamicAttributes.containsKey(attributeName)) {
+        // HTML attribute names are ASCII case-insensitive
+        if (containsIgnoreCase(dynamicAttributes.keySet(), attributeName)) {
             return true;
         }
-        return !"required".equals(attributeName) && 
getAttributes().containsKey(attributeName);
+        return !"required".equalsIgnoreCase(attributeName) && 
containsIgnoreCase(getAttributes().keySet(), attributeName);
+    }
+
+    private static boolean containsIgnoreCase(Set<String> names, String name) {
+        for (String candidate : names) {
+            if (candidate.equalsIgnoreCase(name)) {
+                return true;
+            }
+        }
+        return false;
     }
 
     /**
@@ -1110,13 +1124,13 @@ public abstract class UIBean extends Component {
 
     /**
      * The kind of HTML control this component renders, used to decide which 
HTML5 constraint
-     * attributes are legal on it. Defaults to {@link HtmlControlType#OTHER}, 
which supports no
+     * attributes are legal on it. Defaults to {@link 
HtmlControlType#UNSUPPORTED}, which supports no
      * constraints — so a component that does not override this emits none.
      *
      * @since 7.4.0
      */
     protected HtmlControlType getControlType() {
-        return HtmlControlType.OTHER;
+        return HtmlControlType.UNSUPPORTED;
     }
 
     protected void evaluateExtraParams() {
diff --git 
a/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java
 
b/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java
index 6d70cd20e..6c7a59e73 100644
--- 
a/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java
+++ 
b/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java
@@ -36,6 +36,8 @@ public class ConstraintAttributesTest extends 
AbstractUITagTest {
     private FormTag form;
     private String theme = "html5";
     private String fieldName = "username";
+    private final Map<String, String> dynamicAttributes = new HashMap<>();
+    private String declaredMaxlength;
 
     public void testNoConstraintsWhenTheConstantIsOff() throws Exception {
         initDispatcherWith("false");
@@ -86,6 +88,44 @@ public class ConstraintAttributesTest extends 
AbstractUITagTest {
         assertFalse(constraints.containsKey("data-msg-field-visitor"));
     }
 
+    /**
+     * HTML attribute names are ASCII case-insensitive, and the documented 
rule is that the developer's
+     * own value always wins — including one typed as {@code MAXLENGTH}.
+     */
+    public void testADynamicAttributeSuppressesTheDerivedOneRegardlessOfCase() 
throws Exception {
+        initDispatcherWith("true");
+        fieldName = "bio";
+        dynamicAttributes.put("MAXLENGTH", "5");
+
+        Map<String, String> constraints = 
renderFieldAndReturnConstraints(null);
+
+        assertTrue("expected the derived maxlength to yield to the developer's 
MAXLENGTH",
+            constraints == null || !constraints.containsKey("maxlength"));
+    }
+
+    /**
+     * {@code html5/text.ftl} has already emitted a hardcoded {@code type} by 
the time the constraint
+     * map renders, so a {@code type} from a provider would be a duplicate 
attribute the browser drops.
+     */
+    public void 
testATypeOrADeclaredAttributeFromTheProviderIsDiscardedRegardlessOfCase() 
throws Exception {
+        initDispatcherWith("true");
+
+        declaredMaxlength = "5";
+        TextFieldTag field = startField(null);
+        ((UIBean) field.getComponent()).setHtmlConstraintProvider((validators, 
control, derivedFrom) ->
+            new java.util.LinkedHashMap<>(Map.of("Type", "email", "Maxlength", 
"9", "required", "required")));
+        Map<String, Object> attributes = ((UIBean) 
field.getComponent()).getAttributes();
+
+        finishField(field);
+
+        @SuppressWarnings("unchecked")
+        Map<String, String> constraints = (Map<String, String>) 
attributes.get("constraints");
+        assertNotNull(constraints);
+        assertFalse("type must never reach the template, whatever its case", 
constraints.containsKey("Type"));
+        assertFalse("a declared maxlength wins over a provider's Maxlength", 
constraints.containsKey("Maxlength"));
+        assertEquals("required", constraints.get("required"));
+    }
+
     /**
      * Pins the hook to running after {@code evaluateExtraParams()}. A {@code 
stringlength} validator on
      * a control the browser treats as numeric must not emit {@code minlength} 
at all — that attribute
@@ -185,6 +225,12 @@ public class ConstraintAttributesTest extends 
AbstractUITagTest {
         if (type != null) {
             field.setType(type);
         }
+        if (declaredMaxlength != null) {
+            field.setMaxlength(declaredMaxlength);
+        }
+        for (Map.Entry<String, String> dynamic : dynamicAttributes.entrySet()) 
{
+            field.setDynamicAttribute(null, dynamic.getKey(), 
dynamic.getValue());
+        }
         field.doStartTag();
         return field;
     }
diff --git 
a/core/src/test/java/org/apache/struts2/components/ControlTypeTest.java 
b/core/src/test/java/org/apache/struts2/components/ControlTypeTest.java
index 9c819b059..8f4cd026d 100644
--- a/core/src/test/java/org/apache/struts2/components/ControlTypeTest.java
+++ b/core/src/test/java/org/apache/struts2/components/ControlTypeTest.java
@@ -36,7 +36,7 @@ public class ControlTypeTest extends AbstractUITagTest {
     public void testTextFieldFallsBackForAnUnknownType() {
         TextField textField = new TextField(stack, request, response);
         textField.addParameter("type", "supercolor");
-        assertEquals(HtmlControlType.OTHER, textField.getControlType());
+        assertEquals(HtmlControlType.UNSUPPORTED, textField.getControlType());
     }
 
     public void testPasswordIsAlwaysPassword() {
diff --git 
a/core/src/test/java/org/apache/struts2/components/EcmaScriptSafeRegexTest.java 
b/core/src/test/java/org/apache/struts2/components/EcmaScriptSafeRegexTest.java
index 743ccc4c3..c76895f0a 100644
--- 
a/core/src/test/java/org/apache/struts2/components/EcmaScriptSafeRegexTest.java
+++ 
b/core/src/test/java/org/apache/struts2/components/EcmaScriptSafeRegexTest.java
@@ -43,6 +43,79 @@ public class EcmaScriptSafeRegexTest {
         assertThat(EcmaScriptSafeRegex.isSafe("\\h+")).isFalse();
     }
 
+    @Test
+    public void allowsAnEscapedHyphenOnlyInsideACharacterClass() {
+        // HTML compiles pattern with the Unicode flag, under which \- outside 
a class is a
+        // SyntaxError and the whole attribute is ignored — silently, so the 
allowlist must not admit it
+        assertThat(EcmaScriptSafeRegex.isSafe("[\\w\\-]+")).isTrue();
+        assertThat(EcmaScriptSafeRegex.isSafe("\\d+\\-\\d+")).isFalse();
+    }
+
+    @Test
+    public void rejectsAClosingBracketThatOpensAClass() {
+        // Java reads []a] as a class holding ] and a; the browser's 
unicode-mode compiler throws
+        assertThat(EcmaScriptSafeRegex.isSafe("[]a]")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("[^]a]")).isFalse();
+    }
+
+    @Test
+    public void rejectsALoneClosingBracketOrBraceOutsideAClass() {
+        // literals in Java, "lone quantifier brackets" in the browser
+        assertThat(EcmaScriptSafeRegex.isSafe("a]")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("x}")).isFalse();
+    }
+
+    @Test
+    public void rejectsUnicodeSetsSyntaxCharactersUnescapedInsideAClass() {
+        // browsers compile pattern with the v flag, under which ( ) { } / | 
and a hyphen that is not
+        // a range operator must be escaped inside a class — verified on node 
24
+        assertThat(EcmaScriptSafeRegex.isSafe("[a-z/]")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("^[+-]?\\d+$")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("[-a]")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("[a-]")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("[(]")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("[|]")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("[{]")).isFalse();
+        
assertThat(EcmaScriptSafeRegex.isSafe("[a-z0-9._%+-]+@[a-z]+")).isFalse();
+        // the negation marker is not a literal a range can start from
+        assertThat(EcmaScriptSafeRegex.isSafe("[^-a]")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("^[^-,]+$")).isFalse();
+    }
+
+    @Test
+    public void rejectsStackedQuantifiers() {
+        // Java compiles a{2}{3}; the browser throws "nothing to repeat"
+        assertThat(EcmaScriptSafeRegex.isSafe("a{2}{3}")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("[a-z]{2}{3}")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("a{2}*")).isFalse();
+    }
+
+    @Test
+    public void acceptsLazyQuantifiersAndNegatedClasses() {
+        assertThat(EcmaScriptSafeRegex.isSafe("a+?")).isTrue();
+        assertThat(EcmaScriptSafeRegex.isSafe("a{2}?")).isTrue();
+        assertThat(EcmaScriptSafeRegex.isSafe("[^a-z]+")).isTrue();
+        assertThat(EcmaScriptSafeRegex.isSafe("[^\\d]")).isTrue();
+        assertThat(EcmaScriptSafeRegex.isSafe("(?:ab){2}")).isTrue();
+    }
+
+    @Test
+    public void rejectsDoubledPunctuatorsInsideAClass() {
+        assertThat(EcmaScriptSafeRegex.isSafe("[a..z]")).isFalse();
+        assertThat(EcmaScriptSafeRegex.isSafe("[!!]")).isFalse();
+    }
+
+    @Test
+    public void acceptsUnicodeSetsSafeClasses() {
+        assertThat(EcmaScriptSafeRegex.isSafe("[a-z]")).isTrue();
+        assertThat(EcmaScriptSafeRegex.isSafe("[a.z]")).isTrue();
+        assertThat(EcmaScriptSafeRegex.isSafe("[a\\-z]")).isTrue();
+        assertThat(EcmaScriptSafeRegex.isSafe("[\\d\\-]")).isTrue();
+        
assertThat(EcmaScriptSafeRegex.isSafe("[a-z0-9._%+\\-]+@[a-z]+")).isTrue();
+        assertThat(EcmaScriptSafeRegex.isSafe("a/b")).isTrue();
+        assertThat(EcmaScriptSafeRegex.isSafe("a{2}")).isTrue();
+    }
+
     @Test
     public void rejectsPossessiveQuantifiers() {
         assertThat(EcmaScriptSafeRegex.isSafe("\\d++")).isFalse();
diff --git 
a/core/src/test/java/org/apache/struts2/components/HtmlControlTypeTest.java 
b/core/src/test/java/org/apache/struts2/components/HtmlControlTypeTest.java
index 9fa862cdd..0adf678d5 100644
--- a/core/src/test/java/org/apache/struts2/components/HtmlControlTypeTest.java
+++ b/core/src/test/java/org/apache/struts2/components/HtmlControlTypeTest.java
@@ -38,17 +38,17 @@ public class HtmlControlTypeTest {
 
     @Test
     public void neverThrowsOnUnusableInput() {
-        
assertThat(HtmlControlType.from(null)).isEqualTo(HtmlControlType.OTHER);
-        assertThat(HtmlControlType.from("")).isEqualTo(HtmlControlType.OTHER);
-        assertThat(HtmlControlType.from("   
")).isEqualTo(HtmlControlType.OTHER);
-        
assertThat(HtmlControlType.from("supercolor")).isEqualTo(HtmlControlType.OTHER);
+        
assertThat(HtmlControlType.from(null)).isEqualTo(HtmlControlType.UNSUPPORTED);
+        
assertThat(HtmlControlType.from("")).isEqualTo(HtmlControlType.UNSUPPORTED);
+        assertThat(HtmlControlType.from("   
")).isEqualTo(HtmlControlType.UNSUPPORTED);
+        
assertThat(HtmlControlType.from("supercolor")).isEqualTo(HtmlControlType.UNSUPPORTED);
     }
 
     @Test
     public void otherSupportsNothing() {
-        assertThat(HtmlControlType.OTHER.supportsPattern()).isFalse();
-        assertThat(HtmlControlType.OTHER.supportsLength()).isFalse();
-        assertThat(HtmlControlType.OTHER.supportsRange()).isFalse();
+        assertThat(HtmlControlType.UNSUPPORTED.supportsPattern()).isFalse();
+        assertThat(HtmlControlType.UNSUPPORTED.supportsLength()).isFalse();
+        assertThat(HtmlControlType.UNSUPPORTED.supportsRange()).isFalse();
     }
 
     @Test
diff --git 
a/core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java
 
b/core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java
index 0cc97eab0..357accbc0 100644
--- 
a/core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java
+++ 
b/core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java
@@ -25,6 +25,7 @@ import 
org.apache.struts2.validator.validators.DateRangeFieldValidator;
 import org.apache.struts2.validator.validators.DoubleRangeFieldValidator;
 import org.apache.struts2.validator.validators.EmailValidator;
 import org.apache.struts2.validator.validators.IntRangeFieldValidator;
+import org.apache.struts2.validator.validators.RangeValidatorSupport;
 import org.apache.struts2.validator.validators.RegexFieldValidator;
 import org.apache.struts2.validator.validators.RequiredFieldValidator;
 import org.apache.struts2.validator.validators.RequiredStringValidator;
@@ -32,6 +33,7 @@ import 
org.apache.struts2.validator.validators.StringLengthFieldValidator;
 import org.junit.Before;
 import org.junit.Test;
 
+import java.math.BigDecimal;
 import java.util.Date;
 import java.util.List;
 import java.util.Map;
@@ -289,7 +291,7 @@ public class StrutsHtmlConstraintProviderTest {
 
     @Test
     public void unknownControlGetsNothing() {
-        assertThat(constraints(new RequiredFieldValidator(), 
HtmlControlType.OTHER)).isEmpty();
+        assertThat(constraints(new RequiredFieldValidator(), 
HtmlControlType.UNSUPPORTED)).isEmpty();
     }
 
     @Test
@@ -298,6 +300,82 @@ public class StrutsHtmlConstraintProviderTest {
         assertThat(provider.constraintsFor(List.of(), HtmlControlType.TEXT, 
null)).isEmpty();
     }
 
+    @Test
+    public void messageIsNotEmittedOnAControlThatNeverSubmits() {
+        // s:label and unknown controls resolve to UNSUPPORTED; a message 
there decorates an element the
+        // browser never validates and no script has a submitted value to 
check it against
+        Validator validator = mock(Validator.class);
+        when(validator.getValidatorType()).thenReturn("requiredstring");
+        when(validator.getMessage(action)).thenReturn("required");
+
+        Map<String, String> result =
+            provider.constraintsFor(singletonList(validator), 
HtmlControlType.UNSUPPORTED, action);
+
+        assertThat(result).isEmpty();
+    }
+
+    @Test
+    public void 
messageIsNotEmittedForAValidatorTypeThatCannotBeAnAttributeName() {
+        // validator types are free-form in validators.xml; FreeMarker escapes 
the value, not the name
+        Validator validator = mock(Validator.class);
+        when(validator.getValidatorType()).thenReturn("my type=\"x\"");
+        when(validator.getMessage(action)).thenReturn("nope");
+
+        Map<String, String> result =
+            provider.constraintsFor(singletonList(validator), 
HtmlControlType.TEXT, action);
+
+        assertThat(result).isEmpty();
+    }
+
+    @Test
+    public void messageIsEmittedForADottedValidatorType() {
+        // legal both as a validators.xml type name and as a data-* attribute 
suffix
+        Validator validator = mock(Validator.class);
+        when(validator.getValidatorType()).thenReturn("acme.required");
+        when(validator.getMessage(action)).thenReturn("needed");
+
+        Map<String, String> result =
+            provider.constraintsFor(singletonList(validator), 
HtmlControlType.TEXT, action);
+
+        assertThat(result).containsEntry("data-msg-acme.required", "needed");
+    }
+
+    @Test
+    public void rangeOmitsAMaxThatIsNotANumber() {
+        // a date range on a control the developer declared numeric: min 
already fails the integral
+        // guard, max must not fall through as Date.toString()
+        DateRangeFieldValidator validator = new DateRangeFieldValidator();
+        validator.setMin(new Date(0));
+        validator.setMax(new Date(1_000_000L));
+
+        assertThat(constraints(validator, HtmlControlType.NUMBER)).isEmpty();
+    }
+
+    @Test
+    public void doubleRangeOmitsANonFiniteMax() {
+        DoubleRangeFieldValidator nan = new DoubleRangeFieldValidator();
+        nan.setMaxInclusive(Double.NaN);
+        DoubleRangeFieldValidator infinite = new DoubleRangeFieldValidator();
+        infinite.setMaxInclusive(Double.POSITIVE_INFINITY);
+
+        assertThat(constraints(nan, 
HtmlControlType.NUMBER)).doesNotContainKey("max");
+        assertThat(constraints(infinite, 
HtmlControlType.NUMBER)).doesNotContainKey("max");
+    }
+
+    @Test
+    public void rangeOmitsAMinThatIsOnlyIntegralAfterRoundingThroughDouble() {
+        // 1.0000000000000000001 collapses to 1.0 as a double but renders with 
its fraction, which
+        // would shift the HTML step base exactly like a fractional min
+        RangeValidatorSupport<BigDecimal> validator = new 
RangeValidatorSupport<>(BigDecimal.class) {
+        };
+        validator.setMin(new BigDecimal("1.0000000000000000001"));
+        validator.setMax(new BigDecimal("10"));
+
+        assertThat(constraints(validator, HtmlControlType.NUMBER))
+            .doesNotContainKey("min")
+            .containsEntry("max", "10");
+    }
+
     @Test
     public void messageIsEmittedEvenForAValidatorThatContributesNoConstraint() 
{
         Validator validator = mock(Validator.class);

Reply via email to