This is an automated email from the ASF dual-hosted git repository.
garydgregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-lang.git
The following commit(s) were added to refs/heads/master by this push:
new fd6361792 FastDateParser.parse throws undeclared
IllegalArgumentException, NullPointerException, and IllegalStateException on
crafted date strings (f010).
fd6361792 is described below
commit fd6361792d1e021462215386d69dd3e0d1b59229
Author: Gary Gregory <[email protected]>
AuthorDate: Sat Sep 5 08:31:26 2026 -0400
FastDateParser.parse throws undeclared IllegalArgumentException,
NullPointerException, and IllegalStateException on crafted date strings
(f010).
Three independent vectors past the declared ParseException.
---
src/changes/changes.xml | 1 +
.../apache/commons/lang3/time/FastDateParser.java | 52 +++++--
.../commons/lang3/time/FastDateParserTest.java | 163 ++++++++++++++++++---
3 files changed, 181 insertions(+), 35 deletions(-)
diff --git a/src/changes/changes.xml b/src/changes/changes.xml
index e2e06396f..38bccebe6 100644
--- a/src/changes/changes.xml
+++ b/src/changes/changes.xml
@@ -259,6 +259,7 @@ java.lang.NullPointerException: Cannot invoke
<action type="fix" dev="ggregory" due-to="Gary
Gregory">LocaleUtils static caches no longer grows on invalid input to
LocaleUtils.countriesByLanguage(String) (f007).</action>
<action type="fix" dev="ggregory" due-to="Gary
Gregory">ExtendedMessageFormat.applyPattern() is quadratic: full
pattern.toCharArray() per token (f008).</action>
<action type="fix" dev="ggregory" due-to="Gary
Gregory">WordUtils.wrap(wrapLongWords=false, the 2-arg default) copies the
entire remaining string every iteration. (f009).</action>
+ <action type="fix" dev="ggregory" due-to="Gary
Gregory">FastDateParser.parse throws undeclared IllegalArgumentException,
NullPointerException, and IllegalStateException on crafted date strings.
(f010).</action>
<!-- ADD -->
<action type="add" dev="ggregory" due-to="Gary
Gregory">Add JavaVersion.JAVA_27.</action>
<action type="add" dev="ggregory" due-to="Gary
Gregory">Add SystemUtils.IS_JAVA_27.</action>
diff --git a/src/main/java/org/apache/commons/lang3/time/FastDateParser.java
b/src/main/java/org/apache/commons/lang3/time/FastDateParser.java
index eadad25a1..09cd4409f 100644
--- a/src/main/java/org/apache/commons/lang3/time/FastDateParser.java
+++ b/src/main/java/org/apache/commons/lang3/time/FastDateParser.java
@@ -24,7 +24,6 @@
import java.text.ParsePosition;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
-import java.util.Arrays;
import java.util.Calendar;
import java.util.Comparator;
import java.util.Date;
@@ -120,12 +119,28 @@ private static final class CaseInsensitiveTextStrategy
extends PatternStrategy {
*/
@Override
void setCalendar(final FastDateParser parser, final Calendar calendar,
final String value) {
- final String lowerCase = value.toLowerCase(locale);
+ String lowerCase = value.toLowerCase(locale);
Integer iVal = lKeyValues.get(lowerCase);
if (iVal == null) {
// match missing the optional trailing period
iVal = lKeyValues.get(lowerCase + '.');
}
+ if (iVal == null) {
+ // The regex matches case-insensitively via Unicode case
folding ("(?iu)"), which is a
+ // wider equivalence than the toLowerCase(locale) fold used to
build the key map; retry
+ // with the root-locale fold so that, for example, ASCII input
under locales with
+ // special casing rules still resolves to the same key.
+ lowerCase = value.toLowerCase(Locale.ROOT);
+ iVal = lKeyValues.get(lowerCase);
+ if (iVal == null) {
+ iVal = lKeyValues.get(lowerCase + '.');
+ }
+ }
+ if (iVal == null) {
+ // Converted to a parse failure by PatternStrategy.parse
instead of surfacing as an
+ // undeclared NullPointerException.
+ throw new IllegalArgumentException("Invalid display name for
field " + field + ": '" + value + "'");
+ }
// LANG-1669: Mimic fix done in OpenJDK 17 to resolve issue with
parsing newly supported day periods added in OpenJDK 16
if (Calendar.AM_PM != this.field || iVal <= 1) {
calendar.set(field, iVal.intValue());
@@ -194,11 +209,11 @@ public String toString() {
private static final class ISO8601TimeZoneStrategy extends PatternStrategy
{
// Z, +hh, -hh, +hhmm, -hhmm, +hh:mm or -hh:mm
- private static final Strategy ISO_8601_1_STRATEGY = new
ISO8601TimeZoneStrategy("(Z|(?:[+-]\\d{2}))");
+ private static final Strategy ISO_8601_1_STRATEGY = new
ISO8601TimeZoneStrategy("(Z|(?:[+-](?:2[0-3]|[01]\\d)))");
- private static final Strategy ISO_8601_2_STRATEGY = new
ISO8601TimeZoneStrategy("(Z|(?:[+-]\\d{2}\\d{2}))");
+ private static final Strategy ISO_8601_2_STRATEGY = new
ISO8601TimeZoneStrategy("(Z|(?:[+-](?:2[0-3]|[01]\\d)[0-5]\\d))");
- private static final Strategy ISO_8601_3_STRATEGY = new
ISO8601TimeZoneStrategy("(Z|(?:[+-]\\d{2}(?::)\\d{2}))");
+ private static final Strategy ISO_8601_3_STRATEGY = new
ISO8601TimeZoneStrategy("(Z|(?:[+-](?:2[0-3]|[01]\\d)(?::)[0-5]\\d))");
/**
* Factory method for ISO8601TimeZoneStrategies.
@@ -359,8 +374,17 @@ boolean parse(final FastDateParser parser, final Calendar
calendar, final String
pos.setErrorIndex(pos.getIndex());
return false;
}
+ try {
+ setCalendar(parser, calendar, matcher.group(1));
+ } catch (final IllegalArgumentException e) {
+ // A matched field whose value cannot be interpreted (for
example an out-of-range GMT
+ // offset or a display name the key map cannot resolve) is a
parse failure, reported
+ // through the ParsePosition error index, not an undeclared
runtime exception:
+ // the public parse methods declare only ParseException.
+ pos.setErrorIndex(pos.getIndex());
+ return false;
+ }
pos.setIndex(pos.getIndex() + matcher.end(1));
- setCalendar(parser, calendar, matcher.group(1));
return true;
}
@@ -499,7 +523,7 @@ public String toString() {
}
}
- private static final String RFC_822_TIME_ZONE = "[+-]\\d{4}";
+ private static final String RFC_822_TIME_ZONE =
"[+-](?:2[0-3]|[01]\\d)[0-5]\\d";
private static final String GMT_OPTION = TimeZones.GMT_ID +
"[+-]\\d{1,2}:\\d{2}";
@@ -607,10 +631,11 @@ void setCalendar(final FastDateParser parser, final
Calendar calendar, final Str
// match missing the optional trailing period
tzInfo = tzNames.get(timeZone + '.');
if (tzInfo == null) {
- // show chars in case this is multiple byte character
issue
- final char[] charArray = timeZone.toCharArray();
- throw new IllegalStateException(String.format("Can't
find time zone '%s' (%d %s) in %s", timeZone, charArray.length,
- Arrays.toString(charArray), new
TreeSet<>(tzNames.keySet())));
+ // Converted to a parse failure by
PatternStrategy.parse instead of surfacing as an
+ // undeclared IllegalStateException; the message is
bounded by the matched input
+ // (no dump of the entire time zone name table).
+ throw new IllegalArgumentException(
+ String.format("Can't find time zone '%s' (%d
chars)", timeZone, timeZone.length()));
}
}
calendar.set(Calendar.DST_OFFSET, tzInfo.dstOffset);
@@ -1001,6 +1026,10 @@ public String getPattern() {
return pattern;
}
+ List<StrategyAndWidth> getPatterns() {
+ return patterns;
+ }
+
/**
* Gets a Strategy given a field from a SimpleDateFormat pattern
*
@@ -1228,6 +1257,7 @@ public String toString() {
return "FastDateParser[" + pattern + ", " + locale + ", " +
timeZone.getID() + "]";
}
+
/**
* Converts all state of this instance to a String handy for debugging.
*
diff --git
a/src/test/java/org/apache/commons/lang3/time/FastDateParserTest.java
b/src/test/java/org/apache/commons/lang3/time/FastDateParserTest.java
index 389e1e7cb..a71a70fbd 100644
--- a/src/test/java/org/apache/commons/lang3/time/FastDateParserTest.java
+++ b/src/test/java/org/apache/commons/lang3/time/FastDateParserTest.java
@@ -27,6 +27,7 @@
import static org.junit.jupiter.api.Assertions.fail;
import java.io.Serializable;
+import java.lang.reflect.Constructor;
import java.text.ParseException;
import java.text.ParsePosition;
import java.text.SimpleDateFormat;
@@ -35,6 +36,7 @@
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.HashMap;
+import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TimeZone;
@@ -46,11 +48,13 @@
import org.apache.commons.lang3.SerializationUtils;
import org.apache.commons.lang3.SystemUtils;
import org.apache.commons.lang3.function.TriFunction;
+import org.apache.commons.lang3.reflect.FieldUtils;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.junitpioneer.jupiter.DefaultLocale;
import org.junitpioneer.jupiter.DefaultTimeZone;
@@ -113,6 +117,24 @@ private enum Expected1806 {
private static final Locale SWEDEN = new Locale("sv", "SE");
+ private static void assertParseFailure(final DateParser parser, final
String source, final int errorIndex) {
+ final ParseException exception = assertThrows(ParseException.class, ()
-> parser.parse(source), source);
+ assertEquals(errorIndex, exception.getErrorOffset(), source);
+ for (final int startIndex : new int[] { 0, 2 }) {
+ final String input = startIndex == 0 ? source : "##" + source;
+ final ParsePosition datePosition = new ParsePosition(startIndex);
+ assertNull(parser.parse(input, datePosition), input);
+ assertEquals(startIndex + errorIndex, datePosition.getIndex(),
input);
+ assertEquals(startIndex + errorIndex,
datePosition.getErrorIndex(), input);
+ final ParsePosition calendarPosition = new
ParsePosition(startIndex);
+ final Calendar calendar = Calendar.getInstance(TimeZones.GMT,
Locale.US);
+ calendar.clear();
+ assertFalse(parser.parse(input, calendarPosition, calendar),
input);
+ assertEquals(startIndex + errorIndex, calendarPosition.getIndex(),
input);
+ assertEquals(startIndex + errorIndex,
calendarPosition.getErrorIndex(), input);
+ }
+ }
+
static void checkParse(final Locale locale, final Calendar cal, final
SimpleDateFormat simpleDateFormat,
final DateParser dateParser) {
final String formattedDate = simpleDateFormat.format(cal.getTime());
@@ -202,29 +224,6 @@ private Calendar getEraStart(int year, final TimeZone
zone, final Locale locale)
return cal;
}
- @Test
- void testWeekYearParsing() throws ParseException {
- // 'Y' must parse as a week year (resolved through
Calendar.setWeekDate), matching both SimpleDateFormat
- // and FastDatePrinter's WeekYear rule, instead of silently mapping to
the plain calendar year.
- final String[][] cases = {
- { "YYYY-MM-dd", "2025-12-29" }, // the ubiquitous YYYY-for-yyyy
slip, at a year boundary
- { "YYYY-'W'ww-u", "2025-W01-1" },
- { "YYYY-'W'ww-u", "2020-W53-5" },
- { "YYYY-'W'ww", "2024-W15" },
- { "YY-MM-dd", "25-12-29" },
- { "YYYY", "2025" },
- { "yyyy-MM-dd", "2024-12-29" } // plain calendar year is unaffected
- };
- for (final Locale locale : new Locale[] { Locale.US, Locale.GERMANY })
{
- for (final String[] testCase : cases) {
- final SimpleDateFormat sdf = new SimpleDateFormat(testCase[0],
locale);
- final DateParser fdp = getInstance(testCase[0], locale);
- assertEquals(sdf.parse(testCase[1]), fdp.parse(testCase[1]),
- "Pattern " + testCase[0] + " input " + testCase[1] + "
locale " + locale);
- }
- }
- }
-
DateParser getInstance(final String format) {
return getInstance(null, format, TimeZone.getDefault(),
Locale.getDefault());
}
@@ -547,6 +546,39 @@ void testParseErrorMessageJapaneseImperial(final
TriFunction<String, TimeZone, L
assertTrue(message.contains("does not support dates before
1868-01-01."), message);
}
+ @ParameterizedTest
+ @CsvSource({
+ "Z, GMT+0:99", "Z, GMT-0:99", "Z, GMT+24:00", "Z, GMT-24:00",
+ "z, GMT+0:99", "z, GMT-0:99", "z, GMT+24:00", "z, GMT-24:00"
+ })
+ void testParseInvalidGmtTimeZoneOffsets(final String zonePattern, final
String offset) {
+ final String pattern = "yyyy-MM-dd'T'HH:mm:ss" + zonePattern;
+ final String prefix = "2024-01-01T00:00:00";
+ assertParseFailure(new FastDateParser(pattern, TimeZones.GMT,
Locale.US), prefix + offset, prefix.length());
+ assertParseFailure(FastDateFormat.getInstance(pattern, TimeZones.GMT,
Locale.US), prefix + offset, prefix.length());
+ }
+
+ @ParameterizedTest
+ @MethodSource(DATE_PARSER_PARAMETERS)
+ void testParseInvalidTimeZoneOffsets(final TriFunction<String, TimeZone,
Locale, DateParser> dpProvider) {
+ // Out-of-range offsets must not escape as IllegalArgumentException
from GmtTimeZone.
+ final String prefix = "2024-01-01T00:00:00";
+ final String[][] cases = {
+ { "X", "+24", "-24", "+99", "-99" },
+ { "XX", "+2400", "-2400", "+0060", "-0060", "+9999" },
+ { "XXX", "+24:00", "-24:00", "+00:60", "-00:60", "+99:99" },
+ { "ZZ", "+24:00", "-24:00", "+00:60", "-00:60", "+99:99" },
+ { "Z", "+2400", "-2400", "+0060", "-0060" },
+ { "z", "+2400", "-2400", "+0060", "-0060" }
+ };
+ for (final String[] testCase : cases) {
+ final DateParser parser = getInstance(dpProvider,
"yyyy-MM-dd'T'HH:mm:ss" + testCase[0], TimeZones.GMT, Locale.US);
+ for (int i = 1; i < testCase.length; i++) {
+ assertParseFailure(parser, prefix + testCase[i],
prefix.length());
+ }
+ }
+ }
+
@ParameterizedTest
@MethodSource(DATE_PARSER_PARAMETERS)
void testParseLongShort(final TriFunction<String, TimeZone, Locale,
DateParser> dpProvider)
@@ -573,6 +605,23 @@ void testParseLongShort(final TriFunction<String,
TimeZone, Locale, DateParser>
assertEquals(cal.getTime(), fdf.parse("03 AD 2 10 PM Saturday 15 33 20
989 -0500"));
}
+ @Test
+ void testParseMissingTimeZoneName() throws ReflectiveOperationException {
+ final FastDateParser parser = new FastDateParser("yyyy-MM-dd z",
TimeZones.GMT, Locale.US);
+ final List<?> patterns = parser.getPatterns();
+ final Object zonePattern = patterns.get(patterns.size() - 1);
+ final Object cachedStrategy = FieldUtils.readField(zonePattern,
"strategy", true);
+ // The report supplies no concrete regex/TreeMap mismatch. Simulate a
matched name missing
+ // from the lookup using a private strategy instance, leaving the
shared cache untouched.
+ final Constructor<?> constructor =
cachedStrategy.getClass().getDeclaredConstructor(Locale.class);
+ constructor.setAccessible(true);
+ final Object strategy = constructor.newInstance(Locale.US);
+ final Map<?, ?> names = (Map<?, ?>) FieldUtils.readField(strategy,
"tzNames", true);
+ assertNotNull(names.remove("PST"));
+ FieldUtils.writeField(zonePattern, "strategy", strategy, true);
+ assertParseFailure(parser, "2024-01-01 PST", 11);
+ }
+
@ParameterizedTest
@MethodSource(DATE_PARSER_PARAMETERS)
void testParseNumerics(final TriFunction<String, TimeZone, Locale,
DateParser> dpProvider)
@@ -601,7 +650,7 @@ void testParseOffset() {
public void testParsePositionBeyondInputLength() {
final String source = "Jan";
final int startingIndex = 10;
- final String[] patterns = new String[] {"yyyy", "MM", "dd", "HH",
"'x'", "-", "/", ":", " 'at' ", "MMM", "EEEE", "a", "z"};
+ final String[] patterns = {"yyyy", "MM", "dd", "HH", "'x'", "-", "/",
":", " 'at' ", "MMM", "EEEE", "a", "z"};
for (final String pattern : patterns) {
final DateParser parser = getInstance(pattern);
final ParsePosition pos1 = new ParsePosition(startingIndex);
@@ -653,6 +702,49 @@ void testParsesKnownJava16Ea25Failure() throws Exception {
validateSdfFormatFdpParseEquality(format, locale, timeZone,
fastDateParser, in, year, centuryStart);
}
+ @ParameterizedTest
+ @MethodSource(DATE_PARSER_PARAMETERS)
+ void testParseUnicodeTextLookupFailure(final TriFunction<String, TimeZone,
Locale, DateParser> dpProvider) {
+ // Unicode regex folding accepts long s and dotted I, but the
lower-case map keys differ.
+ assertParseFailure(getInstance(dpProvider, "dd MMMM yyyy",
TimeZones.GMT, Locale.US), "01 Augu\u017ft 2024", 3);
+ assertParseFailure(getInstance(dpProvider, "yyyy-MM-dd EEEE",
TimeZones.GMT, Locale.US), "2024-08-01 Thur\u017fday", 11);
+ assertParseFailure(getInstance(dpProvider, "yyyy-MM-dd EEEE",
TimeZones.GMT, Locale.US), "2024-08-02 FR\u0130DAY", 11);
+ }
+
+ @ParameterizedTest
+ @MethodSource(DATE_PARSER_PARAMETERS)
+ void testParseUnicodeTextLookupSuccess(final TriFunction<String, TimeZone,
Locale, DateParser> dpProvider) throws ParseException {
+ final DateParser turkish = getInstance(dpProvider, "dd MMMM yyyy",
TimeZones.GMT, new Locale("tr", "TR"));
+ // Root-locale fallback resolves ASCII I where Turkish lower-casing
produces dotless i.
+ assertEquals(turkish.parse("01 Nisan 2024"), turkish.parse("01 NISAN
2024"));
+ final DateParser german = getInstance(dpProvider, "dd MMMM yyyy",
TimeZones.GMT, Locale.GERMANY);
+ assertEquals(german.parse("01 Oktober 2024"), german.parse("01
O\u212atober 2024"));
+ }
+
+ @ParameterizedTest
+ @MethodSource(DATE_PARSER_PARAMETERS)
+ void testParseValidTimeZoneOffsetBoundaries(final TriFunction<String,
TimeZone, Locale, DateParser> dpProvider) {
+ final String prefix = "2024-01-01T00:00:00";
+ final String[][] cases = {
+ { "X", "Z", "+00", "-00", "+23", "-23" },
+ { "XX", "Z", "+0000", "-0000", "+2359", "-2359" },
+ { "XXX", "Z", "+00:00", "-00:00", "+23:59", "-23:59" },
+ { "ZZ", "Z", "+00:00", "-00:00", "+23:59", "-23:59" },
+ { "Z", "+0000", "-0000", "+2359", "-2359", "GMT+0:00", "GMT-23:59"
},
+ { "z", "+0000", "-0000", "+2359", "-2359", "GMT+0:00", "GMT-23:59"
}
+ };
+ for (final String[] testCase : cases) {
+ final DateParser parser = getInstance(dpProvider,
"yyyy-MM-dd'T'HH:mm:ss" + testCase[0], TimeZones.GMT, Locale.US);
+ for (int i = 1; i < testCase.length; i++) {
+ final String source = prefix + testCase[i];
+ final ParsePosition position = new ParsePosition(0);
+ assertNotNull(parser.parse(source, position), source);
+ assertEquals(source.length(), position.getIndex(), source);
+ assertEquals(-1, position.getErrorIndex(), source);
+ }
+ }
+ }
+
@ParameterizedTest
@MethodSource(DATE_PARSER_PARAMETERS)
void testParseZone(final TriFunction<String, TimeZone, Locale, DateParser>
dpProvider)
@@ -801,6 +893,29 @@ void testTzParses(final Locale locale) throws Exception {
}
}
+ @Test
+ void testWeekYearParsing() throws ParseException {
+ // 'Y' must parse as a week year (resolved through
Calendar.setWeekDate), matching both SimpleDateFormat
+ // and FastDatePrinter's WeekYear rule, instead of silently mapping to
the plain calendar year.
+ final String[][] cases = {
+ { "YYYY-MM-dd", "2025-12-29" }, // the ubiquitous YYYY-for-yyyy
slip, at a year boundary
+ { "YYYY-'W'ww-u", "2025-W01-1" },
+ { "YYYY-'W'ww-u", "2020-W53-5" },
+ { "YYYY-'W'ww", "2024-W15" },
+ { "YY-MM-dd", "25-12-29" },
+ { "YYYY", "2025" },
+ { "yyyy-MM-dd", "2024-12-29" } // plain calendar year is unaffected
+ };
+ for (final Locale locale : new Locale[] { Locale.US, Locale.GERMANY })
{
+ for (final String[] testCase : cases) {
+ final SimpleDateFormat sdf = new SimpleDateFormat(testCase[0],
locale);
+ final DateParser fdp = getInstance(testCase[0], locale);
+ assertEquals(sdf.parse(testCase[1]), fdp.parse(testCase[1]),
+ "Pattern " + testCase[0] + " input " + testCase[1] + "
locale " + locale);
+ }
+ }
+ }
+
private void validateSdfFormatFdpParseEquality(final String formatStr,
final Locale locale, final TimeZone timeZone,
final FastDateParser fastDateParser, final Date inDate, final int
year, final Date csDate) throws ParseException {
final SimpleDateFormat sdf = new SimpleDateFormat(formatStr, locale);