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

commit 1a4471e25a9a0ea18e9e8d7fa4907a4daee42a17
Author: Lukasz Lenart <[email protected]>
AuthorDate: Sun Oct 25 17:12:32 2026 +0100

    WW-5811 fix(html5): never let derived date constraints reject what the 
server accepts
    
    - The property type is read on the form's submission action class. A
      different rendering action could declare the same name with another
      type; a ModelDriven submission action it does not render gives null,
      as its model type is only known at runtime.
    - DATE ranges dispatch through addRange again, so a 7.4.0 subclass
      overriding it keeps receiving them.
    - A java.util.Date bound before the Gregorian cutover is skipped:
      DateConverter reads earlier yyyy-MM-dd values as Julian dates, so
      1500-01-01 rendered as min="1500-01-10".
    - A field with its own step gets no derived min, which would become the
      step base and move the developer's grid.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../java/org/apache/struts2/components/Form.java   | 28 ++++++++------
 .../components/StrutsHtmlConstraintProvider.java   | 15 +++++---
 .../java/org/apache/struts2/components/UIBean.java |  4 ++
 .../apache/struts2/TestConfigurationProvider.java  |  3 +-
 .../components/ConstraintAttributesTest.java       | 44 ++++++++++++++++++++++
 .../StrutsHtmlConstraintProviderTest.java          | 27 +++++++++++++
 6 files changed, 104 insertions(+), 17 deletions(-)

