This is an automated email from the ASF dual-hosted git repository.
lukaszlenart pushed a commit to branch support/struts-6-x-x
in repository https://gitbox.apache.org/repos/asf/struts.git
The following commit(s) were added to refs/heads/support/struts-6-x-x by this
push:
new 87a53f726 WW-5836 fix(core): bound integer digits in number tag and
message arguments (#2096)
87a53f726 is described below
commit 87a53f7268bba1b13b67913cf3de74a5dfa494af
Author: Lukasz Lenart <[email protected]>
AuthorDate: Sat Nov 7 09:10:17 2026 +0100
WW-5836 fix(core): bound integer digits in number tag and message arguments
(#2096)
WW-5828 bounded the integer digits StringConverter emits for a BigDecimal
and falls back to BigDecimal.toString() past 340. The number tag and
MessageFormat arguments format a BigDecimal without StringConverter, so
neither applied the bound.
The bound moves to BigDecimalBounds and is shared by StringConverter, the
number tag and message argument formatting.
The number tag falls back to toString() past the bound. Its
maximumIntegerDigits attribute is not a substitute: DecimalFormat applies
it by dropping leading digits.
For message arguments the argument itself cannot be replaced, because a
typed element such as {0,number} rejects a String. When an argument is
past the bound, BoundedMessageFormat formats through a clone whose
elements delegate to their original formats and render the over-bound
value with toString(). A ChoiceFormat element whose branch contains {n}
is re-parsed by MessageFormat only when the element's format is a
ChoiceFormat, so the wrapper repeats that step with the bound applied to
the nested pattern. A call with no argument past the bound is formatted
by MessageFormat unchanged.
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../opensymphony/xwork2/DefaultTextProvider.java | 3 +-
.../xwork2/conversion/impl/StringConverter.java | 18 +---
.../xwork2/util/AbstractLocalizedTextProvider.java | 3 +-
.../java/org/apache/struts2/components/Number.java | 3 +-
.../org/apache/struts2/util/BigDecimalBounds.java | 51 ++++++++++
.../apache/struts2/util/BoundedMessageFormat.java | 111 +++++++++++++++++++++
.../xwork2/DefaultTextProviderTest.java | 10 ++
.../util/StrutsLocalizedTextProviderTest.java | 46 +++++++++
.../apache/struts2/views/jsp/NumberTagTest.java | 57 +++++++++++
9 files changed, 283 insertions(+), 19 deletions(-)
diff --git
a/core/src/main/java/com/opensymphony/xwork2/DefaultTextProvider.java
b/core/src/main/java/com/opensymphony/xwork2/DefaultTextProvider.java
index 5fe207ecd..e34408f1b 100644
--- a/core/src/main/java/com/opensymphony/xwork2/DefaultTextProvider.java
+++ b/core/src/main/java/com/opensymphony/xwork2/DefaultTextProvider.java
@@ -20,6 +20,7 @@ package com.opensymphony.xwork2;
import com.opensymphony.xwork2.inject.Inject;
import com.opensymphony.xwork2.util.ValueStack;
+import org.apache.struts2.util.BoundedMessageFormat;
import java.io.Serializable;
import java.text.MessageFormat;
@@ -101,7 +102,7 @@ public class DefaultTextProvider implements TextProvider,
Serializable, Unchaina
params = EMPTY_ARGS;
}
- return format.format(params);
+ return BoundedMessageFormat.format(format, params);
}
return text;
}
diff --git
a/core/src/main/java/com/opensymphony/xwork2/conversion/impl/StringConverter.java
b/core/src/main/java/com/opensymphony/xwork2/conversion/impl/StringConverter.java
index 8d7fc8a1b..03fee7211 100644
---
a/core/src/main/java/com/opensymphony/xwork2/conversion/impl/StringConverter.java
+++
b/core/src/main/java/com/opensymphony/xwork2/conversion/impl/StringConverter.java
@@ -19,6 +19,7 @@
package com.opensymphony.xwork2.conversion.impl;
import org.apache.commons.lang3.StringUtils;
+import org.apache.struts2.util.BigDecimalBounds;
import java.lang.reflect.Array;
import java.lang.reflect.Member;
@@ -47,17 +48,6 @@ public class StringConverter extends DefaultTypeConverter {
*/
private static final int MAX_FRACTION_DIGITS = 340;
- /**
- * Upper bound on the number of integer digits emitted when formatting a
number.
- * <p>
- * Covers every {@code double} value in full - the widest is {@link
Double#MAX_VALUE}
- * at 309 integer digits. A {@link BigDecimal} wider than this is rendered
by
- * {@link BigDecimal#toString()} instead, so the length of the output
follows its
- * precision rather than its scale.
- */
- private static final int MAX_INTEGER_DIGITS = 340;
-
-
@Override
public Object convertValue(Map<String, Object> context, Object target,
Member member, String propertyName, Object value, Class toType) {
String result;
@@ -102,7 +92,7 @@ public class StringConverter extends DefaultTypeConverter {
}
protected String convertToString(Locale locale, Object value) {
- if (value instanceof BigDecimal && integerDigits((BigDecimal) value) >
MAX_INTEGER_DIGITS) {
+ if (BigDecimalBounds.exceedsMaxIntegerDigits(value)) {
return value.toString();
}
if (Number.class.isInstance(value)) {
@@ -122,8 +112,4 @@ public class StringConverter extends DefaultTypeConverter {
}
}
- private static long integerDigits(BigDecimal value) {
- return (long) value.precision() - value.scale();
- }
-
}
diff --git
a/core/src/main/java/com/opensymphony/xwork2/util/AbstractLocalizedTextProvider.java
b/core/src/main/java/com/opensymphony/xwork2/util/AbstractLocalizedTextProvider.java
index 6571529dc..1f67fc17a 100644
---
a/core/src/main/java/com/opensymphony/xwork2/util/AbstractLocalizedTextProvider.java
+++
b/core/src/main/java/com/opensymphony/xwork2/util/AbstractLocalizedTextProvider.java
@@ -30,6 +30,7 @@ import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.message.ParameterizedMessage;
import org.apache.struts2.StrutsConstants;
+import org.apache.struts2.util.BoundedMessageFormat;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
@@ -457,7 +458,7 @@ abstract class AbstractLocalizedTextProvider implements
LocalizedTextProvider {
}
protected String formatWithNullDetection(MessageFormat mf, Object[] args) {
- String message = mf.format(args);
+ String message = BoundedMessageFormat.format(mf, args);
if ("null".equals(message)) {
return null;
} else {
diff --git a/core/src/main/java/org/apache/struts2/components/Number.java
b/core/src/main/java/org/apache/struts2/components/Number.java
index 7ca45b14c..96c4ca3ff 100644
--- a/core/src/main/java/org/apache/struts2/components/Number.java
+++ b/core/src/main/java/org/apache/struts2/components/Number.java
@@ -20,6 +20,7 @@ package org.apache.struts2.components;
import com.opensymphony.xwork2.ActionContext;
import com.opensymphony.xwork2.util.ValueStack;
+import org.apache.struts2.util.BigDecimalBounds;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.LogManager;
import org.apache.struts2.views.annotations.StrutsTag;
@@ -148,7 +149,7 @@ public class Number extends ContextBean {
setNumberFormatParameters(format);
setRoundingMode(format);
- String msg = format.format(number);
+ String msg = BigDecimalBounds.exceedsMaxIntegerDigits(number) ?
number.toString() : format.format(number);
try {
if (getVar() == null) {
writer.write(msg);
diff --git a/core/src/main/java/org/apache/struts2/util/BigDecimalBounds.java
b/core/src/main/java/org/apache/struts2/util/BigDecimalBounds.java
new file mode 100644
index 000000000..85643c1a8
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/util/BigDecimalBounds.java
@@ -0,0 +1,51 @@
+/*
+ * 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.util;
+
+import java.math.BigDecimal;
+
+/**
+ * Bounds the number of integer digits emitted when a {@link BigDecimal} is
formatted, so the length of the output
+ * follows the precision of the value rather than its scale.
+ *
+ * @since 6.13.0
+ */
+public final class BigDecimalBounds {
+
+ /**
+ * Covers every {@code double} value in full - the widest is {@link
Double#MAX_VALUE} at 309 integer digits.
+ */
+ public static final int MAX_INTEGER_DIGITS = 340;
+
+ private BigDecimalBounds() {
+ }
+
+ /**
+ * @return {@code true} when the value is a {@link BigDecimal} that would
format to more than
+ * {@link #MAX_INTEGER_DIGITS} integer digits and should be rendered with
{@link BigDecimal#toString()} instead
+ */
+ public static boolean exceedsMaxIntegerDigits(Object value) {
+ // long: precision - scale overflows int at the edge of the scale range
+ if (!(value instanceof BigDecimal)) {
+ return false;
+ }
+ BigDecimal decimal = (BigDecimal) value;
+ return (long) decimal.precision() - decimal.scale() >
MAX_INTEGER_DIGITS;
+ }
+}
diff --git
a/core/src/main/java/org/apache/struts2/util/BoundedMessageFormat.java
b/core/src/main/java/org/apache/struts2/util/BoundedMessageFormat.java
new file mode 100644
index 000000000..c1f9309a8
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/util/BoundedMessageFormat.java
@@ -0,0 +1,111 @@
+/*
+ * 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.util;
+
+import java.text.ChoiceFormat;
+import java.text.DateFormat;
+import java.text.FieldPosition;
+import java.text.Format;
+import java.text.MessageFormat;
+import java.text.NumberFormat;
+import java.text.ParsePosition;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.Locale;
+
+/**
+ * Formats message arguments with the {@link BigDecimalBounds} integer-digit
bound applied, leaving every call that
+ * carries no argument over the bound to {@link MessageFormat} untouched.
+ *
+ * @since 6.13.0
+ */
+public final class BoundedMessageFormat {
+
+ private BoundedMessageFormat() {
+ }
+
+ public static String format(MessageFormat messageFormat, Object[] args) {
+ if (args != null &&
Arrays.stream(args).anyMatch(BigDecimalBounds::exceedsMaxIntegerDigits)) {
+ return bound(messageFormat, args).format(args);
+ }
+ return messageFormat.format(args);
+ }
+
+ private static MessageFormat bound(MessageFormat messageFormat, Object[]
args) {
+ MessageFormat bounded = (MessageFormat) messageFormat.clone();
+ Format[] formats = bounded.getFormats();
+ for (int i = 0; i < formats.length; i++) {
+ bounded.setFormat(i, new ElementFormat(formats[i],
bounded.getLocale(), args));
+ }
+ return bounded;
+ }
+
+ /**
+ * Wraps one format element. {@link MessageFormat} re-parses a {@link
ChoiceFormat} result containing {@code {}
+ * only when the element's format is a {@link ChoiceFormat} itself, so
that step is repeated here with the bound
+ * applied to the nested pattern.
+ */
+ private static final class ElementFormat extends Format {
+
+ private static final long serialVersionUID = 1L;
+
+ private final transient Format original;
+ private final Locale locale;
+ private final transient Object[] args;
+
+ private ElementFormat(Format original, Locale locale, Object[] args) {
+ this.original = original;
+ this.locale = locale;
+ this.args = args;
+ }
+
+ @Override
+ public StringBuffer format(Object obj, StringBuffer toAppendTo,
FieldPosition pos) {
+ if (original instanceof ChoiceFormat) {
+ String selected = original.format(obj);
+ if (selected.indexOf('{') >= 0) {
+ return toAppendTo.append(BoundedMessageFormat.format(new
MessageFormat(selected, locale), args));
+ }
+ return toAppendTo.append(selected);
+ }
+ if (BigDecimalBounds.exceedsMaxIntegerDigits(obj)) {
+ return toAppendTo.append(obj);
+ }
+ if (original != null) {
+ return original.format(obj, toAppendTo, pos);
+ }
+ return formatAsMessageFormatDefault(obj, toAppendTo);
+ }
+
+ private StringBuffer formatAsMessageFormatDefault(Object obj,
StringBuffer toAppendTo) {
+ if (obj instanceof Number) {
+ return
toAppendTo.append(NumberFormat.getInstance(locale).format(obj));
+ }
+ if (obj instanceof Date) {
+ return
toAppendTo.append(DateFormat.getDateTimeInstance(DateFormat.SHORT,
DateFormat.SHORT, locale).format(obj));
+ }
+ return toAppendTo.append(obj);
+ }
+
+ @Override
+ public Object parseObject(String source, ParsePosition pos) {
+ return original == null ? null : original.parseObject(source, pos);
+ }
+ }
+}
diff --git
a/core/src/test/java/com/opensymphony/xwork2/DefaultTextProviderTest.java
b/core/src/test/java/com/opensymphony/xwork2/DefaultTextProviderTest.java
index 511e6a884..84f4eccf1 100644
--- a/core/src/test/java/com/opensymphony/xwork2/DefaultTextProviderTest.java
+++ b/core/src/test/java/com/opensymphony/xwork2/DefaultTextProviderTest.java
@@ -18,6 +18,8 @@
*/
package com.opensymphony.xwork2;
+import java.math.BigDecimal;
+import java.math.BigInteger;
import java.util.*;
/**
@@ -118,6 +120,14 @@ public class DefaultTextProviderTest extends XWorkTestCase
{
assertEquals("this is default Santa speaking loud",
tp.getText("not.in.bundle", "this is default {0} speaking {1}", args, null));
}
+ public void testBigDecimalArgumentIntegerDigitsAreBounded() {
+ List<Object> args = Collections.singletonList(new
BigDecimal(BigInteger.ONE, -340));
+
+ assertEquals("v=1E+340", tp.getText("not.in.bundle", "v={0}", args));
+ assertEquals("v=1E+340", tp.getText("not.in.bundle", "v={0,number}",
args));
+ assertEquals("1E+340 items", tp.getText("not.in.bundle",
"{0,choice,0#none|1#{0} items}", args));
+ }
+
public void testGetBundle() throws Exception {
assertNull(tp.getTexts()); // always returns null
diff --git
a/core/src/test/java/com/opensymphony/xwork2/util/StrutsLocalizedTextProviderTest.java
b/core/src/test/java/com/opensymphony/xwork2/util/StrutsLocalizedTextProviderTest.java
index bd7454934..a889ddf03 100644
---
a/core/src/test/java/com/opensymphony/xwork2/util/StrutsLocalizedTextProviderTest.java
+++
b/core/src/test/java/com/opensymphony/xwork2/util/StrutsLocalizedTextProviderTest.java
@@ -39,6 +39,8 @@ import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
+import java.math.BigDecimal;
+import java.math.BigInteger;
import java.text.DateFormat;
import java.text.MessageFormat;
import java.text.ParseException;
@@ -731,6 +733,50 @@ public class StrutsLocalizedTextProviderTest extends
XWorkTestCase {
assertTrue("bundlesMap should populate", provider.bundlesMapSize() >=
1);
}
+ public void testBigDecimalArgumentWithinBoundIsUnchanged() {
+ BigDecimal withinBound = new BigDecimal(BigInteger.ONE, -339);
+ Date date = new Date(0);
+ Object[] args = {withinBound, "text", date};
+ String pattern = "{0} {0,number} {0,number,#,##0.00} {1} {2}
{2,date,short} {0,choice,0#none|1#{0} items}";
+
+ assertEquals(new MessageFormat(pattern, Locale.US).format(args),
formatMessage(pattern, args));
+ }
+
+ public void testBigDecimalArgumentIntegerDigitsAreBounded() {
+ BigDecimal overBound = new BigDecimal(BigInteger.ONE, -340);
+
+ assertEquals("v=1E+340", formatMessage("v={0}", overBound));
+ assertEquals("v=1E+340", formatMessage("v={0,number}", overBound));
+ assertEquals("v=1E+340", formatMessage("v={0,number,currency}",
overBound));
+ assertEquals("v=1E+2147483647", formatMessage("v={0}", new
BigDecimal(BigInteger.ONE, Integer.MIN_VALUE + 1)));
+ }
+
+ public void testOtherArgumentsAreUnchangedWhenOneIsOverTheBound() {
+ BigDecimal overBound = new BigDecimal(BigInteger.ONE, -340);
+ Date date = new Date(0);
+ String pattern = "{1} {1,number,#,##0.00} {2} {3} {3,date,short} {4}";
+ Object[] args = {overBound, new BigDecimal("1234.5"), "text", date,
null};
+
+ assertEquals("1E+340 " + new MessageFormat(pattern,
Locale.US).format(args),
+ formatMessage("{0} " + pattern, args));
+ }
+
+ public void testBigDecimalArgumentInNestedChoiceIsBounded() {
+ BigDecimal overBound = new BigDecimal(BigInteger.ONE, -340);
+
+ assertEquals("1E+340 items", formatMessage("{0,choice,0#none|1#{0}
items}", overBound));
+ assertEquals("3 of 1E+340", formatMessage("{0,choice,0#none|1#{0} of
{1}}", 3, overBound));
+ String twoLevels = "{0,choice,0#none|1#{1,choice,0'#'zero'|'1'#'{1}
deep}}";
+ assertEquals("7 deep", new MessageFormat(twoLevels,
Locale.US).format(new Object[]{1, 7}));
+ assertEquals("1E+340 deep", formatMessage(twoLevels, 1, overBound));
+ assertEquals("none", formatMessage("{0,choice,0#none|1#{1}}", 0,
overBound));
+ }
+
+ private String formatMessage(String pattern, Object... args) {
+ ValueStack stack = ActionContext.getContext().getValueStack();
+ return
localizedTextProvider.findText(StrutsLocalizedTextProviderTest.class,
"not.in.any.bundle", Locale.US, pattern, args, stack);
+ }
+
@Override
protected void setUp() throws Exception {
super.setUp();
diff --git a/core/src/test/java/org/apache/struts2/views/jsp/NumberTagTest.java
b/core/src/test/java/org/apache/struts2/views/jsp/NumberTagTest.java
index c41aabeaf..a4c86997c 100644
--- a/core/src/test/java/org/apache/struts2/views/jsp/NumberTagTest.java
+++ b/core/src/test/java/org/apache/struts2/views/jsp/NumberTagTest.java
@@ -21,6 +21,8 @@ package org.apache.struts2.views.jsp;
import com.opensymphony.xwork2.ActionContext;
import org.apache.struts2.TestAction;
+import java.math.BigDecimal;
+import java.math.BigInteger;
import java.math.RoundingMode;
import java.text.NumberFormat;
import java.util.Locale;
@@ -265,4 +267,59 @@ public class NumberTagTest extends AbstractTagTest {
strutsBodyTagsAreReflectionEqual(tag, freshTag));
}
+ public void testBigDecimalIntegerDigitsAreBounded() throws Exception {
+ // when the number of integer digits is within the bound
+ // then the value is rendered in full
+ BigDecimal withinBound = new BigDecimal(BigInteger.ONE, -339);
+ assertEquals(NumberFormat.getInstance(Locale.US).format(withinBound),
renderNumber(withinBound, null));
+
+ // when the number of integer digits exceeds the bound
+ // then the value is rendered in its compact form rather than padded
out to its scale
+ assertEquals("1E+340", renderNumber(new BigDecimal(BigInteger.ONE,
-340), null));
+
+ // when the scale sits at the edge of its range
+ // then the bound still applies
+ assertEquals("1E+2147483647", renderNumber(new
BigDecimal(BigInteger.ONE, Integer.MIN_VALUE + 1), null));
+ }
+
+ public void testBigDecimalCurrencyWithinBoundIsUnchanged() throws
Exception {
+ NumberFormat format = NumberFormat.getCurrencyInstance(Locale.US);
+ BigDecimal value = new BigDecimal(BigInteger.ONE, -339);
+
+ assertEquals(format.format(value), renderNumber(value, "currency"));
+ assertEquals("$1,234.50", renderNumber(new BigDecimal("1234.5"),
"currency"));
+ }
+
+ public void testBigDecimalCurrencyIntegerDigitsAreBounded() throws
Exception {
+ assertEquals("1E+340", renderNumber(new BigDecimal(BigInteger.ONE,
-340), "currency"));
+ }
+
+ private String renderNumber(BigDecimal value, String type) throws
Exception {
+ context =
ActionContext.of(context).withLocale(Locale.US).getContextMap();
+ stack.push(new AmountHolder(value));
+
+ NumberTag tag = new NumberTag();
+ tag.setPageContext(pageContext);
+ tag.setName("amount");
+ tag.setType(type);
+
+ writer.getBuffer().setLength(0);
+ tag.doStartTag();
+ tag.doEndTag();
+ stack.pop();
+ return writer.toString();
+ }
+
+ public static class AmountHolder {
+ private final BigDecimal amount;
+
+ public AmountHolder(BigDecimal amount) {
+ this.amount = amount;
+ }
+
+ public BigDecimal getAmount() {
+ return amount;
+ }
+ }
+
}