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 a2837aabc WW-5703 fix(core): keep the HTML5 pattern from rejecting 
blank input the regex validator skips (#1938)
a2837aabc is described below

commit a2837aabc6ff07776d434fafb63db8e21547dcc6
Author: Lukasz Lenart <[email protected]>
AuthorDate: Mon Sep 14 13:53:19 2026 +0200

    WW-5703 fix(core): keep the HTML5 pattern from rejecting blank input the 
regex validator skips (#1938)
    
    * WW-5703 fix(core): keep the HTML5 pattern from rejecting blank input the 
regex validator skips
    
    RegexFieldValidator.validateFieldValue returns before it consults its
    trim param whenever the value trims to empty, so a whitespace-only value
    passes the server in every configuration - including the only one
    (trim=false, case-sensitive, portable) in which a pattern is emitted.
    The browser skips pattern only for the empty string, so a single space
    was blocked client-side and accepted server-side, violating the
    never-false-reject rule.
    
    Unless the field also carries a trimming requiredstring validator, which
    rejects blank input server-side, the pattern now reads
    (?:<regex>)|[\x00-\x20]* - the regex, or a value made only of the
    characters String.trim() strips. Verified with node 24 under the v flag:
    space, tab and vertical-tab-only values pass, a non-breaking space and
    non-matching values still fail, matching values are unaffected. The
    presence check is a pre-scan of the field's validators so the order in
    validation.xml does not matter.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
    
    * WW-5703 refactor(core): admit blank input as a post-step instead of a flag
    
    addConstraints and addPattern keep their original signatures; the
    whitespace-only alternative is applied once per field after the loop by
    admitBlankInPattern, guarded by rejectsBlank.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
    
    * WW-5703 refactor(core): name the pattern attribute once
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
    
    * WW-5703 refactor(core): qualify the attribute-name constants
    
    ATTRIBUTE_NAME in the same class is a regex Pattern, so a bare PATTERN
    invited the wrong reading.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
    
    * WW-5703 refactor(core): wrap the pattern with computeIfPresent
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
 .../components/StrutsHtmlConstraintProvider.java   | 40 ++++++++++++++++++---
 .../StrutsHtmlConstraintProviderTest.java          | 42 +++++++++++++++++++++-
 .../struts2/validator/RegexFieldValidatorTest.java | 17 +++++++++
 .../views/jsp/ui/Html5ConstraintRenderingTest.java |  7 +++-
 4 files changed, 100 insertions(+), 6 deletions(-)

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 4f538037c..775939d8e 100644
--- 
a/core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java
+++ 
b/core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java
@@ -51,12 +51,18 @@ 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";
+    private static final String REQUIRED_ATTRIBUTE = "required";
+    private static final String PATTERN_ATTRIBUTE = "pattern";
     /**
      * 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_.-]+");
+    /**
+     * The characters {@link String#trim()} strips: a value made only of these 
is skipped by
+     * {@code RegexFieldValidator} whatever its {@code trim} param says.
+     */
+    private static final String BLANK = "[\\x00-\\x20]*";
 
     @Override
     public Map<String, String> constraintsFor(List<Validator> validators, 
HtmlControlType control, Object action) {
@@ -70,9 +76,35 @@ public class StrutsHtmlConstraintProvider implements 
HtmlConstraintProvider {
                 addMessage(attributes, validator, action);
             }
         }
+        if (!rejectsBlank(validators)) {
+            admitBlankInPattern(attributes);
+        }
         return attributes;
     }
 
+    /**
+     * Only a trimming {@code requiredstring} fails a whitespace-only value 
server-side; with
+     * {@code trim=false} it counts as non-empty and falls through to the 
other validators.
+     */
+    protected boolean rejectsBlank(List<Validator> validators) {
+        for (Validator validator : validators) {
+            if (validator instanceof RequiredStringValidator required && 
required.isTrim()) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * {@code RegexFieldValidator} skips a value that trims to empty before it 
looks at {@code trim},
+     * while the browser skips {@code pattern} only for the empty string. 
Unless another validator on
+     * the field rejects blank input, the pattern therefore gets a 
whitespace-only alternative so that
+     * a single space is not blocked client-side and accepted server-side.
+     */
+    protected void admitBlankInPattern(Map<String, String> attributes) {
+        attributes.computeIfPresent(PATTERN_ATTRIBUTE, (name, regex) -> "(?:" 
+ regex + ")|" + BLANK);
+    }
+
     protected void addConstraints(Map<String, String> attributes, Validator 
validator, HtmlControlType control) {
         if (validator instanceof RequiredStringValidator) {
             addRequiredString(attributes, control);
@@ -97,7 +129,7 @@ public class StrutsHtmlConstraintProvider implements 
HtmlConstraintProvider {
         if (!control.supportsLength()) {
             return;
         }
-        attributes.put(REQUIRED, REQUIRED);
+        attributes.put(REQUIRED_ATTRIBUTE, REQUIRED_ATTRIBUTE);
     }
 
     /**
@@ -111,7 +143,7 @@ public class StrutsHtmlConstraintProvider implements 
HtmlConstraintProvider {
         if (control != HtmlControlType.RADIO && control != 
HtmlControlType.FILE) {
             return;
         }
-        attributes.put(REQUIRED, REQUIRED);
+        attributes.put(REQUIRED_ATTRIBUTE, REQUIRED_ATTRIBUTE);
     }
 
     protected void addLength(Map<String, String> attributes, 
StringLengthFieldValidator validator, HtmlControlType control) {
@@ -146,7 +178,7 @@ public class StrutsHtmlConstraintProvider implements 
HtmlConstraintProvider {
         }
         String regex = validator.getRegex();
         if (EcmaScriptSafeRegex.isSafe(regex)) {
-            attributes.put("pattern", regex);
+            attributes.put(PATTERN_ATTRIBUTE, regex);
         }
     }
 
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 357accbc0..23ad65374 100644
--- 
a/core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java
+++ 
b/core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java
@@ -152,16 +152,56 @@ public class StrutsHtmlConstraintProviderTest {
     }
 
     @Test
-    public void regexEmitsPatternWhenPortableAndCaseSensitive() {
+    public void regexAloneEmitsPatternThatAlsoAcceptsBlankInput() {
+        // RegexFieldValidator skips a value that trims to empty regardless of 
its trim param, so a
+        // bare pattern would block whitespace-only input the server lets 
through
         RegexFieldValidator validator = new RegexFieldValidator();
         validator.setRegex("[a-z]+");
         validator.setCaseSensitive(true);
         validator.setTrim(false);
 
         assertThat(constraints(validator, HtmlControlType.TEXT))
+            .containsEntry("pattern", "(?:[a-z]+)|[\\x00-\\x20]*");
+    }
+
+    @Test
+    public void regexWithTrimmingRequiredStringEmitsBarePattern() {
+        // a trimming requiredstring rejects blank input server-side, so the 
two sides agree again
+        RegexFieldValidator regex = new RegexFieldValidator();
+        regex.setRegex("[a-z]+");
+        regex.setCaseSensitive(true);
+        regex.setTrim(false);
+
+        assertThat(provider.constraintsFor(List.of(new 
RequiredStringValidator(), regex), HtmlControlType.TEXT, null))
             .containsEntry("pattern", "[a-z]+");
     }
 
+    @Test
+    public void regexWithRequiredStringListedAfterItStillEmitsBarePattern() {
+        RegexFieldValidator regex = new RegexFieldValidator();
+        regex.setRegex("[a-z]+");
+        regex.setCaseSensitive(true);
+        regex.setTrim(false);
+
+        assertThat(provider.constraintsFor(List.of(regex, new 
RequiredStringValidator()), HtmlControlType.TEXT, null))
+            .containsEntry("pattern", "[a-z]+");
+    }
+
+    @Test
+    public void regexWithNonTrimmingRequiredStringKeepsTheBlankAlternative() {
+        // requiredstring with trim=false accepts " " as non-empty, so blank 
input still reaches the
+        // regex validator's unconditional skip
+        RegexFieldValidator regex = new RegexFieldValidator();
+        regex.setRegex("[a-z]+");
+        regex.setCaseSensitive(true);
+        regex.setTrim(false);
+        RequiredStringValidator required = new RequiredStringValidator();
+        required.setTrim(false);
+
+        assertThat(provider.constraintsFor(List.of(required, regex), 
HtmlControlType.TEXT, null))
+            .containsEntry("pattern", "(?:[a-z]+)|[\\x00-\\x20]*");
+    }
+
     @Test
     public void regexEmitsNothingWhenCaseInsensitive() {
         RegexFieldValidator validator = new RegexFieldValidator();
diff --git 
a/core/src/test/java/org/apache/struts2/validator/RegexFieldValidatorTest.java 
b/core/src/test/java/org/apache/struts2/validator/RegexFieldValidatorTest.java
index c2988efa0..686c0dde3 100644
--- 
a/core/src/test/java/org/apache/struts2/validator/RegexFieldValidatorTest.java
+++ 
b/core/src/test/java/org/apache/struts2/validator/RegexFieldValidatorTest.java
@@ -82,6 +82,23 @@ public class RegexFieldValidatorTest extends XWorkTestCase {
         assertFalse(validator.getValidatorContext().hasFieldErrors());
     }
 
+    public void testBlankValueIsSkippedEvenWithoutTrim() throws Exception {
+        // StrutsHtmlConstraintProvider mirrors this skip in the emitted HTML5 
pattern; if the
+        // validator ever starts matching blank input, that mirror has to go 
too
+        MyTestPerson testPerson = new MyTestPerson();
+        testPerson.setUsername(" \t ");
+
+        RegexFieldValidator validator = new RegexFieldValidator();
+        validator.setTrim(false);
+        validator.setRegex("^\\d{5}$");
+        validator.setValidatorContext(new DummyValidatorContext(new Object(), 
tpf));
+        validator.setFieldName("username");
+        validator.setValueStack(ActionContext.getContext().getValueStack());
+        validator.validate(testPerson);
+
+        assertFalse(validator.getValidatorContext().hasFieldErrors());
+    }
+
     public void testFail() throws Exception {
         MyTestPerson testPerson = new MyTestPerson();
         testPerson.setUsername("Superman");
diff --git 
a/core/src/test/java/org/apache/struts2/views/jsp/ui/Html5ConstraintRenderingTest.java
 
b/core/src/test/java/org/apache/struts2/views/jsp/ui/Html5ConstraintRenderingTest.java
index b6717b2f4..bc85f1926 100644
--- 
a/core/src/test/java/org/apache/struts2/views/jsp/ui/Html5ConstraintRenderingTest.java
+++ 
b/core/src/test/java/org/apache/struts2/views/jsp/ui/Html5ConstraintRenderingTest.java
@@ -86,10 +86,15 @@ public class Html5ConstraintRenderingTest extends 
AbstractUITagTest {
             output.contains("Contains \"quotes\" and <brackets>"));
     }
 
+    /**
+     * The field carries only a regex validator, so the pattern also has to 
admit the whitespace-only
+     * input the server skips; the suffix must survive FreeMarker's attribute 
escaping unchanged.
+     */
     public void testRendersPatternOnATextField() throws Exception {
         String output = render("true", "code", null);
 
-        assertTrue("expected pattern in: " + output, 
output.contains("pattern=\"^[A-Z]{3}\\d{2}$\""));
+        assertTrue("expected pattern in: " + output,
+            
output.contains("pattern=\"(?:^[A-Z]{3}\\d{2}$)|[\\x00-\\x20]*\""));
     }
 
     /**

Reply via email to