diff --git a/core/src/main/java/org/apache/struts2/StrutsConstants.java b/core/src/main/java/org/apache/struts2/StrutsConstants.java index 9c12452489..ffc135f0b3 100644 --- a/core/src/main/java/org/apache/struts2/StrutsConstants.java +++ b/core/src/main/java/org/apache/struts2/StrutsConstants.java @@ -203,6 +203,14 @@ public final class StrutsConstants { */ public static final String STRUTS_UI_STATIC_CONTENT_PATH = "struts.ui.staticContentPath"; + /** + * Whether the html5 theme emits HTML5 constraint attributes derived from the action's validators. + * Defaults to {@code false}; the default is expected to flip in a future major release. + * + * @since 7.4.0 + */ + public static final String STRUTS_UI_HTML5_CONSTRAINTS = "struts.ui.html5.constraints"; + /** * Whether WebJars support is enabled (serving and URL building) */ @@ -218,6 +226,14 @@ public final class StrutsConstants { */ public static final String STRUTS_UI_ESCAPE_HTML_BODY = "struts.ui.escapeHtmlBody"; + /** + * The {@link org.apache.struts2.components.HtmlConstraintProvider} implementation used to derive + * HTML5 constraint attributes from an action's validators. + * + * @since 7.4.0 + */ + public static final String STRUTS_HTML_CONSTRAINT_PROVIDER = "struts.htmlConstraintProvider"; + /** * The maximum size of a multipart request (file upload) */ diff --git a/core/src/main/java/org/apache/struts2/components/EcmaScriptSafeRegex.java b/core/src/main/java/org/apache/struts2/components/EcmaScriptSafeRegex.java new file mode 100644 index 0000000000..f334143935 --- /dev/null +++ b/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. + *

+ * 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. + *

+ * {@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\\.*+?()[]{}|^$/-"; + + private EcmaScriptSafeRegex() { + } + + public static boolean isSafe(String regex) { + 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)) { + 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; + } + + /** + * 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. + */ + 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: + return true; + } + } + + private static boolean isAllowedEscape(String regex, int index) { + return index + 1 < regex.length() && ALLOWED_ESCAPES.indexOf(regex.charAt(index + 1)) >= 0; + } + + /** + * 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, '?')) { + return true; + } + if (index + 2 >= regex.length()) { + return false; + } + 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; + } +} diff --git a/core/src/main/java/org/apache/struts2/components/File.java b/core/src/main/java/org/apache/struts2/components/File.java index e9317afba4..58bfe42ce1 100644 --- a/core/src/main/java/org/apache/struts2/components/File.java +++ b/core/src/main/java/org/apache/struts2/components/File.java @@ -62,6 +62,11 @@ protected String getDefaultTemplate() { return TEMPLATE; } + @Override + protected HtmlControlType getControlType() { + return HtmlControlType.FILE; + } + public void evaluateParams() { super.evaluateParams(); 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 7db1477c32..cb7a4f0ab9 100644 --- a/core/src/main/java/org/apache/struts2/components/Form.java +++ b/core/src/main/java/org/apache/struts2/components/Form.java @@ -77,6 +77,9 @@ * * *

+ * The client-side JS validate attribute is deprecated since 7.4.0 — use the html5 theme's + * constraint attributes instead. Removed in 8.0.0. + *

* * *

Examples

@@ -98,6 +101,8 @@ public class Form extends ClosingUIBean { public static final String OPEN_TEMPLATE = "form"; public static final String TEMPLATE = "form-close"; + private static final String ATTR_ACTION_CLASS = "actionClass"; + private int sequence = 0; protected String onsubmit; @@ -119,6 +124,10 @@ public class Form extends ClosingUIBean { protected UrlRenderer urlRenderer; protected ActionValidatorManager actionValidatorManager; + private List cachedActionValidators; + private String cachedActionName; + private boolean actionValidatorsResolved; + public Form(ValueStack stack, HttpServletRequest request, HttpServletResponse response) { super(stack, request, response); } @@ -238,7 +247,12 @@ protected void populateComponentHtmlId(Form form) { * @param actionName the actioName to check for * @param namespace the namespace to check for * @param actionMethod the method to ckeck for + * @deprecated since 7.4.0, for removal in 8.0.0. The generated client-side validator only ever + * covered fields rendered by a nested Struts tag (WW-2975). Use the {@code html5} theme with + * {@code struts.ui.html5.constraints=true}, which derives native HTML5 constraint attributes + * per field instead. */ + @Deprecated(since = "7.4.0", forRemoval = true) protected void evaluateClientSideJsEnablement(String actionName, String namespace, String actionMethod) { // Only evaluate if Client-Side js is to be enable when validate=true @@ -268,8 +282,17 @@ protected void evaluateClientSideJsEnablement(String actionName, String namespac } } + /** + * Looks up the validators for a field, for the deprecated client-side JavaScript validator. + * + * @param name the field name to look up + * @return the validators applying to the field, never null + * @deprecated since 7.4.0, for removal in 8.0.0. Use {@link #getFieldValidators(String)}, which + * is generically typed and resolves the action's validators once per form rather than per field. + */ + @Deprecated(since = "7.4.0", forRemoval = true) public List getValidators(String name) { - Class actionClass = (Class) getAttributes().get("actionClass"); + Class actionClass = (Class) getAttributes().get(ATTR_ACTION_CLASS); if (actionClass == null) { return Collections.EMPTY_LIST; } @@ -300,6 +323,47 @@ public List getValidators(String name) { return validators; } + /** + * Returns the validators declared for a single field, resolving the action's validator list at + * most once per form render. + * + * @since 7.4.0 + */ + public List getFieldValidators(String name) { + resolveActionValidators(); + if (cachedActionValidators.isEmpty()) { + return Collections.emptyList(); + } + Class actionClass = (Class) getAttributes().get(ATTR_ACTION_CLASS); + List validators = new ArrayList<>(); + findFieldValidators(name, actionClass, cachedActionName, cachedActionValidators, validators, ""); + return validators; + } + + private void resolveActionValidators() { + if (actionValidatorsResolved) { + return; + } + actionValidatorsResolved = true; + cachedActionValidators = Collections.emptyList(); + + Class actionClass = (Class) getAttributes().get(ATTR_ACTION_CLASS); + if (actionClass == null) { + return; + } + ActionMapping mapping = actionMapper.getMappingFromActionName(findString(action)); + if (mapping == null) { + mapping = actionMapper.getMappingFromActionName((String) getAttributes().get("actionName")); + } + if (mapping == null) { + return; + } + cachedActionName = mapping.getName(); + String methodName = isValidateAnnotatedMethodOnly(cachedActionName) ? mapping.getMethod() : null; + cachedActionValidators = + actionValidatorManager.getValidators(actionClass, cachedActionName, methodName); + } + private boolean isValidateAnnotatedMethodOnly(String actionName) { RuntimeConfiguration runtimeConfiguration = configuration.getRuntimeConfiguration(); String actionNamespace = getNamespace(stack); @@ -507,8 +571,14 @@ public void setNamespace(String namespace) { this.namespace = namespace; } + /** + * @deprecated since 7.4.0, for removal in 8.0.0. The generated client-side validator only ever + * covered fields rendered by a nested Struts tag (WW-2975). Use the {@code html5} theme with + * {@code struts.ui.html5.constraints=true} instead. + */ @StrutsTagAttribute(description = "Whether client side/remote validation should be performed. Only" + " useful with theme xhtml/ajax", type = "Boolean", defaultValue = "false") + @Deprecated(since = "7.4.0", forRemoval = true) public void setValidate(String validate) { this.validate = validate; } diff --git a/core/src/main/java/org/apache/struts2/components/HtmlConstraintProvider.java b/core/src/main/java/org/apache/struts2/components/HtmlConstraintProvider.java new file mode 100644 index 0000000000..6fdeaa52be --- /dev/null +++ b/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. + *

+ * 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. + * + * @since 7.4.0 + */ +public interface HtmlConstraintProvider { + + /** + * @param validators the field's validators; may be null or empty + * @param control the kind of control being rendered + * @param action the action instance, used to resolve i18n validator messages; may be null + * @return attribute name to value; never null, possibly empty + */ + Map constraintsFor(List validators, HtmlControlType control, Object action); +} diff --git a/core/src/main/java/org/apache/struts2/components/HtmlControlType.java b/core/src/main/java/org/apache/struts2/components/HtmlControlType.java new file mode 100644 index 0000000000..6e69617b98 --- /dev/null +++ b/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. + *

+ * This models the control 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, + TEXTAREA, + OTHER; + + private static final Set TEXT_ENTRY = EnumSet.of(TEXT, SEARCH, TEL, PASSWORD, EMAIL, URL); + private static final Set NUMERIC = EnumSet.of(NUMBER, RANGE); + private static final Set TEMPORAL = EnumSet.of(DATE, MONTH, WEEK, TIME, DATETIME_LOCAL); + + /** + * 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 + * constraints at all — so an unknown control degrades to emitting nothing. + */ + public static HtmlControlType from(String type) { + if (type == null) { + return OTHER; + } + String normalised = type.trim().toUpperCase(Locale.ROOT).replace('-', '_'); + if (normalised.isEmpty()) { + return OTHER; + } + try { + return valueOf(normalised); + } catch (IllegalArgumentException e) { + return OTHER; + } + } + + public boolean supportsPattern() { + return TEXT_ENTRY.contains(this); + } + + public boolean supportsLength() { + return TEXT_ENTRY.contains(this) || this == TEXTAREA; + } + + public boolean supportsRange() { + return NUMERIC.contains(this) || TEMPORAL.contains(this); + } +} diff --git a/core/src/main/java/org/apache/struts2/components/Password.java b/core/src/main/java/org/apache/struts2/components/Password.java index 471f7dbd24..4bd75c1a18 100644 --- a/core/src/main/java/org/apache/struts2/components/Password.java +++ b/core/src/main/java/org/apache/struts2/components/Password.java @@ -64,6 +64,11 @@ protected String getDefaultTemplate() { return TEMPLATE; } + @Override + protected HtmlControlType getControlType() { + return HtmlControlType.PASSWORD; + } + public void evaluateExtraParams() { super.evaluateExtraParams(); diff --git a/core/src/main/java/org/apache/struts2/components/Radio.java b/core/src/main/java/org/apache/struts2/components/Radio.java index d0d3eb1465..bc6bf76069 100644 --- a/core/src/main/java/org/apache/struts2/components/Radio.java +++ b/core/src/main/java/org/apache/struts2/components/Radio.java @@ -74,4 +74,9 @@ protected boolean lazyEvaluation() { return true; } + @Override + protected HtmlControlType getControlType() { + return HtmlControlType.RADIO; + } + } diff --git a/core/src/main/java/org/apache/struts2/components/Select.java b/core/src/main/java/org/apache/struts2/components/Select.java index f1a8e5b6df..237775fce8 100644 --- a/core/src/main/java/org/apache/struts2/components/Select.java +++ b/core/src/main/java/org/apache/struts2/components/Select.java @@ -97,6 +97,11 @@ protected String getDefaultTemplate() { return TEMPLATE; } + @Override + protected HtmlControlType getControlType() { + return HtmlControlType.SELECT; + } + public void evaluateExtraParams() { super.evaluateExtraParams(); diff --git a/core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java b/core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java new file mode 100644 index 0000000000..79790aabb1 --- /dev/null +++ b/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}. + *

+ * 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 never sets or changes + * an input's {@code type} — 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 constraintsFor(List validators, HtmlControlType control, Object action) { + Map 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 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 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 attributes, HtmlControlType control) { + if (control != HtmlControlType.RADIO && control != HtmlControlType.FILE) { + return; + } + attributes.put(REQUIRED, REQUIRED); + } + + protected void addLength(Map 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 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 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 would not. + Object min = validator.getMin(); + if (isIntegral(min)) { + putIfPresent(attributes, "min", min); + } + putIfPresent(attributes, "max", validator.getMax()); + } + + protected void addDoubleRange(Map 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); + } + + protected void addMessage(Map attributes, Validator validator, Object action) { + if (action == null) { + return; + } + String message = validator.getMessage(action); + if (message != null && !message.isEmpty()) { + attributes.put("data-msg-" + validator.getValidatorType(), message); + } + } + + private void putIfPresent(Map attributes, String name, Object value) { + if (value != null) { + attributes.put(name, String.valueOf(value)); + } + } +} diff --git a/core/src/main/java/org/apache/struts2/components/TextArea.java b/core/src/main/java/org/apache/struts2/components/TextArea.java index 7f3babf561..41856b2709 100644 --- a/core/src/main/java/org/apache/struts2/components/TextArea.java +++ b/core/src/main/java/org/apache/struts2/components/TextArea.java @@ -62,6 +62,11 @@ protected String getDefaultTemplate() { return TEMPLATE; } + @Override + protected HtmlControlType getControlType() { + return HtmlControlType.TEXTAREA; + } + public void evaluateExtraParams() { super.evaluateExtraParams(); diff --git a/core/src/main/java/org/apache/struts2/components/TextField.java b/core/src/main/java/org/apache/struts2/components/TextField.java index 726c4fc5b3..e72772deed 100644 --- a/core/src/main/java/org/apache/struts2/components/TextField.java +++ b/core/src/main/java/org/apache/struts2/components/TextField.java @@ -72,6 +72,12 @@ protected String getDefaultTemplate() { return TEMPLATE; } + @Override + protected HtmlControlType getControlType() { + Object resolvedType = getAttributes().get("type"); + return resolvedType == null ? HtmlControlType.TEXT : HtmlControlType.from(String.valueOf(resolvedType)); + } + protected void evaluateExtraParams() { super.evaluateExtraParams(); 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 adac94dbaa..6e73392e8b 100644 --- a/core/src/main/java/org/apache/struts2/components/UIBean.java +++ b/core/src/main/java/org/apache/struts2/components/UIBean.java @@ -25,6 +25,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; +import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.ObjectUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -531,6 +532,9 @@ public UIBean(ValueStack stack, HttpServletRequest request, HttpServletResponse protected CspNonceReader cspNonceReader; + protected HtmlConstraintProvider htmlConstraintProvider; + protected boolean html5ConstraintsEnabled; + @Inject(StrutsConstants.STRUTS_UI_TEMPLATEDIR) public void setDefaultTemplateDir(String dir) { this.defaultTemplateDir = dir; @@ -561,6 +565,16 @@ public void setCspNonceReader(CspNonceReader cspNonceReader) { this.cspNonceReader = cspNonceReader; } + @Inject + public void setHtmlConstraintProvider(HtmlConstraintProvider htmlConstraintProvider) { + this.htmlConstraintProvider = htmlConstraintProvider; + } + + @Inject(value = StrutsConstants.STRUTS_UI_HTML5_CONSTRAINTS, required = false) + public void setHtml5ConstraintsEnabled(String html5ConstraintsEnabled) { + this.html5ConstraintsEnabled = BooleanUtils.toBoolean(html5ConstraintsEnabled); + } + @Override public boolean end(Writer writer, String body) { evaluateParams(); @@ -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. + *

+ * 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 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. + *

+ * {@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); } /** @@ -968,6 +1045,17 @@ protected String ensureAttributeSafelyNotEscaped(String val) { } } + /** + * 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 + * constraints — so a component that does not override this emits none. + * + * @since 7.4.0 + */ + protected HtmlControlType getControlType() { + return HtmlControlType.OTHER; + } + protected void evaluateExtraParams() { } diff --git a/core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java b/core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java index f37cb47520..b0dda639b2 100644 --- a/core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java +++ b/core/src/main/java/org/apache/struts2/config/StrutsBeanSelectionProvider.java @@ -29,6 +29,7 @@ import org.apache.struts2.text.TextProvider; import org.apache.struts2.text.TextProviderFactory; import org.apache.struts2.UnknownHandlerManager; +import org.apache.struts2.components.HtmlConstraintProvider; import org.apache.struts2.components.UrlRenderer; import org.apache.struts2.components.date.DateFormatter; import org.apache.struts2.conversion.ConversionAnnotationProcessor; @@ -424,6 +425,7 @@ public void register(ContainerBuilder builder, LocatableProperties props) { alias(MultiPartRequest.class, StrutsConstants.STRUTS_MULTIPART_PARSER, builder, props, Scope.PROTOTYPE); alias(FreemarkerManager.class, StrutsConstants.STRUTS_FREEMARKER_MANAGER_CLASSNAME, builder, props); alias(UrlRenderer.class, StrutsConstants.STRUTS_URL_RENDERER, builder, props); + alias(HtmlConstraintProvider.class, StrutsConstants.STRUTS_HTML_CONSTRAINT_PROVIDER, builder, props); alias(ActionValidatorManager.class, StrutsConstants.STRUTS_ACTIONVALIDATORMANAGER, builder, props); alias(ValueStackFactory.class, StrutsConstants.STRUTS_VALUESTACKFACTORY, builder, props); alias(ReflectionProvider.class, StrutsConstants.STRUTS_REFLECTIONPROVIDER, builder, props); diff --git a/core/src/main/java/org/apache/struts2/views/jsp/ui/FormTag.java b/core/src/main/java/org/apache/struts2/views/jsp/ui/FormTag.java index 3bdb483798..b646702477 100644 --- a/core/src/main/java/org/apache/struts2/views/jsp/ui/FormTag.java +++ b/core/src/main/java/org/apache/struts2/views/jsp/ui/FormTag.java @@ -54,6 +54,7 @@ public Component getBean(ValueStack stack, HttpServletRequest req, HttpServletRe } @Override + @SuppressWarnings("removal") // must keep forwarding `validate` until it is removed in 8.0.0 protected void populateParams() { super.populateParams(); Form form = ((Form) component); @@ -93,6 +94,12 @@ public void setNamespace(String namespace) { this.namespace = namespace; } + /** + * @deprecated since 7.4.0, for removal in 8.0.0. The generated client-side validator only ever + * covered fields rendered by a nested Struts tag (WW-2975). Use the {@code html5} theme with + * {@code struts.ui.html5.constraints=true} instead. + */ + @Deprecated(since = "7.4.0", forRemoval = true) public void setValidate(String validate) { this.validate = validate; } diff --git a/core/src/main/resources/org/apache/struts2/default.properties b/core/src/main/resources/org/apache/struts2/default.properties index a14c84bcc3..a35001534e 100644 --- a/core/src/main/resources/org/apache/struts2/default.properties +++ b/core/src/main/resources/org/apache/struts2/default.properties @@ -171,11 +171,20 @@ struts.ui.theme.expansion.token=~~~ ### Sets the default template type. Either ftl, vm, or jsp struts.ui.templateSuffix=ftl +### Whether the html5 theme emits HTML5 constraint attributes (required, minlength, +### maxlength, pattern, min, max) derived from the action's validators. +### Defaults to false so existing html5-theme forms render unchanged; the default is +### expected to flip in a future major release. +struts.ui.html5.constraints=false + ### Sets a global flag which will escape html body of Anchor, Submit and Component tag ### You can control this flag per tag, e.g.: ... ### and this take precedence over the global flag # struts.ui.escapeHtmlBody=true +### The HtmlConstraintProvider implementation used to derive HTML5 constraint attributes +struts.htmlConstraintProvider=struts + ### Configuration reloading ### This will cause the configuration to reload struts.xml when it is changed # struts.configuration.xml.reload=false diff --git a/core/src/main/resources/struts-beans.xml b/core/src/main/resources/struts-beans.xml index 84f0919dcd..8ad4dff8be 100644 --- a/core/src/main/resources/struts-beans.xml +++ b/core/src/main/resources/struts-beans.xml @@ -145,6 +145,8 @@ + diff --git a/core/src/main/resources/template/html5/common-attributes.ftl b/core/src/main/resources/template/html5/common-attributes.ftl index 424316dad6..d8730c23a4 100644 --- a/core/src/main/resources/template/html5/common-attributes.ftl +++ b/core/src/main/resources/template/html5/common-attributes.ftl @@ -21,3 +21,4 @@ <#if attributes.accesskey?has_content> accesskey="${attributes.accesskey}"<#rt/> +<#include "/${attributes.templateDir}/${attributes.expandTheme}/constraints.ftl" /><#rt/> diff --git a/core/src/main/resources/template/html5/constraints.ftl b/core/src/main/resources/template/html5/constraints.ftl new file mode 100644 index 0000000000..ad86c25b0f --- /dev/null +++ b/core/src/main/resources/template/html5/constraints.ftl @@ -0,0 +1,25 @@ +<#-- +/* + * 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. + */ +--> +<#if attributes.constraints??> +<#list attributes.constraints as attributeName, attributeValue> + ${attributeName}="${attributeValue}"<#rt/> + + diff --git a/core/src/main/resources/template/xhtml/form-close-validate.ftl b/core/src/main/resources/template/xhtml/form-close-validate.ftl index 8e86615683..a815105a19 100644 --- a/core/src/main/resources/template/xhtml/form-close-validate.ftl +++ b/core/src/main/resources/template/xhtml/form-close-validate.ftl @@ -19,6 +19,13 @@ */ --> <#-- +DEPRECATED since Struts 7.4.0, removed in 8.0.0 (WW-5694 / WW-5696). + +JavaScript client-side validation is superseded by native HTML5 constraint +attributes in the html5 theme (WW-5695). This template, form-validate.ftl and +validation.js are all removed in 8.0.0. +--> +<#-- START SNIPPET: supported-validators Only the following validators are supported: * required validator diff --git a/core/src/test/java/org/apache/struts2/TestConfigurationProvider.java b/core/src/test/java/org/apache/struts2/TestConfigurationProvider.java index 34d1ecd5b6..c6fabf7427 100644 --- a/core/src/test/java/org/apache/struts2/TestConfigurationProvider.java +++ b/core/src/test/java/org/apache/struts2/TestConfigurationProvider.java @@ -38,6 +38,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.views.jsp.ui.DoubleValidationAction; import java.util.HashMap; @@ -94,6 +95,13 @@ public void loadPackages() { .addInterceptor(new InterceptorMapping("validation", validationInterceptor)) .build(); + ActionConfig constraintActionConfig = new ActionConfig.Builder("", "constraintAction", ConstraintAction.class.getName()) + .addResultConfig(new ResultConfig.Builder(Action.SUCCESS, ServletDispatcherResult.class.getName()) + .addParam("location", "success.jsp") + .build()) + .addInterceptor(new InterceptorMapping("validation", validationInterceptor)) + .build(); + ActionConfig testActionConfig = new ActionConfig.Builder("", "", TestAction.class.getName()) .addResultConfig(new ResultConfig.Builder(Action.SUCCESS, ServletDispatcherResult.class.getName()) .addParam("location", "success.jsp") @@ -119,6 +127,7 @@ public void loadPackages() { .addActionConfig(EXECUTION_COUNT_ACTION_NAME, executionCountActionConfig) .addActionConfig(TEST_ACTION_NAME, testActionConfig) .addActionConfig("doubleValidationAction", doubleValidationActionConfig) + .addActionConfig("constraintAction", constraintActionConfig) .addActionConfig(TOKEN_ACTION_NAME, tokenActionConfig) .addActionConfig(TOKEN_SESSION_ACTION_NAME, tokenSessionActionConfig) .addActionConfig("testActionTagAction", new ActionConfig.Builder("", "", TestAction.class.getName()) diff --git a/core/src/test/java/org/apache/struts2/components/ConstraintAction.java b/core/src/test/java/org/apache/struts2/components/ConstraintAction.java new file mode 100644 index 0000000000..2a1f9b4f32 --- /dev/null +++ b/core/src/test/java/org/apache/struts2/components/ConstraintAction.java @@ -0,0 +1,56 @@ +/* + * 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.ActionSupport; +import org.apache.struts2.interceptor.parameter.StrutsParameter; + +public class ConstraintAction extends ActionSupport { + + private String username; + private String comment; + private String bio; + + public String getUsername() { + return username; + } + + @StrutsParameter + public void setUsername(String username) { + this.username = username; + } + + public String getComment() { + return comment; + } + + @StrutsParameter + public void setComment(String comment) { + this.comment = comment; + } + + public String getBio() { + return bio; + } + + @StrutsParameter + public void setBio(String bio) { + this.bio = bio; + } +} diff --git a/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java b/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java new file mode 100644 index 0000000000..319c001236 --- /dev/null +++ b/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java @@ -0,0 +1,101 @@ +/* + * 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.StrutsConstants; +import org.apache.struts2.TestConfigurationProvider; +import org.apache.struts2.mock.MockActionProxy; +import org.apache.struts2.views.jsp.AbstractUITagTest; +import org.apache.struts2.views.jsp.ui.FormTag; +import org.apache.struts2.views.jsp.ui.TextFieldTag; + +import java.util.HashMap; +import java.util.Map; + +public class ConstraintAttributesTest extends AbstractUITagTest { + + public void testNoConstraintsWhenTheConstantIsOff() throws Exception { + initDispatcherWith("false"); + + assertNull(renderFieldAndReturnConstraints(null)); + } + + public void testConstraintsWhenTheConstantIsOn() throws Exception { + initDispatcherWith("true"); + + Map constraints = renderFieldAndReturnConstraints(null); + assertNotNull("expected constraints to be populated", constraints); + assertEquals("3", constraints.get("minlength")); + } + + /** + * 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 + * is not legal there. This can only resolve correctly if the control type ({@code type="number"}, + * resolved by {@code TextField.evaluateExtraParams()}) is already known when the constraint hook + * fires. Untyped text fields resolve to {@code TEXT} either way, so + * {@link #testConstraintsWhenTheConstantIsOn()} alone cannot distinguish a correctly-placed hook + * from one hoisted up to the {@code tagNames} block. + */ + public void testConstraintsRespectAnExplicitInputType() throws Exception { + initDispatcherWith("true"); + + Map constraints = renderFieldAndReturnConstraints("number"); + + assertTrue("expected minlength to be suppressed for a numeric control", + constraints == null || !constraints.containsKey("minlength")); + } + + @SuppressWarnings("unchecked") + private Map renderFieldAndReturnConstraints(String type) throws Exception { + FormTag form = new FormTag(); + form.setPageContext(pageContext); + form.setAction("constraintAction"); + form.setNamespace(""); + form.doStartTag(); + + TextFieldTag field = new TextFieldTag(); + field.setPageContext(pageContext); + field.setName("username"); + if (type != null) { + field.setType(type); + } + field.doStartTag(); + + Map attributes = + ((UIBean) field.getComponent()).getAttributes(); + + field.doEndTag(); + form.doEndTag(); + + return (Map) attributes.get("constraints"); + } + + private void initDispatcherWith(String constraintsEnabled) { + initDispatcher(new HashMap() {{ + put("configProviders", TestConfigurationProvider.class.getName()); + put(StrutsConstants.STRUTS_UI_HTML5_CONSTRAINTS, constraintsEnabled); + }}); + createMocks(); + // createMocks() never sets a config on the MockActionProxy it builds; without one, + // AnnotationActionValidatorManager.buildValidatorKey NPEs dereferencing proxy.getConfig(). + ((MockActionProxy) actionProxy).setConfig( + configuration.getRuntimeConfiguration().getActionConfig("", "constraintAction")); + } +} diff --git a/core/src/test/java/org/apache/struts2/components/ControlTypeTest.java b/core/src/test/java/org/apache/struts2/components/ControlTypeTest.java new file mode 100644 index 0000000000..c43cfe6b0e --- /dev/null +++ b/core/src/test/java/org/apache/struts2/components/ControlTypeTest.java @@ -0,0 +1,74 @@ +/* + * 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.views.jsp.AbstractUITagTest; + +public class ControlTypeTest extends AbstractUITagTest { + + public void testTextFieldDefaultsToText() { + TextField textField = new TextField(stack, request, response); + assertEquals(HtmlControlType.TEXT, textField.getControlType()); + } + + public void testTextFieldHonoursAnExplicitType() { + TextField textField = new TextField(stack, request, response); + textField.addParameter("type", "number"); + assertEquals(HtmlControlType.NUMBER, textField.getControlType()); + } + + public void testTextFieldFallsBackForAnUnknownType() { + TextField textField = new TextField(stack, request, response); + textField.addParameter("type", "supercolor"); + assertEquals(HtmlControlType.OTHER, textField.getControlType()); + } + + public void testPasswordIsAlwaysPassword() { + Password password = new Password(stack, request, response); + assertEquals(HtmlControlType.PASSWORD, password.getControlType()); + } + + public void testTextAreaIsTextarea() { + TextArea textArea = new TextArea(stack, request, response); + assertEquals(HtmlControlType.TEXTAREA, textArea.getControlType()); + } + + public void testSelectIsSelect() { + Select select = new Select(stack, request, response); + assertEquals(HtmlControlType.SELECT, select.getControlType()); + } + + public void testRadioIsRadio() { + Radio radio = new Radio(stack, request, response); + assertEquals(HtmlControlType.RADIO, radio.getControlType()); + } + + public void testFileIsFile() { + File file = new File(stack, request, response); + assertEquals(HtmlControlType.FILE, file.getControlType()); + } + + public void testControlsWithoutAnOverrideAreUnknown() { + // CheckboxInterceptor substitutes "false" for an unticked box, so the server accepts what + // a browser "required" would block — that is a real false reject, and the reason Checkbox + // and Hidden deliberately have no getControlType() override. + assertEquals(HtmlControlType.OTHER, new Checkbox(stack, request, response).getControlType()); + assertEquals(HtmlControlType.OTHER, new Hidden(stack, request, response).getControlType()); + } +} diff --git a/core/src/test/java/org/apache/struts2/components/EcmaScriptSafeRegexTest.java b/core/src/test/java/org/apache/struts2/components/EcmaScriptSafeRegexTest.java new file mode 100644 index 0000000000..a895d1337b --- /dev/null +++ b/core/src/test/java/org/apache/struts2/components/EcmaScriptSafeRegexTest.java @@ -0,0 +1,83 @@ +/* + * 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.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class EcmaScriptSafeRegexTest { + + @Test + public void acceptsPortableConstructs() { + assertThat(EcmaScriptSafeRegex.isSafe("[a-z]+")).isTrue(); + assertThat(EcmaScriptSafeRegex.isSafe("\\d{3}-\\d{4}")).isTrue(); + assertThat(EcmaScriptSafeRegex.isSafe("(foo|bar)?baz")).isTrue(); + assertThat(EcmaScriptSafeRegex.isSafe("^\\w+@\\w+\\.\\w{2,6}$")).isTrue(); + assertThat(EcmaScriptSafeRegex.isSafe("(?:ab)+")).isTrue(); + assertThat(EcmaScriptSafeRegex.isSafe("a(?=b)")).isTrue(); + assertThat(EcmaScriptSafeRegex.isSafe("a(?!b)")).isTrue(); + } + + @Test + public void rejectsJavaOnlyEscapes() { + assertThat(EcmaScriptSafeRegex.isSafe("\\p{Alpha}+")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("\\A\\d+\\z")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("\\Qliteral\\E")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("\\h+")).isFalse(); + } + + @Test + public void rejectsPossessiveQuantifiers() { + assertThat(EcmaScriptSafeRegex.isSafe("\\d++")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("a*+")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("a?+")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("a{2,3}+")).isFalse(); + } + + @Test + public void rejectsNonPortableGroups() { + assertThat(EcmaScriptSafeRegex.isSafe("(?a)")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("(?<=a)b")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("(?>a)")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("(?i)abc")).isFalse(); + } + + @Test + public void rejectsJavaCharacterClassFeatures() { + assertThat(EcmaScriptSafeRegex.isSafe("[[:alpha:]]")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("[a-z&&[^aeiou]]")).isFalse(); + } + + @Test + public void rejectsUnusableInput() { + assertThat(EcmaScriptSafeRegex.isSafe(null)).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("abc\\")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("[abc")).isFalse(); + } + + @Test + public void rejectsWhitespaceClassesWhoseMeaningDiffersBetweenEngines() { + // Java's \s is ASCII-only by default; ECMAScript's includes NBSP and friends, so + // ^\S+$ accepts a value containing NBSP on the server and rejects it in the browser + assertThat(EcmaScriptSafeRegex.isSafe("^\\S+$")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("\\s*")).isFalse(); + } +} diff --git a/core/src/test/java/org/apache/struts2/components/FormFieldValidatorsTest.java b/core/src/test/java/org/apache/struts2/components/FormFieldValidatorsTest.java new file mode 100644 index 0000000000..cdc2fbfc2b --- /dev/null +++ b/core/src/test/java/org/apache/struts2/components/FormFieldValidatorsTest.java @@ -0,0 +1,95 @@ +/* + * 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.TestConfigurationProvider; +import org.apache.struts2.mock.MockActionProxy; +import org.apache.struts2.validator.ActionValidatorManager; +import org.apache.struts2.validator.Validator; +import org.apache.struts2.views.jsp.AbstractUITagTest; +import org.apache.struts2.views.jsp.ui.FormTag; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.when; + +public class FormFieldValidatorsTest extends AbstractUITagTest { + + public void testFindsTheFieldsValidators() throws Exception { + Form form = formForDoubleValidationAction(); + + List validators = form.getFieldValidators("myUpDownSelectTag"); + + assertEquals(1, validators.size()); + assertEquals("double", validators.get(0).getValidatorType()); + } + + public void testReturnsEmptyForAnUnvalidatedField() throws Exception { + Form form = formForDoubleValidationAction(); + + assertTrue(form.getFieldValidators("noSuchField").isEmpty()); + } + + public void testResolvesTheActionsValidatorsOnlyOnceAcrossFields() throws Exception { + Form form = formForDoubleValidationAction(); + + ActionValidatorManager manager = mock(ActionValidatorManager.class); + when(manager.getValidators(any(Class.class), anyString(), nullable(String.class))) + .thenReturn(Collections.emptyList()); + form.setActionValidatorManager(manager); + + form.getFieldValidators("myUpDownSelectTag"); + form.getFieldValidators("someOtherField"); + + // fully qualified: AbstractUITagTest inherits verify(URL), which would shadow a static import + org.mockito.Mockito.verify(manager, times(1)) + .getValidators(any(Class.class), anyString(), nullable(String.class)); + } + + private Form formForDoubleValidationAction() throws Exception { + FormTag tag = new FormTag(); + tag.setPageContext(pageContext); + tag.setName("myForm"); + tag.setAction("doubleValidationAction"); + tag.setNamespace(""); + tag.doStartTag(); + return (Form) tag.getComponent(); + } + + @Override + protected void setUp() throws Exception { + super.setUp(); + initDispatcher(new HashMap() {{ + put("configProviders", TestConfigurationProvider.class.getName()); + }}); + createMocks(); + // AnnotationActionValidatorManager.buildValidatorKey() dereferences the current ActionInvocation's + // proxy config; at real runtime the Dispatcher always attaches one, but the mock proxy from + // createMocks() does not, so it has to be wired up explicitly here. + ((MockActionProxy) actionProxy).setConfig( + configuration.getRuntimeConfiguration().getActionConfig("", "doubleValidationAction")); + } +} diff --git a/core/src/test/java/org/apache/struts2/components/HtmlControlTypeTest.java b/core/src/test/java/org/apache/struts2/components/HtmlControlTypeTest.java new file mode 100644 index 0000000000..9fa862cdd6 --- /dev/null +++ b/core/src/test/java/org/apache/struts2/components/HtmlControlTypeTest.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 org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class HtmlControlTypeTest { + + @Test + public void resolvesKnownTypes() { + assertThat(HtmlControlType.from("text")).isEqualTo(HtmlControlType.TEXT); + assertThat(HtmlControlType.from("number")).isEqualTo(HtmlControlType.NUMBER); + assertThat(HtmlControlType.from("datetime-local")).isEqualTo(HtmlControlType.DATETIME_LOCAL); + } + + @Test + public void isLenientAboutCaseAndWhitespace() { + assertThat(HtmlControlType.from(" NuMbEr ")).isEqualTo(HtmlControlType.NUMBER); + } + + @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); + } + + @Test + public void otherSupportsNothing() { + assertThat(HtmlControlType.OTHER.supportsPattern()).isFalse(); + assertThat(HtmlControlType.OTHER.supportsLength()).isFalse(); + assertThat(HtmlControlType.OTHER.supportsRange()).isFalse(); + } + + @Test + public void patternIsTextEntryOnly() { + assertThat(HtmlControlType.TEXT.supportsPattern()).isTrue(); + assertThat(HtmlControlType.PASSWORD.supportsPattern()).isTrue(); + assertThat(HtmlControlType.NUMBER.supportsPattern()).isFalse(); + assertThat(HtmlControlType.TEXTAREA.supportsPattern()).isFalse(); + assertThat(HtmlControlType.SELECT.supportsPattern()).isFalse(); + } + + @Test + public void lengthIsTextEntryPlusTextarea() { + assertThat(HtmlControlType.TEXT.supportsLength()).isTrue(); + assertThat(HtmlControlType.TEXTAREA.supportsLength()).isTrue(); + assertThat(HtmlControlType.NUMBER.supportsLength()).isFalse(); + assertThat(HtmlControlType.CHECKBOX.supportsLength()).isFalse(); + } + + @Test + public void rangeIsNumericAndTemporalOnly() { + assertThat(HtmlControlType.NUMBER.supportsRange()).isTrue(); + assertThat(HtmlControlType.RANGE.supportsRange()).isTrue(); + assertThat(HtmlControlType.DATE.supportsRange()).isTrue(); + assertThat(HtmlControlType.TEXT.supportsRange()).isFalse(); + } +} diff --git a/core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java b/core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java new file mode 100644 index 0000000000..0cc97eab0f --- /dev/null +++ b/core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java @@ -0,0 +1,312 @@ +/* + * 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.ActionSupport; +import org.apache.struts2.validator.Validator; +import org.apache.struts2.validator.validators.CreditCardValidator; +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.RegexFieldValidator; +import org.apache.struts2.validator.validators.RequiredFieldValidator; +import org.apache.struts2.validator.validators.RequiredStringValidator; +import org.apache.struts2.validator.validators.StringLengthFieldValidator; +import org.junit.Before; +import org.junit.Test; + +import java.util.Date; +import java.util.List; +import java.util.Map; + +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class StrutsHtmlConstraintProviderTest { + + private StrutsHtmlConstraintProvider provider; + private Object action; + + @Before + public void setUp() { + provider = new StrutsHtmlConstraintProvider(); + action = new ActionSupport(); + } + + private Map constraints(Validator validator, HtmlControlType control) { + return provider.constraintsFor(singletonList(validator), control, null); + } + + @Test + public void requiredStringEmitsRequiredEvenThoughServerIsStricter() { + assertThat(constraints(new RequiredStringValidator(), HtmlControlType.TEXT)) + .containsEntry("required", "required"); + } + + @Test + public void requiredStringEmitsRequiredOnTextarea() { + assertThat(constraints(new RequiredStringValidator(), HtmlControlType.TEXTAREA)) + .containsEntry("required", "required"); + } + + @Test + public void requiredFieldEmitsNothingOnATextControlBecauseEmptyStringWouldPassServerSide() { + // an empty text input submits name="", which RequiredFieldValidator accepts (it only + // rejects null / empty array / empty collection) — required here would false-reject + assertThat(constraints(new RequiredFieldValidator(), HtmlControlType.TEXT)).isEmpty(); + } + + @Test + public void requiredFieldEmitsNothingOnACheckboxBecauseUncheckedSubstitutesFalse() { + // CheckboxInterceptor substitutes "false" for an unticked box, so the field is never + // null server-side and an unticked required checkbox would still pass validation + assertThat(constraints(new RequiredFieldValidator(), HtmlControlType.CHECKBOX)).isEmpty(); + } + + @Test + public void requiredFieldEmitsNothingOnASelectBecauseAnEmptyOptionWouldPassServerSide() { + // a select with an empty-valued header option submits "", which passes server-side + assertThat(constraints(new RequiredFieldValidator(), HtmlControlType.SELECT)).isEmpty(); + } + + @Test + public void requiredFieldEmitsRequiredOnRadioBecauseNoSelectionOmitsTheParameter() { + // an unselected radio group omits the parameter entirely, agreeing with the server + assertThat(constraints(new RequiredFieldValidator(), HtmlControlType.RADIO)) + .containsEntry("required", "required"); + } + + @Test + public void requiredFieldEmitsRequiredOnFileBecauseNoSelectionOmitsTheParameter() { + // an empty file input omits the parameter entirely, agreeing with the server + assertThat(constraints(new RequiredFieldValidator(), HtmlControlType.FILE)) + .containsEntry("required", "required"); + } + + @Test + public void stringLengthEmitsLengthsWhenNotTrimming() { + StringLengthFieldValidator validator = new StringLengthFieldValidator(); + validator.setTrim(false); + validator.setMinLength(3); + validator.setMaxLength(10); + + assertThat(constraints(validator, HtmlControlType.TEXT)) + .containsEntry("minlength", "3") + .containsEntry("maxlength", "10"); + } + + @Test + public void stringLengthEmitsNothingWhenTrimming() { + StringLengthFieldValidator validator = new StringLengthFieldValidator(); + validator.setTrim(true); + validator.setMinLength(3); + validator.setMaxLength(10); + + // the server measures the trimmed value, so maxlength here would stop the user + // typing input the server would have accepted + assertThat(constraints(validator, HtmlControlType.TEXT)).isEmpty(); + } + + @Test + public void stringLengthEmitsNothingOnAControlWithoutLength() { + StringLengthFieldValidator validator = new StringLengthFieldValidator(); + validator.setTrim(false); + validator.setMaxLength(10); + + assertThat(constraints(validator, HtmlControlType.NUMBER)).isEmpty(); + } + + @Test + public void stringLengthOmitsMinlengthWhenOnlyMaxLengthIsSet() { + StringLengthFieldValidator validator = new StringLengthFieldValidator(); + validator.setTrim(false); + validator.setMaxLength(10); + + Map result = constraints(validator, HtmlControlType.TEXT); + + // minLength defaults to the -1 sentinel (unset), which must not become "minlength=-1" + assertThat(result) + .containsEntry("maxlength", "10") + .doesNotContainKey("minlength"); + } + + @Test + public void regexEmitsPatternWhenPortableAndCaseSensitive() { + RegexFieldValidator validator = new RegexFieldValidator(); + validator.setRegex("[a-z]+"); + validator.setCaseSensitive(true); + validator.setTrim(false); + + assertThat(constraints(validator, HtmlControlType.TEXT)) + .containsEntry("pattern", "[a-z]+"); + } + + @Test + public void regexEmitsNothingWhenCaseInsensitive() { + RegexFieldValidator validator = new RegexFieldValidator(); + validator.setRegex("[a-z]+"); + validator.setCaseSensitive(false); + validator.setTrim(false); + + // HTML pattern accepts no flags, so a case-insensitive rule cannot be expressed + assertThat(constraints(validator, HtmlControlType.TEXT)).isEmpty(); + } + + @Test + public void regexEmitsNothingWhenNotPortable() { + RegexFieldValidator validator = new RegexFieldValidator(); + validator.setRegex("\\p{Alpha}+"); + validator.setCaseSensitive(true); + validator.setTrim(false); + + assertThat(constraints(validator, HtmlControlType.TEXT)).isEmpty(); + } + + @Test + public void regexEmitsNothingWhenTrimming() { + // trim defaults to true: the server matches the trimmed value while pattern matches the + // raw one, so "abc " would pass server-side and be blocked by the browser + RegexFieldValidator validator = new RegexFieldValidator(); + validator.setRegex("[a-z]+"); + validator.setCaseSensitive(true); + + assertThat(constraints(validator, HtmlControlType.TEXT)).isEmpty(); + } + + @Test + public void creditCardValidatorNeverContributesAPatternConstraint() { + // CreditCardValidator strips all whitespace before matching, so its regex cannot be + // expressed as a browser pattern without also stripping whitespace client-side. + // caseSensitive and trim are set explicitly here so this test actually reaches the + // EmailValidator/CreditCardValidator exclusion in addPattern, rather than returning + // earlier at the case-sensitivity guard (the constructor defaults caseSensitive to false). + CreditCardValidator validator = new CreditCardValidator(); + validator.setCaseSensitive(true); + validator.setTrim(false); + + assertThat(constraints(validator, HtmlControlType.TEXT)).isEmpty(); + } + + @Test + public void intRangeEmitsBoundsOnlyOnANumericControl() { + IntRangeFieldValidator validator = new IntRangeFieldValidator(); + validator.setMin(5); + validator.setMax(50); + + assertThat(constraints(validator, HtmlControlType.NUMBER)) + .containsEntry("min", "5") + .containsEntry("max", "50"); + assertThat(constraints(validator, HtmlControlType.TEXT)).isEmpty(); + } + + @Test + public void doubleRangeEmitsInclusiveBoundsOnlyOnANumericControl() { + // integral bounds here, deliberately: a fractional min is covered separately by + // doubleRangeOmitsMinWhenItIsFractionalBecauseItWouldShiftTheStepBase, since it must NOT + // emit min at all (it would shift the HTML step base off zero) + DoubleRangeFieldValidator validator = new DoubleRangeFieldValidator(); + validator.setMinInclusive(6000.0); + validator.setMaxInclusive(10000.1); + + assertThat(constraints(validator, HtmlControlType.NUMBER)) + .containsEntry("min", "6000.0") + .containsEntry("max", "10000.1"); + assertThat(constraints(validator, HtmlControlType.TEXT)).isEmpty(); + } + + @Test + public void doubleRangeOmitsMinWhenItIsFractionalBecauseItWouldShiftTheStepBase() { + // min becomes the HTML step base, and the default step is 1: min="6000.1" would make the + // browser reject 6002, which DoubleRangeFieldValidator accepts server-side. max does not + // participate in the step base, so it is unaffected. + DoubleRangeFieldValidator validator = new DoubleRangeFieldValidator(); + validator.setMinInclusive(6000.1); + validator.setMaxInclusive(10000.1); + + Map result = constraints(validator, HtmlControlType.NUMBER); + + assertThat(result) + .containsEntry("max", "10000.1") + .doesNotContainKey("min"); + } + + @Test + public void doubleRangeEmitsMinWhenItIsIntegral() { + DoubleRangeFieldValidator validator = new DoubleRangeFieldValidator(); + validator.setMinInclusive(6000.0); + validator.setMaxInclusive(10000.0); + + assertThat(constraints(validator, HtmlControlType.NUMBER)) + .containsEntry("min", "6000.0") + .containsEntry("max", "10000.0"); + } + + @Test + public void dateRangeEmitsNothingBecauseTemporalFormattingIsDeferred() { + DateRangeFieldValidator validator = new DateRangeFieldValidator(); + validator.setMin(new Date(0)); + validator.setMax(new Date(1_000_000L)); + + assertThat(constraints(validator, HtmlControlType.DATE)).isEmpty(); + } + + @Test + public void emailValidatorNeverContributesAConstraint() { + // the browser's email grammar differs from EmailValidator's, so honouring it could reject + // an address the server accepts. caseSensitive and trim are set explicitly here so this + // test actually reaches addPattern's EmailValidator exclusion, rather than returning + // earlier at the case-sensitivity guard or the isTrimed() guard (the constructor defaults + // caseSensitive to false, and trim defaults to true). + EmailValidator textControlValidator = new EmailValidator(); + textControlValidator.setCaseSensitive(true); + textControlValidator.setTrim(false); + assertThat(constraints(textControlValidator, HtmlControlType.TEXT)).isEmpty(); + + EmailValidator emailControlValidator = new EmailValidator(); + emailControlValidator.setCaseSensitive(true); + emailControlValidator.setTrim(false); + assertThat(constraints(emailControlValidator, HtmlControlType.EMAIL)).isEmpty(); + } + + @Test + public void unknownControlGetsNothing() { + assertThat(constraints(new RequiredFieldValidator(), HtmlControlType.OTHER)).isEmpty(); + } + + @Test + public void emptyInputIsHandled() { + assertThat(provider.constraintsFor(null, HtmlControlType.TEXT, null)).isEmpty(); + assertThat(provider.constraintsFor(List.of(), HtmlControlType.TEXT, null)).isEmpty(); + } + + @Test + public void messageIsEmittedEvenForAValidatorThatContributesNoConstraint() { + Validator validator = mock(Validator.class); + when(validator.getValidatorType()).thenReturn("email"); + when(validator.getMessage(action)).thenReturn("not an email"); + + Map result = + provider.constraintsFor(singletonList(validator), HtmlControlType.TEXT, action); + + assertThat(result).containsEntry("data-msg-email", "not an email"); + } +} 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 new file mode 100644 index 0000000000..c5979e36d5 --- /dev/null +++ b/core/src/test/java/org/apache/struts2/views/jsp/ui/Html5ConstraintRenderingTest.java @@ -0,0 +1,131 @@ +/* + * 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.views.jsp.ui; + +import org.apache.struts2.StrutsConstants; +import org.apache.struts2.TestConfigurationProvider; +import org.apache.struts2.mock.MockActionProxy; +import org.apache.struts2.views.jsp.AbstractUITagTest; + +import java.util.HashMap; + +public class Html5ConstraintRenderingTest extends AbstractUITagTest { + + public void testRendersConstraintAttributes() throws Exception { + String output = render("true"); + + assertTrue("expected minlength in: " + output, output.contains("minlength=\"3\"")); + } + + public void testRendersNothingWhenTheConstantIsOff() throws Exception { + String output = render("false"); + + assertFalse("expected no minlength in: " + output, output.contains("minlength=")); + } + + public void testRendersExactMarkupWhenTheConstantIsOff() throws Exception { + String output = render("false"); + + assertEquals("

" + + "
", output); + } + + public void testRequiredLabelDoesNotBecomeARequiredAttribute() throws Exception { + String output = render("true", "username", "true"); + + assertTrue("expected minlength in: " + output, output.contains("minlength=\"3\"")); + assertFalse("requiredLabel draws an asterisk; it must never emit a required attribute: " + output, + output.contains("required=\"required\"")); + } + + /** + * text.ftl renders {@code attributes.maxlength} (the developer's own tag attribute) before + * including common-attributes.ftl, which renders the derived {@code attributes.constraints} map. + * Without suppressing the derived duplicate, a stringlength validator on this field would render + * {@code maxlength} twice: once from the tag attribute, once from the constraint. + */ + public void testDeveloperSetMaxlengthSuppressesTheDerivedOne() throws Exception { + String output = renderWithMaxlength("bio", "20"); + + int firstIndex = output.indexOf("maxlength="); + assertTrue("expected a maxlength attribute in: " + output, firstIndex >= 0); + assertEquals("expected exactly one maxlength attribute in: " + output, + firstIndex, output.lastIndexOf("maxlength=")); + assertTrue("expected the developer's own value to win: " + output, + output.contains("maxlength=\"20\"")); + } + + /** + * data-msg-* values pass through TextParseUtil.translateVariables and can carry user-submitted + * content into an HTML attribute. Escaping is applied by FreemarkerManager's HTMLOutputFormat + * configuration, not by the template, so this pins it against regression. + */ + public void testDataMsgAttributesAreHtmlEscaped() throws Exception { + String output = render("true", "comment", null); + + assertTrue("expected the escaped message in: " + output, + output.contains("data-msg-requiredstring=\"Contains "quotes" and <brackets>\"")); + assertFalse("the raw, unescaped message must never appear in: " + output, + output.contains("Contains \"quotes\" and ")); + } + + private String render(String constraintsEnabled) throws Exception { + return render(constraintsEnabled, "username", null); + } + + private String render(String constraintsEnabled, String fieldName, String requiredLabel) throws Exception { + return render(constraintsEnabled, fieldName, requiredLabel, null); + } + + private String renderWithMaxlength(String fieldName, String maxlength) throws Exception { + return render("true", fieldName, null, maxlength); + } + + private String render(String constraintsEnabled, String fieldName, String requiredLabel, String maxlength) throws Exception { + initDispatcher(new HashMap() {{ + put("configProviders", TestConfigurationProvider.class.getName()); + put(StrutsConstants.STRUTS_UI_HTML5_CONSTRAINTS, constraintsEnabled); + }}); + createMocks(); + ((MockActionProxy) actionProxy).setConfig(configuration.getRuntimeConfiguration().getActionConfig("", "constraintAction")); + + FormTag form = new FormTag(); + form.setPageContext(pageContext); + form.setTheme("html5"); + form.setAction("constraintAction"); + form.setNamespace(""); + form.doStartTag(); + + TextFieldTag field = new TextFieldTag(); + field.setPageContext(pageContext); + field.setTheme("html5"); + field.setName(fieldName); + if (requiredLabel != null) { + field.setRequiredLabel(requiredLabel); + } + if (maxlength != null) { + field.setMaxlength(maxlength); + } + field.doStartTag(); + field.doEndTag(); + form.doEndTag(); + + return writer.toString(); + } +} diff --git a/core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml b/core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml new file mode 100644 index 0000000000..30bf5dd431 --- /dev/null +++ b/core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml @@ -0,0 +1,43 @@ + + + + + + + false + 3 + username must be at least ${minLength} characters + + + + + Contains "quotes" and <brackets> + + + + + false + 10 + bio must be at most ${maxLength} characters + + + diff --git a/docs/superpowers/plans/2026-08-24-html5-constraint-validation.md b/docs/superpowers/plans/2026-08-24-html5-constraint-validation.md new file mode 100644 index 0000000000..2bde1ae1f7 --- /dev/null +++ b/docs/superpowers/plans/2026-08-24-html5-constraint-validation.md @@ -0,0 +1,1828 @@ +# HTML5 Constraint Validation (7.4.0) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Deprecate the generated JavaScript client-side validator, and let the `html5` theme emit native HTML5 constraint attributes derived from the action's validators. + +**Architecture:** A container-registered `HtmlConstraintProvider` maps a field's validators plus its resolved `HtmlControlType` to a map of HTML attributes. `UIBean.evaluateParams` calls it at the end of the method and stashes the result as the `constraints` attribute; a new `html5/constraints.ftl`, included from `common-attributes.ftl`, renders it. Everything is gated behind a new constant defaulting to `false`, so no existing rendering changes. + +**Tech Stack:** Java 17, Maven, FreeMarker templates, JUnit 4 + JUnit 3 (`XWorkTestCase`), AssertJ, EasyMock. + +**Spec:** `docs/superpowers/specs/2026-08-24-html5-constraint-validation-design.md` + +**Tickets:** WW-5694 (deprecation, Task 1), WW-5695 (constraints, Tasks 2–9). WW-5696 is the 8.0.0 removal and is **out of scope for this plan**. + +## Global Constraints + +- Target version string in every `@Deprecated` annotation: `since = "7.4.0", forRemoval = true`. +- **Every new file — main source AND test source — must carry the Apache licence header**, copied verbatim + from a sibling file in the same directory. The code blocks in this plan omit it for brevity; that omission + is not permission to skip it. `apache-rat-plugin:check` is bound to the `prepare-package` phase + (`pom.xml:549-555`), so `mvn test -DskipAssembly` — the command every task below uses — **never runs the + licence check**. A missing header therefore passes every task-level verification in this plan and fails + the real build. Add it as you create each file. +- **Tests are JUnit 4 or JUnit 3. There is no JUnit 5 in this repo.** New pure-unit test classes use `org.junit.Test`. Any test that renders a tag must extend `AbstractUITagTest` (JUnit 3 style: methods named `testXxx()`, no annotation). A Jupiter `@Test` on an `XWorkTestCase` subclass silently never runs. +- Commit message format: `WW-XXXX (): `. Ticket prefix is mandatory. +- Never commit to `main`. Work happens on `feature/WW-5695-html5-constraint-validation`. +- Never `git add -A` in this repo — the tree holds ~20 long-lived untracked files. Stage explicit paths and verify with `git diff --cached --name-only`. +- Build command: `mvn test -DskipAssembly -pl core -Dtest=` from the repo root. +- `DateTest.testJavaSqlDate` is a known flake (WW-5686). A red build on that one alone is the clock, not your change. +- **The governing rule for every mapping decision: never false-reject.** Emit a constraint only when the browser cannot reject input the server would accept. When unsure, emit nothing. +- Struts ships **no JavaScript** as part of this work. + +--- + +### Task 1: Deprecate the JavaScript client-side validator (WW-5694) + +Independent of every other task and separately mergeable. Annotations and documentation only — no behaviour changes. + +**Files:** +- Modify: `core/src/main/java/org/apache/struts2/components/Form.java` +- Modify: `core/src/main/java/org/apache/struts2/views/jsp/ui/FormTag.java` +- Modify: `core/src/main/resources/template/xhtml/form-close-validate.ftl` + +**Interfaces:** +- Consumes: nothing. +- Produces: nothing. Later tasks do not depend on this one. + +- [ ] **Step 1: Annotate the `Form` component** + +In `Form.java`, add `@Deprecated(since = "7.4.0", forRemoval = true)` to three members. `setValidate` is around line 512, `getValidators` around line 270, `evaluateClientSideJsEnablement` around line 242 — confirm by search, not by line number. + +```java + @Deprecated(since = "7.4.0", forRemoval = true) + protected void evaluateClientSideJsEnablement(String actionName, String namespace, String actionMethod) { + + @Deprecated(since = "7.4.0", forRemoval = true) + public List getValidators(String name) { + + @StrutsTagAttribute(description = "Whether client side/js validation should be performed. Only useful with theme xhtml/ajax", type = "Boolean", defaultValue = "false") + @Deprecated(since = "7.4.0", forRemoval = true) + public void setValidate(String validate) { +``` + +Keep the existing `@StrutsTagAttribute` annotation and its exact text; only add the `@Deprecated` line beneath it. + +**Do not touch `Dispatcher.setValidate` or `ValidationInterceptor.setValidate`.** They are unrelated methods that happen to share the name. + +- [ ] **Step 2: Annotate the JSP tag layer** + +WW-5510 (`c2a5bfe3c`) annotated both the component and the tag. In `FormTag.java` (setter around line 96): + +```java + @Deprecated(since = "7.4.0", forRemoval = true) + public void setValidate(String validate) { + this.validate = validate; + } +``` + +Leave `FormTag.clearTagStateForTagPoolingServers` alone — the field reset stays until removal. + +- [ ] **Step 3: Add the Javadoc banner** + +Find the `` block in `Form.java`'s class Javadoc that documents `validate`. Add, as the first line inside the snippet: + +```java + * Deprecated since 7.4.0 — use the html5 theme's constraint attributes instead. Removed in 8.0.0. +``` + +This matters because the website pulls these snippets in via the `remote_file_content` Jekyll plugin. Omitting it leaves struts.apache.org advertising the feature as current. + +- [ ] **Step 4: Add the template banner** + +A `.ftl` carries no annotation, and anyone who overrode this template needs to see the notice there. In `core/src/main/resources/template/xhtml/form-close-validate.ftl`, immediately after the Apache licence header comment: + +``` +<#-- +DEPRECATED since Struts 7.4.0, removed in 8.0.0 (WW-5694 / WW-5696). + +JavaScript client-side validation is superseded by native HTML5 constraint +attributes in the html5 theme (WW-5695). This template, form-validate.ftl and +validation.js are all removed in 8.0.0. +--> +``` + +- [ ] **Step 5: Verify the build still compiles and existing tests pass** + +Run: `mvn test -DskipAssembly -pl core -Dtest=FormTagTest` +Expected: PASS. Deprecation annotations change no behaviour, so all four `validateForm_` golden files must still match byte-for-byte. + +- [ ] **Step 6: Commit** + +```bash +git add core/src/main/java/org/apache/struts2/components/Form.java \ + core/src/main/java/org/apache/struts2/views/jsp/ui/FormTag.java \ + core/src/main/resources/template/xhtml/form-close-validate.ftl +git diff --cached --name-only +git commit -m "WW-5694 refactor(validation): deprecate JavaScript client-side validation + +Marks the form tag's validate attribute and the machinery behind it for +removal in 8.0.0. Annotations and documentation only; no behaviour change. + +Follows the WW-5510 precedent: annotate both the component and the JSP tag, +and put the notice inside the START SNIPPET block so the website picks it up. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 2: `HtmlControlType` enum + +**Files:** +- Create: `core/src/main/java/org/apache/struts2/components/HtmlControlType.java` +- Test: `core/src/test/java/org/apache/struts2/components/HtmlControlTypeTest.java` + +**Interfaces:** +- Consumes: nothing. +- Produces: `HtmlControlType` with `static HtmlControlType from(String type)`, and instance predicates `boolean supportsPattern()`, `boolean supportsLength()`, `boolean supportsRange()`. Used by Tasks 4, 6, 7. + +Package note: `org.apache.struts2.components` is where `UrlRenderer` lives, so a new view-layer extension point belongs there by convention. WW-5689 eventually moves this whole package into a plugin; that is expected and does not change the right answer for 7.4.0. + +- [ ] **Step 1: Write the failing test** + +Create `core/src/test/java/org/apache/struts2/components/HtmlControlTypeTest.java` (JUnit 4 — this is a plain object with no container): + +```java +package org.apache.struts2.components; + +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class HtmlControlTypeTest { + + @Test + public void resolvesKnownTypes() { + assertThat(HtmlControlType.from("text")).isEqualTo(HtmlControlType.TEXT); + assertThat(HtmlControlType.from("number")).isEqualTo(HtmlControlType.NUMBER); + assertThat(HtmlControlType.from("datetime-local")).isEqualTo(HtmlControlType.DATETIME_LOCAL); + } + + @Test + public void isLenientAboutCaseAndWhitespace() { + assertThat(HtmlControlType.from(" NuMbEr ")).isEqualTo(HtmlControlType.NUMBER); + } + + @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); + } + + @Test + public void otherSupportsNothing() { + assertThat(HtmlControlType.OTHER.supportsPattern()).isFalse(); + assertThat(HtmlControlType.OTHER.supportsLength()).isFalse(); + assertThat(HtmlControlType.OTHER.supportsRange()).isFalse(); + } + + @Test + public void patternIsTextEntryOnly() { + assertThat(HtmlControlType.TEXT.supportsPattern()).isTrue(); + assertThat(HtmlControlType.PASSWORD.supportsPattern()).isTrue(); + assertThat(HtmlControlType.NUMBER.supportsPattern()).isFalse(); + assertThat(HtmlControlType.TEXTAREA.supportsPattern()).isFalse(); + assertThat(HtmlControlType.SELECT.supportsPattern()).isFalse(); + } + + @Test + public void lengthIsTextEntryPlusTextarea() { + assertThat(HtmlControlType.TEXT.supportsLength()).isTrue(); + assertThat(HtmlControlType.TEXTAREA.supportsLength()).isTrue(); + assertThat(HtmlControlType.NUMBER.supportsLength()).isFalse(); + assertThat(HtmlControlType.CHECKBOX.supportsLength()).isFalse(); + } + + @Test + public void rangeIsNumericAndTemporalOnly() { + assertThat(HtmlControlType.NUMBER.supportsRange()).isTrue(); + assertThat(HtmlControlType.RANGE.supportsRange()).isTrue(); + assertThat(HtmlControlType.DATE.supportsRange()).isTrue(); + assertThat(HtmlControlType.TEXT.supportsRange()).isFalse(); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `mvn test -DskipAssembly -pl core -Dtest=HtmlControlTypeTest` +Expected: FAIL — compilation error, `HtmlControlType` does not exist. + +- [ ] **Step 3: Write the implementation** + +Create `core/src/main/java/org/apache/struts2/components/HtmlControlType.java` with the standard Apache licence header (copy it verbatim from any neighbouring file in the same package), then: + +```java +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. + *

+ * This models the control 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, + TEXTAREA, + OTHER; + + private static final Set TEXT_ENTRY = EnumSet.of(TEXT, SEARCH, TEL, PASSWORD, EMAIL, URL); + private static final Set NUMERIC = EnumSet.of(NUMBER, RANGE); + private static final Set TEMPORAL = EnumSet.of(DATE, MONTH, WEEK, TIME, DATETIME_LOCAL); + + /** + * 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 + * constraints at all — so an unknown control degrades to emitting nothing. + */ + public static HtmlControlType from(String type) { + if (type == null) { + return OTHER; + } + String normalised = type.trim().toUpperCase(Locale.ROOT).replace('-', '_'); + if (normalised.isEmpty()) { + return OTHER; + } + try { + return valueOf(normalised); + } catch (IllegalArgumentException e) { + return OTHER; + } + } + + public boolean supportsPattern() { + return TEXT_ENTRY.contains(this); + } + + public boolean supportsLength() { + return TEXT_ENTRY.contains(this) || this == TEXTAREA; + } + + public boolean supportsRange() { + return NUMERIC.contains(this) || TEMPORAL.contains(this); + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `mvn test -DskipAssembly -pl core -Dtest=HtmlControlTypeTest` +Expected: PASS, 7 tests. + +- [ ] **Step 5: Commit** + +```bash +git add core/src/main/java/org/apache/struts2/components/HtmlControlType.java \ + core/src/test/java/org/apache/struts2/components/HtmlControlTypeTest.java +git diff --cached --name-only +git commit -m "WW-5695 feat(components): add HtmlControlType + +Models the kind of form control a UIBean renders, so constraint derivation can +ask which HTML5 attributes are legal rather than string-matching a type +attribute. Models the control, not the attribute, because textarea and select +have no type yet still accept required. + +from() never throws; unknown input becomes OTHER, which supports nothing. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 3: ECMAScript-safe regex detection + +The highest-risk logic in this work. A regex that Java and the browser interpret differently becomes a false rejection the user cannot get past, so detection is an **allowlist**: anything not provably common to both engines is rejected. + +**Files:** +- Create: `core/src/main/java/org/apache/struts2/components/EcmaScriptSafeRegex.java` +- Test: `core/src/test/java/org/apache/struts2/components/EcmaScriptSafeRegexTest.java` + +**Interfaces:** +- Consumes: nothing. +- Produces: `EcmaScriptSafeRegex.isSafe(String regex)` returning `boolean`. Used by Task 4. + +- [ ] **Step 1: Write the failing test** + +```java +package org.apache.struts2.components; + +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class EcmaScriptSafeRegexTest { + + @Test + public void acceptsPortableConstructs() { + assertThat(EcmaScriptSafeRegex.isSafe("[a-z]+")).isTrue(); + assertThat(EcmaScriptSafeRegex.isSafe("\\d{3}-\\d{4}")).isTrue(); + assertThat(EcmaScriptSafeRegex.isSafe("(foo|bar)?baz")).isTrue(); + assertThat(EcmaScriptSafeRegex.isSafe("^\\w+@\\w+\\.\\w{2,6}$")).isTrue(); + assertThat(EcmaScriptSafeRegex.isSafe("(?:ab)+")).isTrue(); + assertThat(EcmaScriptSafeRegex.isSafe("a(?=b)")).isTrue(); + assertThat(EcmaScriptSafeRegex.isSafe("a(?!b)")).isTrue(); + } + + @Test + public void rejectsJavaOnlyEscapes() { + assertThat(EcmaScriptSafeRegex.isSafe("\\p{Alpha}+")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("\\A\\d+\\z")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("\\Qliteral\\E")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("\\h+")).isFalse(); + } + + @Test + public void rejectsWhitespaceClassesWhoseMeaningDiffersBetweenEngines() { + // Java's \s is ASCII-only by default; ECMAScript's includes NBSP and friends, so + // ^\S+$ accepts a value containing NBSP on the server and rejects it in the browser + assertThat(EcmaScriptSafeRegex.isSafe("^\\S+$")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("\\s*")).isFalse(); + } + + @Test + public void rejectsPossessiveQuantifiers() { + assertThat(EcmaScriptSafeRegex.isSafe("\\d++")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("a*+")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("a?+")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("a{2,3}+")).isFalse(); + } + + @Test + public void rejectsNonPortableGroups() { + assertThat(EcmaScriptSafeRegex.isSafe("(?a)")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("(?<=a)b")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("(?>a)")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("(?i)abc")).isFalse(); + } + + @Test + public void rejectsJavaCharacterClassFeatures() { + assertThat(EcmaScriptSafeRegex.isSafe("[[:alpha:]]")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("[a-z&&[^aeiou]]")).isFalse(); + } + + @Test + public void rejectsUnusableInput() { + assertThat(EcmaScriptSafeRegex.isSafe(null)).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("abc\\")).isFalse(); + assertThat(EcmaScriptSafeRegex.isSafe("[abc")).isFalse(); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `mvn test -DskipAssembly -pl core -Dtest=EcmaScriptSafeRegexTest` +Expected: FAIL — compilation error, `EcmaScriptSafeRegex` does not exist. + +- [ ] **Step 3: Write the implementation** + +Create `core/src/main/java/org/apache/struts2/components/EcmaScriptSafeRegex.java` with the Apache licence header, then: + +```java +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. + *

+ * 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. + *

+ * {@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\\.*+?()[]{}|^$/-"; + + private EcmaScriptSafeRegex() { + } + + public static boolean isSafe(String regex) { + if (regex == null || regex.isEmpty()) { + return false; + } + boolean inCharClass = false; + for (int i = 0; i < regex.length(); i++) { + char current = regex.charAt(i); + switch (current) { + case '\\': + if (i + 1 >= regex.length() || ALLOWED_ESCAPES.indexOf(regex.charAt(++i)) < 0) { + return false; + } + break; + case '[': + // Java allows nested classes and POSIX names; ECMAScript allows neither + if (inCharClass || regex.startsWith("[:", i)) { + return false; + } + inCharClass = true; + break; + case ']': + inCharClass = false; + break; + case '&': + // Java character-class intersection + if (inCharClass && i + 1 < regex.length() && regex.charAt(i + 1) == '&') { + return false; + } + break; + case '(': + // only non-capturing groups and lookahead are portable; named groups, + // lookbehind, atomic groups and inline flags are not + if (i + 1 < regex.length() && regex.charAt(i + 1) == '?') { + if (i + 2 >= regex.length()) { + return false; + } + char kind = regex.charAt(i + 2); + if (kind != ':' && kind != '=' && kind != '!') { + return false; + } + } + break; + case '*': + case '+': + case '?': + case '}': + // possessive quantifier + if (i + 1 < regex.length() && regex.charAt(i + 1) == '+') { + return false; + } + break; + default: + break; + } + } + return !inCharClass; + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `mvn test -DskipAssembly -pl core -Dtest=EcmaScriptSafeRegexTest` +Expected: PASS, 7 tests. + +- [ ] **Step 5: Commit** + +```bash +git add core/src/main/java/org/apache/struts2/components/EcmaScriptSafeRegex.java \ + core/src/test/java/org/apache/struts2/components/EcmaScriptSafeRegexTest.java +git diff --cached --name-only +git commit -m "WW-5695 feat(components): add ECMAScript-safe regex detection + +Decides whether a Java regex can become an HTML5 pattern attribute without +changing meaning. Allowlist by design: a denylist would violate the +never-false-reject rule the first time it missed a construct, and a regex the +browser reads differently is a rejection the user cannot get past. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 4: `HtmlConstraintProvider` and the default implementation + +**Files:** +- Create: `core/src/main/java/org/apache/struts2/components/HtmlConstraintProvider.java` +- Create: `core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java` +- Modify: `core/src/main/resources/struts-beans.xml:146` +- Test: `core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java` + +**Interfaces:** +- Consumes: `HtmlControlType` (Task 2), `EcmaScriptSafeRegex.isSafe(String)` (Task 3). +- Produces: `HtmlConstraintProvider.constraintsFor(List validators, HtmlControlType control, Object action)` returning `Map`. Used by Task 7. + +**Signature note:** the spec's sketch showed two parameters. The third, `action`, is required — `Validator.getMessage(Object)` needs the action instance to resolve i18n text, and the `data-msg-*` attributes come from it. + +- [ ] **Step 1: Write the failing test** + +The negative cases carry the weight here; they are what protects the never-false-reject rule. + +```java +package org.apache.struts2.components; + +import org.apache.struts2.ActionSupport; +import org.apache.struts2.validator.Validator; +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.RegexFieldValidator; +import org.apache.struts2.validator.validators.RequiredFieldValidator; +import org.apache.struts2.validator.validators.RequiredStringValidator; +import org.apache.struts2.validator.validators.StringLengthFieldValidator; +import org.junit.Before; +import org.junit.Test; + +import java.util.List; +import java.util.Map; + +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; + +public class StrutsHtmlConstraintProviderTest { + + private StrutsHtmlConstraintProvider provider; + private Object action; + + @Before + public void setUp() { + provider = new StrutsHtmlConstraintProvider(); + action = new ActionSupport(); + } + + private Map constraints(Validator validator, HtmlControlType control) { + return provider.constraintsFor(singletonList(validator), control, null); + } + + @Test + public void requiredValidatorEmitsRequired() { + assertThat(constraints(new RequiredFieldValidator(), HtmlControlType.TEXT)) + .containsEntry("required", "required"); + } + + @Test + public void requiredStringEmitsRequiredEvenThoughServerIsStricter() { + assertThat(constraints(new RequiredStringValidator(), HtmlControlType.TEXT)) + .containsEntry("required", "required"); + } + + @Test + public void stringLengthEmitsLengthsWhenNotTrimming() { + StringLengthFieldValidator validator = new StringLengthFieldValidator(); + validator.setTrim(false); + validator.setMinLength(3); + validator.setMaxLength(10); + + assertThat(constraints(validator, HtmlControlType.TEXT)) + .containsEntry("minlength", "3") + .containsEntry("maxlength", "10"); + } + + @Test + public void stringLengthEmitsNothingWhenTrimming() { + StringLengthFieldValidator validator = new StringLengthFieldValidator(); + validator.setTrim(true); + validator.setMinLength(3); + validator.setMaxLength(10); + + // the server measures the trimmed value, so maxlength here would stop the user + // typing input the server would have accepted + assertThat(constraints(validator, HtmlControlType.TEXT)).isEmpty(); + } + + @Test + public void stringLengthEmitsNothingOnAControlWithoutLength() { + StringLengthFieldValidator validator = new StringLengthFieldValidator(); + validator.setTrim(false); + validator.setMaxLength(10); + + assertThat(constraints(validator, HtmlControlType.NUMBER)).isEmpty(); + } + + @Test + public void regexEmitsPatternWhenPortableAndCaseSensitive() { + RegexFieldValidator validator = new RegexFieldValidator(); + validator.setRegex("[a-z]+"); + validator.setCaseSensitive(true); + + assertThat(constraints(validator, HtmlControlType.TEXT)) + .containsEntry("pattern", "[a-z]+"); + } + + @Test + public void regexEmitsNothingWhenCaseInsensitive() { + RegexFieldValidator validator = new RegexFieldValidator(); + validator.setRegex("[a-z]+"); + validator.setCaseSensitive(false); + + // HTML pattern accepts no flags, so a case-insensitive rule cannot be expressed + assertThat(constraints(validator, HtmlControlType.TEXT)).isEmpty(); + } + + @Test + public void regexEmitsNothingWhenNotPortable() { + RegexFieldValidator validator = new RegexFieldValidator(); + validator.setRegex("\\p{Alpha}+"); + validator.setCaseSensitive(true); + + assertThat(constraints(validator, HtmlControlType.TEXT)).isEmpty(); + } + + @Test + public void intRangeEmitsBoundsOnlyOnANumericControl() { + IntRangeFieldValidator validator = new IntRangeFieldValidator(); + validator.setMin(5); + validator.setMax(50); + + assertThat(constraints(validator, HtmlControlType.NUMBER)) + .containsEntry("min", "5") + .containsEntry("max", "50"); + assertThat(constraints(validator, HtmlControlType.TEXT)).isEmpty(); + } + + @Test + public void doubleRangeEmitsInclusiveBoundsOnlyOnANumericControl() { + DoubleRangeFieldValidator validator = new DoubleRangeFieldValidator(); + validator.setMinInclusive(6000.1); + validator.setMaxInclusive(10000.1); + + assertThat(constraints(validator, HtmlControlType.NUMBER)) + .containsEntry("min", "6000.1") + .containsEntry("max", "10000.1"); + assertThat(constraints(validator, HtmlControlType.TEXT)).isEmpty(); + } + + @Test + public void emailValidatorNeverContributesAConstraint() { + // the browser's email grammar differs from EmailValidator's, so honouring it + // could reject an address the server accepts + assertThat(constraints(new EmailValidator(), HtmlControlType.TEXT)).isEmpty(); + assertThat(constraints(new EmailValidator(), HtmlControlType.EMAIL)).isEmpty(); + } + + @Test + public void unknownControlGetsNothing() { + assertThat(constraints(new RequiredFieldValidator(), HtmlControlType.OTHER)).isEmpty(); + } + + @Test + public void emptyInputIsHandled() { + assertThat(provider.constraintsFor(null, HtmlControlType.TEXT, null)).isEmpty(); + assertThat(provider.constraintsFor(List.of(), HtmlControlType.TEXT, null)).isEmpty(); + } + + @Test + public void messageIsEmittedEvenForAValidatorThatContributesNoConstraint() { + EmailValidator validator = new EmailValidator(); + validator.setDefaultMessage("not an email"); + + Map result = + provider.constraintsFor(singletonList(validator), HtmlControlType.TEXT, action); + + assertThat(result).containsEntry("data-msg-email", "not an email"); + } +} +``` + +**Note on `required` and `OTHER`:** the test above asserts `OTHER` gets nothing at all, including `required`. That is deliberate — `OTHER` means "we do not know what this control is", and guessing is how false rejections happen. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `mvn test -DskipAssembly -pl core -Dtest=StrutsHtmlConstraintProviderTest` +Expected: FAIL — compilation error, `StrutsHtmlConstraintProvider` does not exist. + +- [ ] **Step 3: Write the interface** + +Create `core/src/main/java/org/apache/struts2/components/HtmlConstraintProvider.java` with the licence header, then: + +```java +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. + *

+ * 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. + * + * @since 7.4.0 + */ +public interface HtmlConstraintProvider { + + /** + * @param validators the field's validators; may be null or empty + * @param control the kind of control being rendered + * @param action the action instance, used to resolve i18n validator messages; may be null + * @return attribute name to value; never null, possibly empty + */ + Map constraintsFor(List validators, HtmlControlType control, Object action); +} +``` + +- [ ] **Step 4: Write the default implementation** + +Create `core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java` with the licence header, then: + +**Post-implementation note (added during final-review fixup, not part of the original TDD pass):** the +listing below is the shipped implementation, not the first draft. Two corrections were made after this +task's tests originally went green, both discovered in a whole-branch review: + +- `addRequired` split into `addRequiredString` (safe on any text-entry or `TEXTAREA` control) and + `addRequiredField` (safe only on `RADIO`/`FILE` — see the mapping table in the spec). The original single + `addRequired`, gated only on `control != OTHER`, would have emitted `required` on a text input or a select, + which false-rejects an empty string the server accepts. `addRequiredField` was briefly dead code in an + intermediate commit because no component overrode `getControlType()` to return `RADIO` or `FILE`; that is + fixed in Task 6 below, which now includes those two overrides. +- `addPattern` gained an explicit `EmailValidator`/`CreditCardValidator` exclusion and a `trim` guard, and + `addRange`/`addDoubleRange` gained an integral-`min` guard. Each is explained inline below. + +```java +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}. + *

+ * 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 never sets or changes + * an input's {@code type} — 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 { + + @Override + public Map constraintsFor(List validators, HtmlControlType control, Object action) { + Map 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 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 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 attributes, HtmlControlType control) { + if (control != HtmlControlType.RADIO && control != HtmlControlType.FILE) { + return; + } + attributes.put("required", "required"); + } + + protected void addLength(Map 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 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 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 would not. + Object min = validator.getMin(); + if (isIntegral(min)) { + putIfPresent(attributes, "min", min); + } + putIfPresent(attributes, "max", validator.getMax()); + } + + protected void addDoubleRange(Map attributes, DoubleRangeFieldValidator validator, HtmlControlType control) { + if (!isNumericRange(control)) { + 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. For {@code input type="number"}/{@code "range"}, + * the step base is the {@code min} attribute's value when present (otherwise 0); a {@code double} + * validator with {@code minInclusive=6000.1} would render {@code min="6000.1"}, making the only valid + * values 6000.1, 6001.1, 6002.1 … — the browser then rejects {@code 6002}, which + * {@code DoubleRangeFieldValidator} accepts server-side. + */ + 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); + } + + protected void addMessage(Map attributes, Validator validator, Object action) { + if (action == null) { + return; + } + String message = validator.getMessage(action); + if (message != null && !message.isEmpty()) { + attributes.put("data-msg-" + validator.getValidatorType(), message); + } + } + + private void putIfPresent(Map attributes, String name, Object value) { + if (value != null) { + attributes.put(name, String.valueOf(value)); + } + } +} +``` + +**Scope decision, made explicit:** `DateRangeFieldValidator` extends `RangeValidatorSupport`, so it reaches `addRange`, where `isNumericRange` rejects it and it emits nothing. Temporal `min`/`max` need per-control ISO formatting (`date` wants `yyyy-MM-dd`, `week` wants `2026-W12`, and so on) and are **deliberately not implemented here**. `HtmlControlType.supportsRange()` already covers the temporal types so the enum needs no change when this lands; file it as a follow-up rather than guessing at the formats now. + +- [ ] **Step 5: Register the bean** + +In `core/src/main/resources/struts-beans.xml`, immediately after the `UrlRenderer` bean (around line 146): + +```xml + +``` + +Register it **once**, under a single type. Registering a bean under two types builds two instances. + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `mvn test -DskipAssembly -pl core -Dtest=StrutsHtmlConstraintProviderTest` +Expected: PASS, 14 tests. + +- [ ] **Step 7: Commit** + +```bash +git add core/src/main/java/org/apache/struts2/components/HtmlConstraintProvider.java \ + core/src/main/java/org/apache/struts2/components/StrutsHtmlConstraintProvider.java \ + core/src/main/resources/struts-beans.xml \ + core/src/test/java/org/apache/struts2/components/StrutsHtmlConstraintProviderTest.java +git diff --cached --name-only +git commit -m "WW-5695 feat(components): map validators onto HTML5 constraint attributes + +Adds HtmlConstraintProvider and its conservative default implementation, which +never sets or changes an input's type: type=number would reject 1234,50 that +locale-aware conversion accepts, and browser email/url grammars differ from the +framework's validators. Range constraints therefore land only on a control the +developer already made numeric. + +Registered as a swappable bean so applications wanting a best-effort mapping +can replace the policy. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 5: `Form.getFieldValidators` with per-render memoisation + +**Files:** +- Modify: `core/src/main/java/org/apache/struts2/components/Form.java` +- Test: `core/src/test/java/org/apache/struts2/components/FormFieldValidatorsTest.java` + +**Interfaces:** +- Consumes: nothing new. +- Produces: `Form.getFieldValidators(String name)` returning `List`. Used by Task 7. + +`Form.getValidators(String)` re-runs the action-mapping lookup and `actionValidatorManager.getValidators(...)` on **every** call, so a twenty-field form would do twenty full resolutions. The new method resolves once per form render. + +**Memoisation location:** on private fields of the `Form` component, **not** on the attributes map as the spec sketch said. A `Form` component is constructed per render by `ComponentTagSupport`, so a field is naturally request-scoped, and the attributes map is exposed to templates and should not carry private bookkeeping. + +Leave `getValidators(String)` exactly as it is. It is deprecated and dies with the JavaScript validator in 8.0.0; the small duplication dies with it. + +- [ ] **Step 1: Write the failing test** + +```java +package org.apache.struts2.components; + +import org.apache.struts2.TestConfigurationProvider; +import org.apache.struts2.validator.Validator; +import org.apache.struts2.views.jsp.AbstractUITagTest; +import org.apache.struts2.views.jsp.ui.FormTag; + +import java.util.HashMap; +import java.util.List; + +public class FormFieldValidatorsTest extends AbstractUITagTest { + + public void testFindsTheFieldsValidators() throws Exception { + Form form = formForDoubleValidationAction(); + + List validators = form.getFieldValidators("myUpDownSelectTag"); + + assertEquals(1, validators.size()); + assertEquals("double", validators.get(0).getValidatorType()); + } + + public void testReturnsEmptyForAnUnvalidatedField() throws Exception { + Form form = formForDoubleValidationAction(); + + assertTrue(form.getFieldValidators("noSuchField").isEmpty()); + } + + public void testResolvesTheActionsValidatorsOnlyOnceAcrossFields() throws Exception { + Form form = formForDoubleValidationAction(); + + ActionValidatorManager manager = mock(ActionValidatorManager.class); + when(manager.getValidators(any(), any(), any())).thenReturn(Collections.emptyList()); + form.setActionValidatorManager(manager); + + form.getFieldValidators("myUpDownSelectTag"); + form.getFieldValidators("someOtherField"); + + verify(manager, times(1)).getValidators(any(), any(), any()); + } + + private Form formForDoubleValidationAction() throws Exception { + FormTag tag = new FormTag(); + tag.setPageContext(pageContext); + tag.setName("myForm"); + tag.setAction("doubleValidationAction"); + tag.setNamespace(""); + tag.doStartTag(); + return (Form) tag.getComponent(); + } + + @Override + protected void setUp() throws Exception { + super.setUp(); + initDispatcher(new HashMap() {{ + put("configProviders", TestConfigurationProvider.class.getName()); + }}); + createMocks(); + } +} +``` + +**Harness trap, part two:** `createMocks()` never calls `setConfig` on the `MockActionProxy` it builds, so +`AnnotationActionValidatorManager.buildValidatorKey` dereferences a null `ActionConfig` and NPEs. The test's +`setUp` also needs `((MockActionProxy) actionProxy).setConfig(configuration.getRuntimeConfiguration() +.getActionConfig("", "doubleValidationAction"))`. This is why `FormTagTest` carries its own +`prepareMockInvocation()` helper. + +**Do not write a memoisation test that only compares result sizes** — resolution is deterministic, so such a +test passes identically against an implementation with no cache at all. Assert the *number of resolutions* +with a mocked `ActionValidatorManager`, across two different field names. + +**Harness trap:** without `initDispatcher(configProviders = TestConfigurationProvider)` *and* `createMocks()`, the action config is not present and validator resolution silently returns nothing — the test would pass or fail for entirely the wrong reason. `DoubleValidationAction-validation.xml` already exists at `core/src/test/resources/org/apache/struts2/views/jsp/ui/` and declares a `double` validator for `myUpDownSelectTag`. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `mvn test -DskipAssembly -pl core -Dtest=FormFieldValidatorsTest` +Expected: FAIL — compilation error, `getFieldValidators` does not exist. + +- [ ] **Step 3: Write the implementation** + +In `Form.java`, add two fields beside the existing ones (near `actionValidatorManager`, around line 120): + +```java + private List cachedActionValidators; + private String cachedActionName; + private boolean actionValidatorsResolved; +``` + +Then add the method next to `getValidators`: + +```java + /** + * Returns the validators declared for a single field, resolving the action's validator list at + * most once per form render. + * + * @since 7.4.0 + */ + public List getFieldValidators(String name) { + resolveActionValidators(); + if (cachedActionValidators.isEmpty()) { + return Collections.emptyList(); + } + Class actionClass = (Class) getAttributes().get("actionClass"); + List validators = new ArrayList<>(); + findFieldValidators(name, actionClass, cachedActionName, cachedActionValidators, validators, ""); + return validators; + } + + private void resolveActionValidators() { + if (actionValidatorsResolved) { + return; + } + actionValidatorsResolved = true; + cachedActionValidators = Collections.emptyList(); + + Class actionClass = (Class) getAttributes().get("actionClass"); + if (actionClass == null) { + return; + } + ActionMapping mapping = actionMapper.getMappingFromActionName(findString(action)); + if (mapping == null) { + mapping = actionMapper.getMappingFromActionName((String) getAttributes().get("actionName")); + } + if (mapping == null) { + return; + } + cachedActionName = mapping.getName(); + String methodName = isValidateAnnotatedMethodOnly(cachedActionName) ? mapping.getMethod() : null; + cachedActionValidators = + actionValidatorManager.getValidators(actionClass, cachedActionName, methodName); + } +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `mvn test -DskipAssembly -pl core -Dtest=FormFieldValidatorsTest` +Expected: PASS, 3 tests. + +- [ ] **Step 5: Verify nothing regressed** + +Run: `mvn test -DskipAssembly -pl core -Dtest=FormTagTest` +Expected: PASS. `getValidators` was not touched, so all four `validateForm_` golden files must still match. + +- [ ] **Step 6: Commit** + +```bash +git add core/src/main/java/org/apache/struts2/components/Form.java \ + core/src/test/java/org/apache/struts2/components/FormFieldValidatorsTest.java +git diff --cached --name-only +git commit -m "WW-5695 feat(components): add Form.getFieldValidators with per-render caching + +getValidators re-runs the action-mapping lookup and the validator-manager +resolution on every call, so a twenty-field form would do twenty of them. The +new method resolves once per form render and filters per field. + +Memoised on component fields rather than the attributes map: a Form component +is built per render, and the attributes map is exposed to templates. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 6: `getControlType()` on the component hierarchy + +**Files:** +- Modify: `core/src/main/java/org/apache/struts2/components/UIBean.java` +- Modify: `core/src/main/java/org/apache/struts2/components/TextField.java` +- Modify: `core/src/main/java/org/apache/struts2/components/Password.java` +- Modify: `core/src/main/java/org/apache/struts2/components/TextArea.java` +- Modify: `core/src/main/java/org/apache/struts2/components/Select.java` +- Modify: `core/src/main/java/org/apache/struts2/components/Radio.java` (added as a fix — see the correction note below) +- Modify: `core/src/main/java/org/apache/struts2/components/File.java` (added as a fix — see the correction note below) +- Test: `core/src/test/java/org/apache/struts2/components/ControlTypeTest.java` + +**Interfaces:** +- Consumes: `HtmlControlType` (Task 2). +- Produces: `protected HtmlControlType UIBean.getControlType()`. Used by Task 7. + +`attributes.type` is set by `TextField.evaluateExtraParams` (`TextField.java:91`) and by nothing else on the input path — `TextArea`, `Select`, `Checkbox`, `File`, `Hidden` and `Radio` have no `type` attribute at all. So the control type needs a component hook. + +**Post-implementation correction (added during final-review fixup):** the steps below, as originally +written, gave `Checkbox`, `Radio`, `File` and `Hidden` **no** override, on the theory that all four should +fall through to `OTHER`. That was wrong for two of them. `RADIO` and `FILE` are the only controls where an +unselected/empty submission omits the parameter entirely rather than submitting an empty value, so they are +the only two where `required` agrees with the server (see the mapping table in the spec) — without their own +`getControlType()` override they fall through to `OTHER`, and `addRequiredField` in `StrutsHtmlConstraintProvider` +becomes unreachable dead code. `Radio` and `File` were given overrides as a fix, making it six overrides +total, not four. `Checkbox` and `Hidden` are unaffected by this correction and still deliberately get no +override: `CheckboxInterceptor` substitutes `"false"` for an unticked box, so the field is never missing +server-side and a browser `required` there would false-reject. `ComboBox` extends `TextField` and correctly +inherits `TEXT`. + +- [ ] **Step 1: Write the failing test** + +```java +package org.apache.struts2.components; + +import org.apache.struts2.views.jsp.AbstractUITagTest; + +public class ControlTypeTest extends AbstractUITagTest { + + public void testTextFieldDefaultsToText() { + TextField textField = new TextField(stack, request, response); + assertEquals(HtmlControlType.TEXT, textField.getControlType()); + } + + public void testTextFieldHonoursAnExplicitType() { + TextField textField = new TextField(stack, request, response); + textField.addParameter("type", "number"); + assertEquals(HtmlControlType.NUMBER, textField.getControlType()); + } + + public void testTextFieldFallsBackForAnUnknownType() { + TextField textField = new TextField(stack, request, response); + textField.addParameter("type", "supercolor"); + assertEquals(HtmlControlType.OTHER, textField.getControlType()); + } + + public void testPasswordIsAlwaysPassword() { + Password password = new Password(stack, request, response); + assertEquals(HtmlControlType.PASSWORD, password.getControlType()); + } + + public void testTextAreaIsTextarea() { + TextArea textArea = new TextArea(stack, request, response); + assertEquals(HtmlControlType.TEXTAREA, textArea.getControlType()); + } + + public void testSelectIsSelect() { + Select select = new Select(stack, request, response); + assertEquals(HtmlControlType.SELECT, select.getControlType()); + } + + public void testControlsWithoutAnOverrideAreUnknown() { + assertEquals(HtmlControlType.OTHER, new Checkbox(stack, request, response).getControlType()); + assertEquals(HtmlControlType.OTHER, new Hidden(stack, request, response).getControlType()); + assertEquals(HtmlControlType.OTHER, new File(stack, request, response).getControlType()); + } +} +``` + +`getControlType()` is `protected`, and this test lives in the same package, so it is directly callable. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `mvn test -DskipAssembly -pl core -Dtest=ControlTypeTest` +Expected: FAIL — compilation error, `getControlType` does not exist. + +- [ ] **Step 3: Add the base method to `UIBean`** + +Place it next to the other `protected` helpers, above `evaluateExtraParams()`: + +```java + /** + * 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 + * constraints — so a component that does not override this emits none. + * + * @since 7.4.0 + */ + protected HtmlControlType getControlType() { + return HtmlControlType.OTHER; + } +``` + +- [ ] **Step 4: Add the overrides** (originally four; `Radio` and `File` were added as a fix — see the + correction note above) + +`TextField.java` — reads the attribute set by its own `evaluateExtraParams`, defaulting to `TEXT` to match `text.ftl`'s `attributes.type!"text"`: + +```java + @Override + protected HtmlControlType getControlType() { + Object type = getAttributes().get("type"); + return type == null ? HtmlControlType.TEXT : HtmlControlType.from(String.valueOf(type)); + } +``` + +`Password.java` — it extends `TextField`, but its template hardcodes the type: + +```java + @Override + protected HtmlControlType getControlType() { + return HtmlControlType.PASSWORD; + } +``` + +`TextArea.java`: + +```java + @Override + protected HtmlControlType getControlType() { + return HtmlControlType.TEXTAREA; + } +``` + +`Select.java`: + +```java + @Override + protected HtmlControlType getControlType() { + return HtmlControlType.SELECT; + } +``` + +`Radio.java` and `File.java` — added as a fix; without these, `addRequiredField` in +`StrutsHtmlConstraintProvider` has no control type it can ever match, making it dead code: + +```java + @Override + protected HtmlControlType getControlType() { + return HtmlControlType.RADIO; + } +``` + +```java + @Override + protected HtmlControlType getControlType() { + return HtmlControlType.FILE; + } +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `mvn test -DskipAssembly -pl core -Dtest=ControlTypeTest` +Expected: PASS, 7 tests. + +- [ ] **Step 6: Commit** + +```bash +git add core/src/main/java/org/apache/struts2/components/UIBean.java \ + core/src/main/java/org/apache/struts2/components/TextField.java \ + core/src/main/java/org/apache/struts2/components/Password.java \ + core/src/main/java/org/apache/struts2/components/TextArea.java \ + core/src/main/java/org/apache/struts2/components/Select.java \ + core/src/main/java/org/apache/struts2/components/Radio.java \ + core/src/main/java/org/apache/struts2/components/File.java \ + core/src/test/java/org/apache/struts2/components/ControlTypeTest.java +git diff --cached --name-only +git commit -m "WW-5695 feat(components): resolve the HTML control type per component + +attributes.type is set by TextField and nothing else on the input path, so the +control type cannot come from the attribute map alone. Adds getControlType() +with six overrides; Checkbox and Hidden fall through to OTHER, which emits +nothing and is correct for both (CheckboxInterceptor substitutes false for an +unticked box, so required there would false-reject). Radio and File get their +own RADIO/FILE overrides, since they are the only controls where required +agrees with the server. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 7: The constant and the `UIBean` wiring + +**Files:** +- Modify: `core/src/main/java/org/apache/struts2/StrutsConstants.java:204` +- Modify: `core/src/main/resources/org/apache/struts2/default.properties:167` +- Modify: `core/src/main/java/org/apache/struts2/components/UIBean.java` +- Test: `core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java` + +**Interfaces:** +- Consumes: `HtmlConstraintProvider` (Task 4), `Form.getFieldValidators` (Task 5), `getControlType()` (Task 6). +- Produces: the `constraints` attribute on the component's attribute map, a `Map`. Consumed by Task 8's template. + +**Hook placement — this is the part that is easy to get wrong.** `evaluateParams()` resolves `final Form form = (Form) findAncestor(Form.class)` at `UIBean.java:824` and appends to `tagNames` just below. **The hook must not go there.** `evaluateExtraParams()` is the *last* statement of `evaluateParams()` (`UIBean.java:905`), and that is where `TextField` sets `attributes.type` — so at the `tagNames` block no text field has a resolved type and every one of them would look like `OTHER`. The hook goes at the very end of the method. The `form` local is method-scoped and still in scope there. + +- [ ] **Step 1: Write the failing test** + +```java +package org.apache.struts2.components; + +import org.apache.struts2.StrutsConstants; +import org.apache.struts2.TestConfigurationProvider; +import org.apache.struts2.views.jsp.AbstractUITagTest; +import org.apache.struts2.views.jsp.ui.FormTag; +import org.apache.struts2.views.jsp.ui.TextFieldTag; + +import java.util.HashMap; +import java.util.Map; + +public class ConstraintAttributesTest extends AbstractUITagTest { + + public void testNoConstraintsWhenTheConstantIsOff() throws Exception { + initDispatcherWith("false"); + + assertNull(renderFieldAndReturnConstraints()); + } + + public void testConstraintsWhenTheConstantIsOn() throws Exception { + initDispatcherWith("true"); + + Map constraints = renderFieldAndReturnConstraints(); + assertNotNull("expected constraints to be populated", constraints); + assertEquals("3", constraints.get("minlength")); + } + + @SuppressWarnings("unchecked") + private Map renderFieldAndReturnConstraints() throws Exception { + FormTag form = new FormTag(); + form.setPageContext(pageContext); + form.setAction("constraintAction"); + form.setNamespace(""); + form.doStartTag(); + + TextFieldTag field = new TextFieldTag(); + field.setPageContext(pageContext); + field.setName("username"); + field.doStartTag(); + + Map attributes = + ((UIBean) field.getComponent()).getAttributes(); + + field.doEndTag(); + form.doEndTag(); + + return (Map) attributes.get("constraints"); + } + + private void initDispatcherWith(String constraintsEnabled) throws Exception { + initDispatcher(new HashMap() {{ + put("configProviders", TestConfigurationProvider.class.getName()); + put(StrutsConstants.STRUTS_UI_HTML5_CONSTRAINTS, constraintsEnabled); + }}); + createMocks(); + } +} +``` + +This test needs an action named `constraintAction` with a `stringlength` validator carrying `trim="false"` and `minLength=3` on a `username` field. Create both: + +`core/src/test/java/org/apache/struts2/components/ConstraintAction.java`: + +```java +package org.apache.struts2.components; + +import org.apache.struts2.ActionSupport; +import org.apache.struts2.interceptor.parameter.StrutsParameter; + +public class ConstraintAction extends ActionSupport { + + private String username; + + public String getUsername() { + return username; + } + + @StrutsParameter + public void setUsername(String username) { + this.username = username; + } +} +``` + +`core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml` (copy the licence header and DOCTYPE verbatim from `core/src/test/resources/org/apache/struts2/views/jsp/ui/DoubleValidationAction-validation.xml`): + +```xml + + + + false + 3 + username must be at least ${minLength} characters + + + +``` + +Then register `constraintAction` in `core/src/test/java/org/apache/struts2/TestConfigurationProvider.java`. Add the config beside the existing `doubleValidationActionConfig` (around line 90) — it must carry the `ValidationInterceptor` mapping, or `evaluateClientSideJsEnablement` finds no interceptor and the action looks unvalidated: + +```java + ActionConfig constraintActionConfig = new ActionConfig.Builder("", "constraintAction", ConstraintAction.class.getName()) + .addResultConfig(new ResultConfig.Builder(Action.SUCCESS, ServletDispatcherResult.class.getName()) + .addParam("location", "success.jsp") + .build()) + .addInterceptor(new InterceptorMapping("validation", validationInterceptor)) + .build(); +``` + +and register it in the `defaultPackageConfig` builder (around line 121), next to the `doubleValidationAction` line: + +```java + .addActionConfig("constraintAction", constraintActionConfig) +``` + +Add `import org.apache.struts2.components.ConstraintAction;` at the top. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `mvn test -DskipAssembly -pl core -Dtest=ConstraintAttributesTest` +Expected: FAIL — compilation error, `STRUTS_UI_HTML5_CONSTRAINTS` does not exist. + +- [ ] **Step 3: Add the constant** + +In `StrutsConstants.java`, after `STRUTS_UI_STATIC_CONTENT_PATH` (around line 204): + +```java + /** + * Whether the html5 theme emits HTML5 constraint attributes derived from the action's validators. + * Defaults to false in 7.4.0; the default becomes true in 8.0.0. + * + * @since 7.4.0 + */ + public static final String STRUTS_UI_HTML5_CONSTRAINTS = "struts.ui.html5.constraints"; +``` + +In `default.properties`, in the "Standard UI theme" block after `struts.ui.templateSuffix=ftl`: + +```properties +### Whether the html5 theme emits HTML5 constraint attributes (required, minlength, +### maxlength, pattern, min, max) derived from the action's validators. +### Defaults to false so existing html5-theme forms render unchanged; becomes true in 8.0.0. +struts.ui.html5.constraints=false +``` + +- [ ] **Step 4: Wire `UIBean`** + +Add the field and injection setters alongside the existing ones (near `setCspNonceReader`, around line 559): + +```java + protected HtmlConstraintProvider htmlConstraintProvider; + protected boolean html5ConstraintsEnabled; + + @Inject + public void setHtmlConstraintProvider(HtmlConstraintProvider htmlConstraintProvider) { + this.htmlConstraintProvider = htmlConstraintProvider; + } + + @Inject(value = StrutsConstants.STRUTS_UI_HTML5_CONSTRAINTS, required = false) + public void setHtml5ConstraintsEnabled(String html5ConstraintsEnabled) { + this.html5ConstraintsEnabled = BooleanUtils.toBoolean(html5ConstraintsEnabled); + } +``` + +Then, at the **end** of `evaluateParams()`, after the existing `evaluateExtraParams();` call at line 905: + +```java + 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. + * + * @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; + } + Map constraints = htmlConstraintProvider.constraintsFor( + form.getFieldValidators(fieldName), getControlType(), stack.peek()); + if (!constraints.isEmpty()) { + addParameter("constraints", constraints); + } + } +``` + +Add `import org.apache.commons.lang3.BooleanUtils;` if it is not already present. + +Gating on `html5ConstraintsEnabled` first means the cost is exactly zero when the feature is off. + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `mvn test -DskipAssembly -pl core -Dtest=ConstraintAttributesTest` +Expected: PASS, 2 tests. + +- [ ] **Step 6: Verify nothing regressed** + +Run: `mvn test -DskipAssembly -pl core` +Expected: PASS. The constant defaults to `false`, so every existing golden file must be unchanged. If `DateTest.testJavaSqlDate` fails alone, that is the known WW-5686 flake — rerun it on its own to confirm. + +- [ ] **Step 7: Commit** + +```bash +git add core/src/main/java/org/apache/struts2/StrutsConstants.java \ + core/src/main/resources/org/apache/struts2/default.properties \ + core/src/main/java/org/apache/struts2/components/UIBean.java \ + core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java \ + core/src/test/java/org/apache/struts2/components/ConstraintAction.java \ + core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml \ + core/src/test/java/org/apache/struts2/TestConfigurationProvider.java +git diff --cached --name-only +git commit -m "WW-5695 feat(components): derive constraint attributes during tag evaluation + +Adds struts.ui.html5.constraints, default false, and wires UIBean to the +constraint provider behind it. + +The hook sits at the end of evaluateParams rather than beside the tagNames +block, because evaluateExtraParams is where TextField resolves attributes.type +and it runs last; hooking earlier would make every text field look like OTHER. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 8: Render the attributes from the html5 theme + +**Files:** +- Create: `core/src/main/resources/template/html5/constraints.ftl` +- Modify: `core/src/main/resources/template/html5/common-attributes.ftl` +- Test: `core/src/test/java/org/apache/struts2/views/jsp/ui/Html5ConstraintRenderingTest.java` + +**Interfaces:** +- Consumes: the `constraints` attribute produced by Task 7. +- Produces: rendered HTML. Nothing downstream depends on it. + +Including from `common-attributes.ftl` means every html5 input picks the attributes up without per-template edits. + +- [ ] **Step 1: Write the failing test** + +```java +package org.apache.struts2.views.jsp.ui; + +import org.apache.struts2.StrutsConstants; +import org.apache.struts2.TestConfigurationProvider; +import org.apache.struts2.views.jsp.AbstractUITagTest; + +import java.util.HashMap; + +public class Html5ConstraintRenderingTest extends AbstractUITagTest { + + public void testRendersConstraintAttributes() throws Exception { + String output = render("true"); + + assertTrue("expected minlength in: " + output, output.contains("minlength=\"3\"")); + } + + public void testRendersNothingWhenTheConstantIsOff() throws Exception { + String output = render("false"); + + assertFalse("expected no minlength in: " + output, output.contains("minlength=")); + } + + public void testRequiredLabelDoesNotBecomeARequiredAttribute() throws Exception { + FormTag form = new FormTag(); + form.setPageContext(pageContext); + form.setTheme("html5"); + form.setAction("constraintAction"); + form.setNamespace(""); + form.doStartTag(); + + TextFieldTag field = new TextFieldTag(); + field.setPageContext(pageContext); + field.setTheme("html5"); + field.setName("noValidatorHere"); + field.setRequiredLabel("true"); + field.doStartTag(); + field.doEndTag(); + form.doEndTag(); + + String output = writer.toString(); + assertFalse("requiredLabel draws an asterisk; it must never emit a required attribute: " + output, + output.contains("required=\"required\"")); + } + + private String render(String constraintsEnabled) throws Exception { + initDispatcher(new HashMap() {{ + put("configProviders", TestConfigurationProvider.class.getName()); + put(StrutsConstants.STRUTS_UI_HTML5_CONSTRAINTS, constraintsEnabled); + }}); + createMocks(); + + FormTag form = new FormTag(); + form.setPageContext(pageContext); + form.setTheme("html5"); + form.setAction("constraintAction"); + form.setNamespace(""); + form.doStartTag(); + + TextFieldTag field = new TextFieldTag(); + field.setPageContext(pageContext); + field.setTheme("html5"); + field.setName("username"); + field.doStartTag(); + field.doEndTag(); + form.doEndTag(); + + return writer.toString(); + } +} +``` + +The third test is the one that matters most: `requiredLabel` is the visual asterisk and must never become a `required` attribute. Conflating them is the most likely regression in this whole change. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `mvn test -DskipAssembly -pl core -Dtest=Html5ConstraintRenderingTest` +Expected: FAIL — `testRendersConstraintAttributes` finds no `minlength`, because nothing renders the map yet. + +- [ ] **Step 3: Create the template** + +`core/src/main/resources/template/html5/constraints.ftl`, with the same `<#--` licence header used by the neighbouring html5 templates, then: + +``` +<#if attributes.constraints??><#list attributes.constraints as attributeName, attributeValue> ${attributeName}="${attributeValue}"<#rt/> +``` + +**Do not add a `?html` builtin — it is a parse error here.** `FreemarkerManager` sets +`ENABLE_IF_DEFAULT_AUTO_ESCAPING_POLICY` with `HTMLOutputFormat` (`FreemarkerManager.java:354-355`), so values +are already HTML-escaped by configuration and FreeMarker rejects `?html` as a double-escape. Zero templates in +this repo use it. The escaping is still load-bearing — `data-msg-*` values are OGNL-interpolated and can carry +user-submitted content — so never disable auto-escaping for this template. + +- [ ] **Step 4: Include it** + +Append to `core/src/main/resources/template/html5/common-attributes.ftl`, after the existing `accesskey` block: + +``` +<#include "/${attributes.templateDir}/${attributes.expandTheme}/constraints.ftl" /><#rt/> +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `mvn test -DskipAssembly -pl core -Dtest=Html5ConstraintRenderingTest` +Expected: PASS, 3 tests. + +- [ ] **Step 6: Verify the whole suite** + +Run: `mvn test -DskipAssembly -pl core` +Expected: PASS, including every existing html5 golden file — the constant is off by default, so `Formtag-1-html5.txt` and friends must be byte-identical. + +- [ ] **Step 7: Commit** + +```bash +git add core/src/main/resources/template/html5/constraints.ftl \ + core/src/main/resources/template/html5/common-attributes.ftl \ + core/src/test/java/org/apache/struts2/views/jsp/ui/Html5ConstraintRenderingTest.java +git diff --cached --name-only +git commit -m "WW-5695 feat(html5): render derived constraint attributes + +Included from common-attributes.ftl so every html5 input picks the attributes +up without per-template edits. Values are HTML-escaped: pattern and the +data-msg-* text are author-controlled and land inside an attribute. + +Covers the regression that matters most - requiredLabel draws an asterisk and +must never emit a required attribute. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 9: Documentation (`struts-site` repository) + +**This task is in a different repository:** `~/Projects/Apache/struts-site`. It needs its own branch, its own commit, and its own PR. Docs changes there take conventional-commit form with no ticket prefix. + +**Files:** +- Modify: `source/core-developers/client-side-validation.md` +- Delete: `source/core-developers/pure-java-script-client-side-validation.md` +- Modify: `source/core-developers/client-validation-example.md` +- Modify: `source/tag-developers/form-tag.md` + +- [ ] **Step 1: Rewrite the client-side validation page** + +Rewrite `client-side-validation.md` around the html5 theme. It must carry: + +- the `struts.ui.html5.constraints` constant, its `false` default in 7.4.0 and the 8.0.0 flip; +- the full mapping table from the spec, including the `trim="false"` and `caseSensitive="true"` conditions; +- the never-change-the-type rule, stated as the reason `type="email"`, `type="url"` and `type="number"` are never set by Struts, with the `1234,50` comma-decimal example; +- the `data-msg-*` attributes, and that Struts ships nothing that consumes them; +- that `requiredLabel` is unrelated to the `required` attribute; +- a deprecation notice for the JavaScript validator pointing at WW-5694 and WW-5696. + +Its existing "Client Side Validation Types" table links to the pure-JavaScript page; replace that section. + +- [ ] **Step 2: Delete the pure-JavaScript page and fix the stale i18n claim** + +Delete `pure-java-script-client-side-validation.md`. Before deleting, note that it claims errors are reported "not the internationalized version that the server-side might be aware of" — **that is wrong**, `ValidatorSupport.getMessage` resolves through `DelegatingValidatorContext` and `textProviderFactory`. Do not carry the claim into the rewritten page. + +Grep for inbound links and repoint them: + +```bash +cd ~/Projects/Apache/struts-site +grep -rn "pure-java-script-client-side-validation" source/ +``` + +- [ ] **Step 3: Mark the example page deprecated** + +Add a deprecation banner at the top of `client-validation-example.md` pointing at the html5 theme. + +- [ ] **Step 4: Note the deprecated attribute** + +In `tag-developers/form-tag.md`, mark `validate` deprecated since 7.4.0, removed in 8.0.0. + +- [ ] **Step 5: Leave the html5 theme's "since" version alone** + +`tag-developers/html5-theme.md` says "Available since Struts 7.2.0". **That is correct** — `git tag --contains e24d2f2d3` returns `STRUTS_7_2_0`. WW-5444's fix version of 7.2.1 is the wrong record. Do not "fix" the docs to match Jira; the Jira ticket is what needs correcting. + +- [ ] **Step 6: Commit** + +```bash +cd ~/Projects/Apache/struts-site +git checkout -b docs/html5-constraint-validation +git add source/core-developers/client-side-validation.md \ + source/core-developers/client-validation-example.md \ + source/tag-developers/form-tag.md +git rm source/core-developers/pure-java-script-client-side-validation.md +git diff --cached --name-only +git commit -m "docs: document html5 constraint validation, deprecate the JS validator + +Rewrites the client-side validation page around the html5 theme's constraint +attributes and removes the pure-JavaScript page. + +Drops that page's claim that client-side messages are not internationalized: +ValidatorSupport.getMessage resolves through DelegatingValidatorContext and +textProviderFactory, so they always were. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +## Follow-ups (file after this plan lands, do not do them here) + +- **Temporal `min`/`max`.** `DateRangeFieldValidator` currently emits nothing. Needs per-control ISO formatting (`date` → `yyyy-MM-dd`, `month` → `yyyy-MM`, `week` → `yyyy-'W'ww`, `time` → `HH:mm`). `HtmlControlType.supportsRange()` already covers the temporal types, so no enum change is needed. +- **WW-5444's fix version** says 7.2.1; the html5 theme actually shipped in 7.2.0. Correct the ticket. +- **The regex allowlist will be too strict for some real applications.** Expect tuning after the first release; the swappable provider is the escape hatch in the meantime. +- **WW-5696** — the 8.0.0 removal. Already filed, out of scope here. diff --git a/docs/superpowers/specs/2026-08-24-html5-constraint-validation-design.md b/docs/superpowers/specs/2026-08-24-html5-constraint-validation-design.md new file mode 100644 index 0000000000..1de76384e0 --- /dev/null +++ b/docs/superpowers/specs/2026-08-24-html5-constraint-validation-design.md @@ -0,0 +1,422 @@ +# HTML5 constraint validation, and retiring the JavaScript client-side validator + +**Tickets:** not yet filed — three proposed, see "Ticket structure" +**Supersedes:** [WW-2975](https://issues.apache.org/jira/browse/WW-2975) (to be closed Won't Fix) +**Target:** 7.4.0 (deprecate + new feature), 8.0.0 (remove) +**Date:** 2026-08-24 +**Status:** Design approved, pending implementation plan + +**Plan scope:** the implementation plan drawn from this spec covers the **7.4.0 work only** — the deprecation +and the new constraint feature. The 8.0.0 removal is specified here so the deprecation is written against a +known endpoint, but it is a separate ticket, a separate release and a separate plan. + +## Problem + +`xhtml/form-close-validate.ftl` generates a `validateForm_()` function by iterating +`attributes.tagNames`. That list is seeded by `Form.evaluateExtraParams` (`Form.java:207`) and appended to +by `UIBean.evaluateParams` (`UIBean.java:834`) — but **only** when `findAncestor(Form.class)` finds the form +on the component stack. + +Any input that reaches the form's markup by another route is therefore invisible to client-side validation: +raw HTML, a custom tag, an included fragment, or a component handed pre-rendered field markup. The generated +function is then an empty shell: + +```js +function validateForm_doubleValidationAction() { + ... + var errors = false; + var continueValidation = true; + + return !errors; +} +``` + +This is WW-2975, reported 2009-01-30 against 2.1.6 and reproduced unchanged on `main` (7.4.0-SNAPSHOT) on +2026-08-24. The reproduction renders a form whose action declares a `double` validator for +`myUpDownSelectTag`: a nested `` produces the `if (form.elements['myUpDownSelectTag'])` +block, while an identical raw `` produces nothing at all. + +The ticket's second complaint is also live, at LOW confidence (the repo has **no** JavaScript test +infrastructure — no `package.json`, nothing). `validation.js` `addErrorXHTML` walks `row.parentNode` up +until it finds a `TR`. A field with no `TR` ancestor sends that walk off the top of the document, and the +handler is `catch (err) { alert(err) }` — so the user gets a raw JavaScript error instead of a validation +message. + +### Why this is not being fixed in place + +The feature is legacy on every axis. It exists only in `xhtml` (the default theme) and `css_xhtml` +(`parent = xhtml`); the `html5` theme has `parent = simple` and never had it. It supports eight validators. +It reports errors by inserting `` elements, so it only works with the table layout. It has zero test +coverage beyond four golden files. + +The successor is native HTML5 constraint validation, emitted per field. That dissolves the root cause rather +than patching it: with constraints riding on each ``, there is no central field list for a foreign +field to be missing from. + +## Goals + +- Emit HTML5 constraint attributes from the `html5` theme, derived from the action's validators. +- Never emit a constraint the browser would enforce more strictly than the server does. +- Deprecate the JavaScript client-side validator in 7.4.0, remove it in 8.0.0. +- Change nothing about existing renderings on upgrade to 7.4.0. + +## Non-goals + +- Fixing the `tagNames` scope or `addError` fragility in the deprecated path. It is documented as a known + limitation and deleted in 8.0.0. +- Shipping any JavaScript. Struts emits messages as `data-*` attributes and stops there. +- Changing an input's `type` attribute. See "The never-change-the-type rule". +- Constraint support in `xhtml`, `css_xhtml` or `simple`. This is an `html5` theme feature. +- Touching `struts.ui.theme`'s default. `xhtml` remains the default theme in 7.4.0. + +## Approach + +Derive constraints in Java from the action's validators, behind a swappable container bean, and render them +from a single new `html5` template include. + +### The never-change-the-type rule + +`min`/`max` are inert on `type="text"`; they only apply to `number`, `range` and the temporal types. Emitting +them therefore means switching the input to `type="number"` — and that **is** a false rejection: a browser +`type="number"` refuses `1234,50`, which Struts' locale-aware conversion accepts in a comma-decimal locale. +The same argument rules out `type="email"` and `type="url"`, whose browser regexes differ from +`EmailValidator` and `URLValidator`. + +So: **Struts never sets or changes `type`. It only adds constraints that are safe for whatever type is +already there.** A developer who writes `type="number"` has accepted that widget's semantics, and `min`/`max` +become pure additions. + +### Mapping table + +| Validator | Emits | Condition | +|---|---|---| +| `required` | `required` | only on `RADIO` or `FILE` — everywhere else the control can submit a value the server accepts as non-missing (empty string, an unticked checkbox's substituted `"false"`, a select's empty-valued header option), so `required` there would false-reject | +| `requiredstring` | `required` | on any text-entry or `TEXTAREA` control — server is stricter on whitespace-only input, which is safe | +| `stringlength` | `minlength` / `maxlength` | only when the control supports length **and** `trim="false"` | +| `regex` | `pattern` | only when the control supports pattern, `caseSensitive="true"`, `trim="false"`, the regex is ECMAScript-safe, and the validator is not `EmailValidator` or `CreditCardValidator` (see below) | +| `int`, `short`, `long` | `min` / `max` | only when the control is already numeric; `min` additionally requires an integral bound (always true for these types) | +| `double` | `min` / `max` | only when the control is already numeric; `min` is omitted when `minInclusive` is fractional — see "The integral-min guard" below | +| `date` | `min` / `max` | only when the control is already temporal — **deferred, see below** | +| `email`, `url` | — | never; browser regexes diverge from Struts' | +| `creditcard` | — | never; `CreditCardValidator` strips whitespace before matching, which a browser `pattern` cannot replicate | +| `fieldexpression`, `expression`, `conversion`, visitor | — | no safe mapping | + +`RegexFieldValidator.trim` defaults to `true`, and the server matches the *trimmed* value while `pattern` +matches the raw one, so a case-sensitive, portable regex like `[a-z]+` would still need `trim="false"` to be +safe: `"abc "` would pass server-side and be blocked by the browser otherwise. + +### The integral-min guard + +For `input type="number"` and `type="range"`, the HTML step base is the `min` attribute's value when +present (otherwise 0), and the default `step` is `1`. A `double` validator with a fractional +`minInclusive` (say `6000.1`) would therefore render `min="6000.1"`, making the only valid values +6000.1, 6001.1, 6002.1 … — the browser would reject `6002`, which `DoubleRangeFieldValidator` accepts +server-side. `max` does not participate in the step base, so it is unaffected and always emitted when +present. `min` is emitted only when the bound is integral; the shipped `Integer`/`Short`/`Long` range +validators always satisfy this, so the guard only ever suppresses `min` for a fractional `double` bound +(or a custom `RangeValidatorSupport` subclass parameterised with a fractional type). + +`stringlength` with `trim="true"` is excluded because the server measures the *trimmed* value: a +`maxlength` derived from it would stop the user typing input the server would have accepted. + +`RegexFieldValidator` uses `matcher.matches()`, so it is fully anchored and matches HTML5 `pattern` +semantics. The divergence is syntactic, not positional. + +**Temporal ranges are deferred out of the first implementation.** `DateRangeFieldValidator` extends +`RangeValidatorSupport`, so it reaches the range branch and is rejected there for not being numeric — +it emits nothing. Honouring it needs per-control ISO formatting (`date` wants `yyyy-MM-dd`, `month` +`yyyy-MM`, `week` `yyyy-'W'ww`, `time` `HH:mm`), which is guesswork worth doing deliberately rather than +alongside everything else. `HtmlControlType.supportsRange()` already covers the temporal types, so the enum +needs no change when it lands. Filed as a follow-up on the implementation plan. + +### ECMAScript-safe regex detection + +This is the least-solved part of the design and the strongest argument for the provider being swappable. + +A denylist of Java-only constructs (`\p{Alpha}`, possessive quantifiers, `\A`/`\z`, lookbehind) violates the +never-false-reject rule the first time it misses one: a missed construct becomes a `pattern` the browser +interprets differently. So detection is an **allowlist** — literals, `\d` and `\w` and their negations, +character classes without POSIX or Unicode property syntax, grouping, alternation, anchors, and bounded +quantifiers. Anything outside it emits no `pattern`. + +**`\s` and `\S` are excluded, and this is the rule's first real test.** Java's `\s` is ASCII-only by default +(`[ \t\n\x0B\f\r]`); ECMAScript's is always the wider Unicode set — NBSP, ``, `
`, the ` ` +range. For a rule as ordinary as `^\S+$`, a value containing NBSP satisfies Java's `\S` and fails the +browser's, so the server would accept input the browser silently refuses to submit. `\d` and `\w` are safe: +both engines are ASCII-only for those by default, and JavaScript never widens them. An earlier draft of this +spec listed `\s` among the safe escapes — that was wrong, and the governing rule outranks its own example +list. + +This is conservative to the point that some legitimate regexes will silently get no client-side check. That +is the correct failure direction under the agreed rule, and it is the piece most likely to need tuning after +real use. + +## Components + +### `HtmlControlType` (new enum) + +The provider's real question is not "what string is in `type`" but "which constraint attributes are legal on +this control". `textarea` and `select` have no `type` attribute at all yet do accept `required`, so the enum +models the *control*, not the attribute — hence `HtmlControlType`, not `HtmlInputType`. + +```java +public enum HtmlControlType { + TEXT, SEARCH, TEL, PASSWORD, EMAIL, URL, + NUMBER, RANGE, + DATE, MONTH, WEEK, TIME, DATETIME_LOCAL, + CHECKBOX, RADIO, FILE, HIDDEN, SELECT, + TEXTAREA, + OTHER; + + public static HtmlControlType from(String type); + public boolean supportsPattern(); // text-entry only + public boolean supportsLength(); // text-entry + TEXTAREA + public boolean supportsRange(); // numeric + temporal +} +``` + +`from()` must never throw. `type` is an OGNL-evaluated tag attribute, so at runtime it can be any string — a +typo, or an input type newer than this enum. Unknown and `null` normalise to `OTHER`, which supports nothing, +so an unrecognised type degrades to emitting no constraints. The conservative default falls out for free. + +The enum appears in the public signature of an extension point. Adding members later stays binary- and +source-compatible for callers, but can make an exhaustive `switch` in a custom provider non-exhaustive. That +belongs in the release notes. + +### `HtmlConstraintProvider` (new interface) and `StrutsHtmlConstraintProvider` + +```java +public interface HtmlConstraintProvider { + Map constraintsFor(List validators, HtmlControlType control, Object action); +} +``` + +The third parameter is required, not incidental: `Validator.getMessage(Object)` needs the action instance +to resolve i18n text, and the `data-msg-*` attributes come from it. It may be null, in which case no +messages are emitted. + +Named per the project convention of `Struts*` for default implementations rather than `Default*`. Registered +**once** in `struts-beans.xml` as `type="...HtmlConstraintProvider" name="struts"`, following the +`UrlRenderer` model — a bean registered under two types builds two instances, which is not wanted here. + +The default implementation encodes the mapping table. Because the agreed policy is deliberately restrictive, +the swappable bean is how applications that want `type="email"` or best-effort `pattern` get served. + +### `StrutsConstants.STRUTS_UI_HTML5_CONSTRAINTS` + +`"struts.ui.html5.constraints"`, following the `struts.ui.checkbox.submitUnchecked` naming precedent. +Defaults to `false` in `default.properties` for 7.4.0 and `true` for 8.0.0. + +Off by default matters: the `html5` theme shipped in 7.2.x, so emitting `required` on upgrade would start +blocking submits on forms that render unchanged today. The 8.0.0 flip lands in a major with a migration entry. + +### Resolving the control type + +`attributes.type` is set by `TextField.evaluateExtraParams` (`TextField.java:91`) and by nothing else on the +input path — `TextArea`, `Select`, `Checkbox`, `File`, `Hidden` and `Radio` have no `type` attribute at all. +So the control type cannot be read from the attribute map alone; it needs a component-level hook: + +```java +protected HtmlControlType getControlType(); // UIBean, returns OTHER +``` + +Overridden in six places, which is all that is needed: + +| Component | Returns | +|---|---| +| `UIBean` (base) | `OTHER` — supports nothing, so unknown controls emit no constraints | +| `TextField` | `HtmlControlType.from(getAttributes().get("type"))`, defaulting to `TEXT` when absent, matching `text.ftl`'s `attributes.type!"text"` | +| `Password` | `PASSWORD` — it extends `TextField` but its template hardcodes the type | +| `TextArea` | `TEXTAREA` | +| `Select` | `SELECT` | +| `Radio` | `RADIO` | +| `File` | `FILE` | + +`Radio` and `File` are overridden even though they support no pattern, length or range constraint — `RADIO` +and `FILE` are the only two controls where an unselected/empty submission omits the parameter entirely +rather than submitting an empty value, so they are the only two where `required` agrees with the server (see +the mapping table above). Without the override both fall through to `OTHER`, which made `required` dead code +for them — this was caught and fixed as part of implementation triage. + +`Checkbox` and `Hidden` deliberately get no override and fall through to `OTHER`, which emits nothing. This +is not an oversight: `CheckboxInterceptor` substitutes `"false"` for an unticked box, so the field is never +missing server-side and a browser `required` there would false-reject. `ComboBox` extends `TextField` and +correctly inherits `TEXT`. + +### `UIBean` hook + +`UIBean.evaluateParams` resolves `final Form form = (Form) findAncestor(Form.class)` at `UIBean.java:824` +and appends to `tagNames` just below it. **The hook cannot go there.** `evaluateExtraParams()` is the *last* +statement of `evaluateParams()` (`UIBean.java:905`), and that is where `TextField` sets `attributes.type` — +so at the `tagNames` block the control type is not yet resolved and every text field would look like `OTHER`. + +The hook therefore goes at the very end of `evaluateParams()`, after the `evaluateExtraParams()` call. The +`form` local is declared at method scope and is still in scope there (the tooltip block below it already +uses it). When the constant is on and a form was found: + +``` +form.getFieldValidators(translatedName) + → provider.constraintsFor(validators, getControlType()) + → addParameter("constraints", map) +``` + +Gating the *computation* on the constant keeps the cost at zero when off. Themes that do not render +`attributes.constraints` simply ignore it. + +### `Form.getFieldValidators(String)` (new) + +`Form.getValidators(String)` re-runs the action-mapping lookup and +`actionValidatorManager.getValidators(actionClass, actionName, methodName)` on every call, so a 20-field form +would do 20 full lookups. `getFieldValidators` resolves the action's validator list **once**, memoises it on +private fields of the `Form` component, and filters by field name per call. + +Memoise on component fields, not on the attributes map. A `Form` component is constructed per render by +`ComponentTagSupport`, so a field is naturally request-scoped, and the attributes map is exposed to +templates and should not carry private bookkeeping. + +The existing `getValidators(String)` stays untouched for the deprecated `form-close-validate.ftl` and is +deleted with it in 8.0.0. + +### `html5/constraints.ftl` (new), included from `common-attributes.ftl` + +```freemarker +<#if attributes.constraints??><#list attributes.constraints as k, v> ${k}="${v}"<#rt/> +``` + +Including it from `common-attributes.ftl` means every `html5` input picks it up without per-template edits. + +`constraintsFor` returns the full set of attributes to render, not only constraints — messages ride the same +map as `data-msg-` entries: `data-msg-required`, `data-msg-stringlength`, `data-msg-regex`. +The text comes from `validator.getMessage(action)`, which resolves through `DelegatingValidatorContext` and +`textProviderFactory`, so it is properly i18n'd. + +A `data-msg-*` entry is emitted for **every** validator carrying a message, including those that produce no +constraint. An `email` validator therefore contributes `data-msg-email` and nothing else — which is exactly +the case where an application most needs the message, since Struts could not express the rule natively. +Struts ships nothing that consumes these attributes. + +### Deliberately unchanged + +`requiredLabel` keeps meaning "draw a `*` next to the label". It never produces a `required` attribute — only +a `required` *validator* does. Conflating the two is the most likely regression in this work. + +## Deprecation and removal + +`validate="true"` is not one feature but four edits to the rendered form: + +- `xhtml/form-validate.ftl` injects the `validation.js` `