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);

Reply via email to