This is an automated email from the ASF dual-hosted git repository.
lukaszlenart pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/struts.git
The following commit(s) were added to refs/heads/main by this push:
new be683fb07 WW-5836 fix(core): bound integer digits in number tag and
message arguments (#2095)
be683fb07 is described below
commit be683fb07ea16559d7017d47abb55a0ae85874bd
Author: Lukasz Lenart <[email protected]>
AuthorDate: Sat Nov 7 09:10:01 2026 +0100
WW-5836 fix(core): bound integer digits in number tag and message arguments
(#2095)
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]>
---
.../java/org/apache/struts2/components/Number.java | 3 +-
.../struts2/conversion/impl/StringConverter.java | 20 +---
.../text/AbstractLocalizedTextProvider.java | 2 +-
.../apache/struts2/text/BoundedMessageFormat.java | 111 +++++++++++++++++++++
.../apache/struts2/text/DefaultTextProvider.java | 2 +-
.../org/apache/struts2/util/BigDecimalBounds.java | 47 +++++++++
.../struts2/text/DefaultTextProviderTest.java | 10 ++
.../text/StrutsLocalizedTextProviderTest.java | 46 +++++++++
.../apache/struts2/views/jsp/NumberTagTest.java | 57 +++++++++++
9 files changed, 278 insertions(+), 20 deletions(-)
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 db101faff..545be6110 100644
--- a/core/src/main/java/org/apache/struts2/components/Number.java
+++ b/core/src/main/java/org/apache/struts2/components/Number.java
@@ -19,6 +19,7 @@
package org.apache.struts2.components;
import org.apache.struts2.ActionContext;
+import org.apache.struts2.util.BigDecimalBounds;
import org.apache.struts2.util.ValueStack;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -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/conversion/impl/StringConverter.java
b/core/src/main/java/org/apache/struts2/conversion/impl/StringConverter.java
index 0260e8b47..2ae2d28a1 100644
--- a/core/src/main/java/org/apache/struts2/conversion/impl/StringConverter.java
+++ b/core/src/main/java/org/apache/struts2/conversion/impl/StringConverter.java
@@ -19,6 +19,7 @@
package org.apache.struts2.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,8 +92,8 @@ public class StringConverter extends DefaultTypeConverter {
}
protected String convertToString(Locale locale, Object value) {
- if (value instanceof BigDecimal decimal && integerDigits(decimal) >
MAX_INTEGER_DIGITS) {
- return decimal.toString();
+ if (BigDecimalBounds.exceedsMaxIntegerDigits(value)) {
+ return value.toString();
}
if (value instanceof Number) {
NumberFormat format = NumberFormat.getNumberInstance(locale);
@@ -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/org/apache/struts2/text/AbstractLocalizedTextProvider.java
b/core/src/main/java/org/apache/struts2/text/AbstractLocalizedTextProvider.java
index 9d8b7b851..cbc02849d 100644
---
a/core/src/main/java/org/apache/struts2/text/AbstractLocalizedTextProvider.java
+++
b/core/src/main/java/org/apache/struts2/text/AbstractLocalizedTextProvider.java
@@ -421,7 +421,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/text/BoundedMessageFormat.java
b/core/src/main/java/org/apache/struts2/text/BoundedMessageFormat.java
new file mode 100644
index 000000000..037883e60
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/text/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.text;
+
+import org.apache.struts2.util.BigDecimalBounds;
+
+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.
+ */
+final class BoundedMessageFormat {
+
+ private BoundedMessageFormat() {
+ }
+
+ 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 choice) {
+ String selected = choice.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/main/java/org/apache/struts2/text/DefaultTextProvider.java
b/core/src/main/java/org/apache/struts2/text/DefaultTextProvider.java
index 4518f4de0..c502efeca 100644
--- a/core/src/main/java/org/apache/struts2/text/DefaultTextProvider.java
+++ b/core/src/main/java/org/apache/struts2/text/DefaultTextProvider.java
@@ -107,7 +107,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/org/apache/struts2/util/BigDecimalBounds.java
b/core/src/main/java/org/apache/struts2/util/BigDecimalBounds.java
new file mode 100644
index 000000000..556d2f1e8
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/util/BigDecimalBounds.java
@@ -0,0 +1,47 @@
+/*
+ * 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 7.5.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
+ return value instanceof BigDecimal decimal && (long)
decimal.precision() - decimal.scale() > MAX_INTEGER_DIGITS;
+ }
+}
diff --git
a/core/src/test/java/org/apache/struts2/text/DefaultTextProviderTest.java
b/core/src/test/java/org/apache/struts2/text/DefaultTextProviderTest.java
index a9ba860ee..2079f9afc 100644
--- a/core/src/test/java/org/apache/struts2/text/DefaultTextProviderTest.java
+++ b/core/src/test/java/org/apache/struts2/text/DefaultTextProviderTest.java
@@ -21,6 +21,8 @@ package org.apache.struts2.text;
import org.apache.struts2.ActionContext;
import org.apache.struts2.XWorkTestCase;
+import java.math.BigDecimal;
+import java.math.BigInteger;
import java.util.*;
public class DefaultTextProviderTest extends XWorkTestCase {
@@ -116,6 +118,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 = List.of(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() {
assertNull(tp.getTexts()); // always returns null
diff --git
a/core/src/test/java/org/apache/struts2/text/StrutsLocalizedTextProviderTest.java
b/core/src/test/java/org/apache/struts2/text/StrutsLocalizedTextProviderTest.java
index a63c331b2..12abed31f 100644
---
a/core/src/test/java/org/apache/struts2/text/StrutsLocalizedTextProviderTest.java
+++
b/core/src/test/java/org/apache/struts2/text/StrutsLocalizedTextProviderTest.java
@@ -35,6 +35,8 @@ import org.apache.struts2.test.TestBean2;
import org.apache.struts2.util.Bar;
import org.apache.struts2.util.ValueStack;
+import java.math.BigDecimal;
+import java.math.BigInteger;
import java.text.DateFormat;
import java.text.MessageFormat;
import java.text.ParseException;
@@ -913,6 +915,50 @@ public class StrutsLocalizedTextProviderTest extends
XWorkTestCase {
assertTrue("class hierarchy cache should populate",
provider.classHierarchyCacheSize() >= 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 6a69eaffd..87819c012 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 org.apache.struts2.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;
+ }
+ }
+
}