Copilot commented on code in PR #1865:
URL: https://github.com/apache/struts/pull/1865#discussion_r3850064775


##########
core/src/main/java/org/apache/struts2/components/UIBean.java:
##########
@@ -903,6 +917,69 @@ public void evaluateParams() {
         }
 
         evaluateExtraParams();
+
+        // must run after evaluateExtraParams(): that is where TextField 
resolves attributes.type,
+        // and the control type decides which constraints are legal
+        addConstraintAttributes(form);
+    }
+
+    /**
+     * Derives HTML5 constraint attributes for this field from the action's 
validators.
+     * <p>
+     * This reaches {@link Form#getFieldValidators(String)}, which resolves 
the action's validators via
+     * {@code AnnotationActionValidatorManager}, which in turn dereferences 
the current
+     * {@code ActionInvocation} unconditionally. Before this feature that path 
only ran under the opt-in
+     * {@code validate="true"}; with constraint derivation gated only by
+     * {@code struts.ui.html5.constraints}, every {@code html5}-themed form 
now runs it, including one
+     * rendered outside action scope (a direct JSP include from a plain 
servlet, say) — which would NPE.
+     * A stray {@code null} in the validator list, and a broken {@code ${}} in 
a validator message
+     * unbalancing the value stack in {@code ValidatorSupport.getMessage}, 
land in the same call. This
+     * feature is purely decorative — a missing constraint attribute costs 
nothing, a 500 costs the page —
+     * so the broad catch here is deliberate rather than a mistake.
+     *
+     * @since 7.4.0
+     */
+    protected void addConstraintAttributes(Form form) {
+        if (!html5ConstraintsEnabled || form == null || htmlConstraintProvider 
== null) {
+            return;
+        }
+        String fieldName = (String) getAttributes().get("name");
+        if (fieldName == null) {
+            return;
+        }
+        try {
+            Map<String, String> constraints = 
htmlConstraintProvider.constraintsFor(
+                form.getFieldValidators(fieldName), getControlType(), 
stack.peek());

Review Comment:
   `stack.peek()` is not necessarily the action: `ModelDrivenInterceptor` 
pushes the model on top of the ValueStack. Model-driven forms therefore resolve 
validator messages and i18n bundles against the model rather than the action 
used by server-side validation. Obtain the action from the current 
`ActionInvocation`, passing null when no invocation exists.



##########
core/src/main/java/org/apache/struts2/components/UIBean.java:
##########
@@ -903,6 +917,69 @@ public void evaluateParams() {
         }
 
         evaluateExtraParams();
+
+        // must run after evaluateExtraParams(): that is where TextField 
resolves attributes.type,
+        // and the control type decides which constraints are legal
+        addConstraintAttributes(form);
+    }
+
+    /**
+     * Derives HTML5 constraint attributes for this field from the action's 
validators.
+     * <p>
+     * This reaches {@link Form#getFieldValidators(String)}, which resolves 
the action's validators via
+     * {@code AnnotationActionValidatorManager}, which in turn dereferences 
the current
+     * {@code ActionInvocation} unconditionally. Before this feature that path 
only ran under the opt-in
+     * {@code validate="true"}; with constraint derivation gated only by
+     * {@code struts.ui.html5.constraints}, every {@code html5}-themed form 
now runs it, including one
+     * rendered outside action scope (a direct JSP include from a plain 
servlet, say) — which would NPE.
+     * A stray {@code null} in the validator list, and a broken {@code ${}} in 
a validator message
+     * unbalancing the value stack in {@code ValidatorSupport.getMessage}, 
land in the same call. This
+     * feature is purely decorative — a missing constraint attribute costs 
nothing, a 500 costs the page —
+     * so the broad catch here is deliberate rather than a mistake.
+     *
+     * @since 7.4.0
+     */
+    protected void addConstraintAttributes(Form form) {
+        if (!html5ConstraintsEnabled || form == null || htmlConstraintProvider 
== null) {
+            return;
+        }
+        String fieldName = (String) getAttributes().get("name");
+        if (fieldName == null) {
+            return;
+        }
+        try {
+            Map<String, String> constraints = 
htmlConstraintProvider.constraintsFor(
+                form.getFieldValidators(fieldName), getControlType(), 
stack.peek());
+            if (constraints.isEmpty()) {
+                return;
+            }
+            constraints = new LinkedHashMap<>(constraints);
+            constraints.keySet().removeIf(this::isAlreadyRendered);
+            if (!constraints.isEmpty()) {
+                addParameter("constraints", constraints);
+            }
+        } catch (Exception e) {
+            LOG.warn("Failed to derive HTML5 constraint attributes for field 
[{}], skipping", fieldName, e);
+        }

Review Comment:
   Catching the exception does not make this path fail-safe when 
`ValidatorSupport.getMessage` throws after pushing the action/validator: its 
pops are not in a `finally`, so subsequent rendering continues with a corrupted 
ValueStack. Capture the stack depth before derivation and restore it in a 
`finally`, or make `ValidatorSupport` guarantee balanced cleanup.



##########
core/src/main/java/org/apache/struts2/components/EcmaScriptSafeRegex.java:
##########
@@ -0,0 +1,116 @@
+/*
+ * 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.struts2.components;
+
+/**
+ * Decides whether a Java regular expression can be handed to a browser as an 
HTML5 {@code pattern}
+ * attribute without changing meaning.
+ * <p>
+ * This is an allowlist by design. A denylist of Java-only constructs would 
violate the
+ * never-false-reject rule the first time it missed one, because a missed 
construct becomes a pattern
+ * the browser interprets differently and the user cannot get past. Anything 
not provably common to
+ * both engines is rejected, and the field simply gets no client-side check.
+ *
+ * @since 7.4.0
+ */
+public final class EcmaScriptSafeRegex {
+
+    /**
+     * Escapes with identical meaning in both engines.
+     * <p>
+     * {@code \s} and {@code \S} are deliberately absent. Java's {@code \s} is 
ASCII-only by default
+     * while ECMAScript's is the wider Unicode set, so {@code ^\S+$} accepts a 
value containing NBSP
+     * on the server and rejects it in the browser. {@code \d} and {@code \w} 
are safe — both engines
+     * are ASCII-only for those, and JavaScript never widens them.
+     */
+    private static final String ALLOWED_ESCAPES = 
"dDwWbBnrtf\\.*+?()[]{}|^$/-";

Review Comment:
   Java 17 treats `\b`/`\B` boundaries as Unicode-aware, while the browser's 
ECMAScript boundary is based on ASCII word characters. For example, Java 17 
accepts `^\bäiti\b$` for `äiti`, but the emitted HTML pattern rejects it, 
violating the never-false-reject rule. Remove both escapes from the safe subset 
(JDK 19 changed Java's behavior, but Struts still targets Java 17).



##########
core/src/main/java/org/apache/struts2/components/HtmlConstraintProvider.java:
##########
@@ -0,0 +1,44 @@
+/*
+ * 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.struts2.components;
+
+import org.apache.struts2.validator.Validator;
+
+import java.util.List;
+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.

Review Comment:
   The advertised `type="email"` replacement cannot work with the current 
rendering path. `html5/text.ftl` emits `type="text"` before `constraints.ftl` 
renders the provider map, producing a duplicate `type`; HTML keeps the first 
value. Either restrict this contract to post-rendered constraint attributes or 
merge provider overrides into the normal attribute model before the template 
emits `type`.



##########
core/src/main/java/org/apache/struts2/components/UIBean.java:
##########
@@ -903,6 +917,69 @@ public void evaluateParams() {
         }
 
         evaluateExtraParams();
+
+        // must run after evaluateExtraParams(): that is where TextField 
resolves attributes.type,
+        // and the control type decides which constraints are legal
+        addConstraintAttributes(form);
+    }
+
+    /**
+     * Derives HTML5 constraint attributes for this field from the action's 
validators.
+     * <p>
+     * This reaches {@link Form#getFieldValidators(String)}, which resolves 
the action's validators via
+     * {@code AnnotationActionValidatorManager}, which in turn dereferences 
the current
+     * {@code ActionInvocation} unconditionally. Before this feature that path 
only ran under the opt-in
+     * {@code validate="true"}; with constraint derivation gated only by
+     * {@code struts.ui.html5.constraints}, every {@code html5}-themed form 
now runs it, including one
+     * rendered outside action scope (a direct JSP include from a plain 
servlet, say) — which would NPE.
+     * A stray {@code null} in the validator list, and a broken {@code ${}} in 
a validator message
+     * unbalancing the value stack in {@code ValidatorSupport.getMessage}, 
land in the same call. This
+     * feature is purely decorative — a missing constraint attribute costs 
nothing, a 500 costs the page —
+     * so the broad catch here is deliberate rather than a mistake.
+     *
+     * @since 7.4.0
+     */
+    protected void addConstraintAttributes(Form form) {
+        if (!html5ConstraintsEnabled || form == null || htmlConstraintProvider 
== null) {
+            return;
+        }
+        String fieldName = (String) getAttributes().get("name");
+        if (fieldName == null) {
+            return;
+        }
+        try {
+            Map<String, String> constraints = 
htmlConstraintProvider.constraintsFor(
+                form.getFieldValidators(fieldName), getControlType(), 
stack.peek());
+            if (constraints.isEmpty()) {
+                return;
+            }
+            constraints = new LinkedHashMap<>(constraints);
+            constraints.keySet().removeIf(this::isAlreadyRendered);
+            if (!constraints.isEmpty()) {
+                addParameter("constraints", constraints);
+            }
+        } catch (Exception e) {
+            LOG.warn("Failed to derive HTML5 constraint attributes for field 
[{}], skipping", fieldName, e);
+        }
+    }
+
+    /**
+     * True when the developer already supplied this attribute explicitly — as 
a declared tag attribute
+     * (e.g. {@code maxlength}) or a dynamic one (e.g. {@code min} on a 
numeric textfield, which is not a
+     * declared attribute of any component) — so a derived constraint of the 
same name must not be
+     * rendered a second time. The developer's own value always wins.
+     * <p>
+     * {@code required} is deliberately excluded from the declared-attribute 
half of this check:
+     * {@code requiredLabel} stores an unrelated boolean under the same {@code 
attributes.required} key,
+     * purely to draw a label asterisk in the xhtml theme, and that must never 
suppress a genuine
+     * {@code required} constraint derived from a {@code required}/{@code 
requiredstring} validator. A
+     * {@code required} attribute the developer typed by hand as a dynamic 
attribute still wins.
+     */
+    private boolean isAlreadyRendered(String attributeName) {
+        if (dynamicAttributes.containsKey(attributeName)) {
+            return true;
+        }
+        return !"required".equals(attributeName) && 
getAttributes().containsKey(attributeName);
     }

Review Comment:
   HTML attribute names are ASCII case-insensitive, but this exact-key check 
misses dynamic attributes such as `MAXLENGTH` or `Min`. Because derived 
attributes render before dynamic ones, the browser keeps the derived duplicate 
and the developer's value does not win as documented. Compare dynamic keys 
case-insensitively.



##########
core/src/main/java/org/apache/struts2/components/HtmlControlType.java:
##########
@@ -0,0 +1,78 @@
+/*
+ * 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.struts2.components;
+
+import java.util.EnumSet;
+import java.util.Locale;
+import java.util.Set;
+
+/**
+ * The kind of HTML form control a {@link UIBean} renders, used to decide 
which HTML5 constraint
+ * attributes are legal on it.
+ * <p>
+ * This models the <em>control</em> rather than the {@code type} attribute, 
because {@code textarea}
+ * and {@code select} have no {@code type} attribute yet still accept {@code 
required}.
+ *
+ * @since 7.4.0
+ */
+public enum HtmlControlType {
+
+    TEXT, SEARCH, TEL, PASSWORD, EMAIL, URL,
+    NUMBER, RANGE,
+    DATE, MONTH, WEEK, TIME, DATETIME_LOCAL,
+    CHECKBOX, RADIO, FILE, HIDDEN, SELECT,

Review Comment:
   `CHECKBOX` and `HIDDEN` are public control kinds, but the built-in 
`Checkbox` and `Hidden` components inherit `OTHER`; only the six new overrides 
ever expose concrete types. A replacement provider therefore cannot distinguish 
these controls, undermining the swappable mapping policy. Return their actual 
control types and keep the conservative behavior in 
`StrutsHtmlConstraintProvider`.



##########
core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java:
##########
@@ -0,0 +1,208 @@
+/*
+ * 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.struts2.components;
+
+import org.apache.struts2.validator.Validator;
+import org.apache.struts2.validator.validators.CreditCardValidator;
+import org.apache.struts2.validator.validators.DoubleRangeFieldValidator;
+import org.apache.struts2.validator.validators.EmailValidator;
+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;
+import org.apache.struts2.validator.validators.StringLengthFieldValidator;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Default {@link HtmlConstraintProvider}.
+ * <p>
+ * Governed by one rule: never false-reject. A constraint is emitted only when 
the browser cannot
+ * reject input the server would accept. In particular this implementation 
<em>never sets or changes
+ * an input's {@code type}</em> — switching a field to {@code type="number"} 
would reject
+ * {@code 1234,50}, which the framework's locale-aware conversion accepts in a 
comma-decimal locale,
+ * and the browsers' {@code email}/{@code url} grammars differ from the 
framework's validators.
+ * Range constraints are therefore emitted only on a control the developer 
already made numeric.
+ *
+ * @since 7.4.0
+ */
+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";
+
+    @Override
+    public Map<String, String> constraintsFor(List<Validator> validators, 
HtmlControlType control, Object action) {
+        Map<String, String> attributes = new LinkedHashMap<>();
+        if (validators == null || validators.isEmpty() || control == null) {
+            return attributes;
+        }
+        for (Validator validator : validators) {
+            addConstraints(attributes, validator, control);
+            addMessage(attributes, validator, action);
+        }
+        return attributes;
+    }
+
+    protected void addConstraints(Map<String, String> attributes, Validator 
validator, HtmlControlType control) {
+        if (validator instanceof RequiredStringValidator) {
+            addRequiredString(attributes, control);
+        } else if (validator instanceof RequiredFieldValidator) {
+            addRequiredField(attributes, control);
+        } else if (validator instanceof StringLengthFieldValidator 
lengthValidator) {
+            addLength(attributes, lengthValidator, control);
+        } else if (validator instanceof RegexFieldValidator regexValidator) {
+            addPattern(attributes, regexValidator, control);
+        } else if (validator instanceof DoubleRangeFieldValidator 
doubleValidator) {
+            addDoubleRange(attributes, doubleValidator, control);
+        } else if (validator instanceof RangeValidatorSupport<?> 
rangeValidator) {
+            addRange(attributes, rangeValidator, control);
+        }
+    }
+
+    /**
+     * {@code requiredstring} fails on null, empty and (by default) blank, so 
the browser's
+     * {@code required} can only reject what the server would also reject. 
Safe on any text-entry control.
+     */
+    protected void addRequiredString(Map<String, String> attributes, 
HtmlControlType control) {
+        if (!control.supportsLength()) {
+            return;
+        }
+        attributes.put(REQUIRED, REQUIRED);
+    }
+
+    /**
+     * {@code required} fails only on null, an empty array or an empty 
collection. A control that submits
+     * an empty string rather than omitting the parameter therefore passes 
server-side while the browser
+     * blocks it — an empty text input, a select with an empty-valued header 
option, and an unticked
+     * checkbox (CheckboxInterceptor substitutes "false") are all in that 
group. Only RADIO and FILE omit
+     * the parameter entirely when empty, so only they agree with the browser.
+     */
+    protected void addRequiredField(Map<String, String> attributes, 
HtmlControlType control) {
+        if (control != HtmlControlType.RADIO && control != 
HtmlControlType.FILE) {
+            return;
+        }
+        attributes.put(REQUIRED, REQUIRED);
+    }
+
+    protected void addLength(Map<String, String> attributes, 
StringLengthFieldValidator validator, HtmlControlType control) {
+        // with trim=true the server measures the trimmed value, so a 
maxlength taken from it would
+        // stop the user typing input the server would have accepted
+        if (!control.supportsLength() || validator.isTrim()) {
+            return;
+        }
+        if (validator.getMinLength() > -1) {
+            attributes.put("minlength", 
String.valueOf(validator.getMinLength()));
+        }
+        if (validator.getMaxLength() > -1) {
+            attributes.put("maxlength", 
String.valueOf(validator.getMaxLength()));
+        }
+    }
+
+    protected void addPattern(Map<String, String> attributes, 
RegexFieldValidator validator, HtmlControlType control) {
+        // HTML pattern accepts no flags, so a case-insensitive rule cannot be 
expressed at all
+        if (!control.supportsPattern() || !validator.isCaseSensitive()) {
+            return;
+        }
+        // trim defaults to true, and the server matches the trimmed value 
while pattern matches the
+        // raw one: "[a-z]+" would accept "abc " server-side and be blocked by 
the browser
+        if (validator.isTrimed()) {
+            return;
+        }
+        // Both extend RegexFieldValidator but do not match their regex 
against the raw value:
+        // CreditCardValidator strips all whitespace first, and both carry 
grammars the browser
+        // does not share. Neither is expressible as a pattern.
+        if (validator instanceof EmailValidator || validator instanceof 
CreditCardValidator) {
+            return;
+        }
+        String regex = validator.getRegex();
+        if (EcmaScriptSafeRegex.isSafe(regex)) {
+            attributes.put("pattern", regex);
+        }
+    }
+
+    protected void addRange(Map<String, String> attributes, 
RangeValidatorSupport<?> validator, HtmlControlType control) {
+        if (!isNumericRange(control)) {
+            // Temporal controls support ranges too, but min/max there need 
per-control ISO
+            // formatting (date -> yyyy-MM-dd, month -> yyyy-MM, week -> 
yyyy-'W'ww, time -> HH:mm).
+            // Deliberately deferred; DateRangeFieldValidator therefore emits 
nothing for now.
+            return;
+        }
+        // min is guarded by isIntegral; see the comment on that method. The 
shipped Integer/Short/Long
+        // range validators always pass it, but a custom 
RangeValidatorSupport<Double> would not.
+        Object min = validator.getMin();
+        if (isIntegral(min)) {
+            putIfPresent(attributes, "min", min);
+        }
+        putIfPresent(attributes, "max", validator.getMax());
+    }
+
+    protected void addDoubleRange(Map<String, String> attributes, 
DoubleRangeFieldValidator validator, HtmlControlType control) {
+        if (!isNumericRange(control)) {
+            // Temporal controls support ranges too, but min/max there need 
per-control ISO
+            // formatting (date -> yyyy-MM-dd, month -> yyyy-MM, week -> 
yyyy-'W'ww, time -> HH:mm).
+            // Deliberately deferred; DateRangeFieldValidator therefore emits 
nothing for now.
+            return;
+        }
+        // exclusive bounds have no HTML equivalent; omitting them leaves the 
browser more
+        // permissive than the server, which is the safe direction
+        Double minInclusive = validator.getMinInclusive();
+        if (isIntegral(minInclusive)) {
+            putIfPresent(attributes, "min", minInclusive);
+        }
+        putIfPresent(attributes, "max", validator.getMaxInclusive());
+    }
+
+    private boolean isNumericRange(HtmlControlType control) {
+        return control.supportsRange() && (control == HtmlControlType.NUMBER 
|| control == HtmlControlType.RANGE);
+    }
+
+    /**
+     * A fractional {@code min} moves the HTML step base off zero, and with 
the default {@code step="1"}
+     * the browser then rejects whole numbers the server accepts. {@code max} 
does not participate in the
+     * step base, so only {@code min} needs this guard.
+     */
+    private boolean isIntegral(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);
+    }

Review Comment:
   Converting arbitrary `Number` values to `double` can round a fractional 
custom bound to an integer. A `BigDecimal("1.0000000000000000001")` passes this 
check, then renders that fractional value as `min`, shifts the HTML step base, 
and can reject values accepted by the server. Determine integrality from the 
decimal representation instead.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to