diff --git a/core/src/main/java/org/apache/struts2/components/Form.java 
b/core/src/main/java/org/apache/struts2/components/Form.java
index 8e5ccdbab..ba9a84d0e 100644
--- a/core/src/main/java/org/apache/struts2/components/Form.java
+++ b/core/src/main/java/org/apache/struts2/components/Form.java
@@ -439,17 +439,18 @@ public class Form extends ClosingUIBean {
     }
 
     /**
-     * The declared type of the property a field binds to, found on the object 
parameter binding would
-     * write to and then walked through declared types rather than values, so 
a null intermediate object
-     * (which binding creates) does not hide it. Null for an indexed or 
unknown path.
+     * The declared type of the property a field binds to on the form's 
submission action, walked
+     * through declared types rather than values, so a null intermediate 
object (which binding creates)
+     * does not hide it. Null for an indexed or unknown path, and for a 
model-driven submission action
+     * the form is not rendered by, whose model type is only known at runtime.
      */
     Class<?> getPropertyType(String name) {
         if (name == null || name.isEmpty() || StringUtils.containsAny(name, 
'[', '(')) {
             return null;
         }
         String[] segments = name.split("\\.");
-        for (Object root : bindingRoots()) {
-            Class<?> type = declaredType(root.getClass(), segments[0]);
+        for (Class<?> root : bindingRoots()) {
+            Class<?> type = declaredType(root, segments[0]);
             if (type != null) {
                 for (int i = 1; i < segments.length && type != null; i++) {
                     type = declaredType(type, segments[i]);
@@ -460,17 +461,22 @@ public class Form extends ClosingUIBean {
         return null;
     }
 
-    private List<Object> bindingRoots() {
+    private List<Class<?>> bindingRoots() {
+        Class<?> actionClass = (Class<?>) 
getAttributes().get(ATTR_ACTION_CLASS);
+        if (actionClass == null) {
+            return Collections.emptyList();
+        }
         Object invoked = currentAction();
-        if (invoked == null) {
-            return new ArrayList<>(getStack().getRoot());
+        if (!actionClass.isInstance(invoked)) {
+            return ModelDriven.class.isAssignableFrom(actionClass)
+                ? Collections.emptyList() : 
Collections.singletonList(actionClass);
         }
-        List<Object> roots = new ArrayList<>(2);
+        List<Class<?>> roots = new ArrayList<>(2);
         Object model = modelPushedAbove(invoked, getStack().getRoot());
         if (model != null) {
-            roots.add(model);
+            roots.add(model.getClass());
         }
-        roots.add(invoked);
+        roots.add(invoked.getClass());
         return roots;
     }
 
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 f53839f5c..b64fbd5ca 100644
--- 
a/core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java
+++ 
b/core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java
@@ -37,6 +37,7 @@ import java.time.OffsetDateTime;
 import java.time.ZoneId;
 import java.util.Calendar;
 import java.util.Date;
+import java.util.GregorianCalendar;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
@@ -137,11 +138,7 @@ public class StrutsHtmlConstraintProvider implements 
HtmlConstraintProvider {
         } else if (validator instanceof DoubleRangeFieldValidator 
doubleValidator) {
             addDoubleRange(attributes, doubleValidator, control);
         } else if (validator instanceof RangeValidatorSupport<?> 
rangeValidator) {
-            if (control == HtmlControlType.DATE) {
-                addDateRange(attributes, rangeValidator);
-            } else {
-                addRange(attributes, rangeValidator, control);
-            }
+            addRange(attributes, rangeValidator, control);
         }
     }
 
@@ -224,6 +221,10 @@ public class StrutsHtmlConstraintProvider implements 
HtmlConstraintProvider {
         if (bound instanceof LocalDate localDate) {
             date = localDate;
         } else if (bound instanceof Date utilDate) {
+            // before the cutover DateConverter reads yyyy-MM-dd as a Julian 
date, the browser as a Gregorian one
+            if (utilDate.before(new GregorianCalendar().getGregorianChange())) 
{
+                return null;
+            }
             date = 
Instant.ofEpochMilli(utilDate.getTime()).atZone(ZoneId.systemDefault()).toLocalDate();
         } else {
             return null;
@@ -272,6 +273,10 @@ public class StrutsHtmlConstraintProvider implements 
HtmlConstraintProvider {
     }
 
     protected void addRange(Map<String, String> attributes, 
RangeValidatorSupport<?> validator, HtmlControlType control) {
+        if (control == HtmlControlType.DATE) {
+            addDateRange(attributes, validator);
+            return;
+        }
         if (!isNumericRange(control)) {
             return;
         }
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 353fd6abd..06d7f3f29 100644
--- a/core/src/main/java/org/apache/struts2/components/UIBean.java
+++ b/core/src/main/java/org/apache/struts2/components/UIBean.java
@@ -996,6 +996,10 @@ public abstract class UIBean extends Component {
             // duplicate attribute, of which the browser keeps the first
             constraints.keySet().removeIf(attributeName ->
                 "type".equalsIgnoreCase(attributeName) || 
isAlreadyRendered(attributeName));
+            // min is the step base, so it would move the developer's own step 
grid
+            if (isAlreadyRendered("step")) {
+                constraints.remove("min");
+            }
             if (!constraints.isEmpty()) {
                 addParameter("constraints", constraints);
             }
diff --git 
a/core/src/test/java/org/apache/struts2/TestConfigurationProvider.java 
b/core/src/test/java/org/apache/struts2/TestConfigurationProvider.java
index 22f15f1c4..085dc354f 100644
--- a/core/src/test/java/org/apache/struts2/TestConfigurationProvider.java
+++ b/core/src/test/java/org/apache/struts2/TestConfigurationProvider.java
@@ -39,6 +39,7 @@ import 
org.apache.struts2.interceptor.TokenSessionStoreInterceptor;
 import org.apache.struts2.interceptor.parameter.ParametersInterceptor;
 import org.apache.struts2.result.ServletDispatcherResult;
 import org.apache.struts2.components.ConstraintAction;
+import org.apache.struts2.components.ModelDrivenConstraintAction;
 import org.apache.struts2.interceptor.ModelDrivenInterceptor;
 import org.apache.struts2.views.jsp.ui.DoubleValidationAction;
 
@@ -103,7 +104,7 @@ public class TestConfigurationProvider implements 
ConfigurationProvider {
             .addInterceptor(new InterceptorMapping("validation", 
validationInterceptor))
             .build();
 
-        ActionConfig modelDrivenConstraintActionConfig = new 
ActionConfig.Builder("", "modelDrivenConstraintAction", 
ConstraintAction.class.getName())
+        ActionConfig modelDrivenConstraintActionConfig = new 
ActionConfig.Builder("", "modelDrivenConstraintAction", 
ModelDrivenConstraintAction.class.getName())
             .addResultConfig(new ResultConfig.Builder(Action.SUCCESS, 
ServletDispatcherResult.class.getName())
                     .addParam("location", "success.jsp")
                     .build())
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 b87c3a9a6..99a0b9690 100644
--- 
a/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java
+++ 
b/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java
@@ -136,6 +136,21 @@ public class ConstraintAttributesTest extends 
AbstractUITagTest {
             constraints == null || !constraints.containsKey("maxlength"));
     }
 
+    /**
+     * A {@code min} becomes the step base, so on a field with {@code 
step="7"} it would move the
+     * developer's grid and block values the server accepts.
+     */
+    public void testADeveloperStepSuppressesTheDerivedMinButKeepsTheMax() 
throws Exception {
+        initDispatcherWith("true");
+        fieldName = "startDate";
+        dynamicAttributes.put("STEP", "7");
+
+        Map<String, String> constraints = 
renderFieldAndReturnConstraints("date");
+
+        assertFalse(constraints.containsKey("min"));
+        assertEquals("2026-12-31", constraints.get("max"));
+    }
+
     /**
      * {@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.
@@ -445,6 +460,22 @@ public class ConstraintAttributesTest extends 
AbstractUITagTest {
         assertEquals(java.time.LocalDate.class, 
propertyTypeHandedToTheProvider());
     }
 
+    public void 
testThePropertyTypeIsReadFromTheSubmissionActionWhenAnotherActionRendersTheForm()
 throws Exception {
+        actionOverride = new RenderingAction();
+        initDispatcherWith("true");
+        fieldName = "birthday";
+
+        assertEquals(java.util.Date.class, propertyTypeHandedToTheProvider());
+    }
+
+    public void 
testAModelDrivenSubmissionActionRenderedByAnotherActionHandsTheProviderNoType() 
throws Exception {
+        actionName = "modelDrivenConstraintAction";
+        initDispatcherWith("true");
+        fieldName = "birthday";
+
+        assertNull(propertyTypeHandedToTheProvider());
+    }
+
     public void testAnIndexedFieldHandsTheProviderNoType() throws Exception {
         initDispatcherWith("true");
         fieldName = "user.address[0].street";
@@ -494,6 +525,19 @@ public class ConstraintAttributesTest extends 
AbstractUITagTest {
         }
     }
 
+    public static class RenderingAction extends 
org.apache.struts2.ActionSupport {
+
+        private String birthday;
+
+        public String getBirthday() {
+            return birthday;
+        }
+
+        public void setBirthday(String birthday) {
+            this.birthday = birthday;
+        }
+    }
+
     public static class TemporalModel extends ConstraintAction {
 
         private java.time.LocalDate startDate;
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 92a067aac..6e96bde73 100644
--- 
a/core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java
+++ 
b/core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java
@@ -37,6 +37,7 @@ import org.junit.Test;
 
 import java.math.BigDecimal;
 import java.sql.Timestamp;
+import java.text.SimpleDateFormat;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.time.LocalTime;
@@ -392,6 +393,32 @@ public class StrutsHtmlConstraintProviderTest {
         }
     }
 
+    @Test
+    public void aSubclassOverridingAddRangeStillHandlesTheDateControl() {
+        provider = new StrutsHtmlConstraintProvider() {
+            @Override
+            protected void addRange(Map<String, String> attributes, 
RangeValidatorSupport<?> validator,
+                                    HtmlControlType control) {
+                attributes.put("data-range", control.name());
+            }
+        };
+        LocalDateRangeFieldValidator validator = new 
LocalDateRangeFieldValidator();
+        validator.setMin(LocalDate.of(2026, 1, 1));
+
+        assertThat(constraints(validator, 
HtmlControlType.DATE)).containsOnly(Map.entry("data-range", "DATE"));
+    }
+
+    @Test
+    public void 
dateRangeOmitsABoundBeforeTheGregorianCutoverWhereTheConverterReadsJulianDates()
 throws Exception {
+        TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
+        SimpleDateFormat converterFormat = new SimpleDateFormat("yyyy-MM-dd");
+        DateRangeFieldValidator validator = new DateRangeFieldValidator();
+        validator.setMin(converterFormat.parse("1500-01-01"));
+        validator.setMax(converterFormat.parse("2026-12-31"));
+
+        assertThat(constraints(validator, 
HtmlControlType.DATE)).containsOnly(Map.entry("max", "2026-12-31"));
+    }
+
     @Test
     public void localDateRangeOmitsABoundOutsideTheYearsHtmlCanWrite() {
         LocalDateRangeFieldValidator validator = new 
LocalDateRangeFieldValidator();

Reply via email to