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 526a03a87 DateUtils.parseDateWithLeniency(String, Locale, String[], 
boolean) doesn't reset its time zone (#1660)
526a03a87 is described below

commit 526a03a8701f85e422786f0a4d1fadd3f0c249c1
Author: Gary Gregory <[email protected]>
AuthorDate: Mon May 18 12:38:16 2026 -0400

    DateUtils.parseDateWithLeniency(String, Locale, String[], boolean) doesn't 
reset its time zone (#1660)
    
    Calendar.clear() does not reset the time zone. A previous TZ-aware
    pattern (for example, "z", "zz", "Z", "X..") that partially parsed could
    have
    mutated the calendar's time zone via Calendar.setTimeZone(...) before
    failing on the remaining tokens. Restore the caller-supplied zone for
    each
    attempt so the outcome of pattern N+1 does not depend on the partial
    state left by pattern N.
---
 .../org/apache/commons/lang3/time/DateUtils.java   |  4 ++
 .../apache/commons/lang3/time/DateUtilsTest.java   | 53 ++++++++++++++++++++++
 2 files changed, 57 insertions(+)

diff --git a/src/main/java/org/apache/commons/lang3/time/DateUtils.java 
b/src/main/java/org/apache/commons/lang3/time/DateUtils.java
index cdae1d832..04a24c549 100644
--- a/src/main/java/org/apache/commons/lang3/time/DateUtils.java
+++ b/src/main/java/org/apache/commons/lang3/time/DateUtils.java
@@ -1350,6 +1350,10 @@ private static Date parseDateWithLeniency(final String 
dateStr, final Locale loc
         for (final String parsePattern : parsePatterns) {
             final FastDateParser fdp = new FastDateParser(parsePattern, tz, 
lcl);
             calendar.clear();
+            // Calendar.clear() does not reset the time zone. A previous 
TZ-aware pattern (for example, "z", "zz", "Z", "X..") that partially parsed 
could have
+            // mutated the calendar's time zone via Calendar.setTimeZone(...) 
before failing on the remaining tokens. Restore the caller-supplied zone for 
each
+            // attempt so the outcome of pattern N+1 does not depend on the 
partial state left by pattern N.
+            calendar.setTimeZone(tz);
             try {
                 if (fdp.parse(dateStr, pos, calendar) && pos.getIndex() == 
dateStr.length()) {
                     return calendar.getTime();
diff --git a/src/test/java/org/apache/commons/lang3/time/DateUtilsTest.java 
b/src/test/java/org/apache/commons/lang3/time/DateUtilsTest.java
index fb878645e..14bb9d9e5 100644
--- a/src/test/java/org/apache/commons/lang3/time/DateUtilsTest.java
+++ b/src/test/java/org/apache/commons/lang3/time/DateUtilsTest.java
@@ -1680,5 +1680,58 @@ void testWeekIterator() {
         }
     }
 
+    /**
+     * Failed date parse can poison the calendar's time zone for the next 
pattern.
+     *
+     * <p>
+     * {@link DateUtils#parseDateWithLeniency} reuses a single {@link 
Calendar} across pattern attempts. The reset between iterations is
+     * {@link Calendar#clear()}, which does NOT reset the calendar's time 
zone. A TZ-aware pattern (e.g. starts with {@code "X"}, {@code "Z"}, {@code 
"z"}) that
+     * successfully consumes its TZ token (mutating the calendar via {@link 
Calendar#setTimeZone}) before failing on later tokens leaves the calendar 
carrying
+     * the parsed (foreign) zone. The next pattern in the list then interprets 
its date fields against that zone, producing a wrong instant.
+     * </p>
+     *
+     * <p>
+     * <b>Construction:</b> input {@code "+0500 01/01/2024"} is parsed against 
two patterns:
+     * </p>
+     * <ol>
+     * <li>{@code "Z 'X' MM/dd/yyyy"}: the {@code Z} strategy consumes {@code 
"+0500"} and calls {@code calendar.setTimeZone(GMT+05:00)}; the next literal
+     * {@code 'X'} fails to match {@code " 01"}, so the overall parse returns 
false. The leaked TZ remains on the shared calendar.</li>
+     * <li>{@code "'+0500 'MM/dd/yyyy"}: a TZ-less pattern that absorbs the 
literal prefix and parses the date. With the bug, the calendar still carries
+     * GMT+05:00, so the date is interpreted as midnight in GMT+05:00 (5 hours 
earlier as an instant) than the same date interpreted in the system default
+     * zone.</li>
+     * </ol>
+     *
+     * <p>
+     * The assertion compares {@link Date#getTime()} against a baseline parse 
using only the second pattern. A naive PoC that only checked calendar fields
+     * (year/month/day) would not detect the bug because those equal in both 
runs.
+     * </p>
+     *
+     * <p>
+     * Pre-patch: the bug shifts the resulting instant by 5 hours.<br>
+     * Post-patch: {@code parseDateWithLeniency} restores {@code 
calendar.setTimeZone(TimeZone.getDefault())} before each iteration, so the 
leaked zone never
+     * reaches the second attempt and both instants match.
+     * </p>
+     */
+    @Test
+    public void testFailedTimeZonePatternDoesNotPoisonNextPatternInstant() 
throws Exception {
+        final String input = "+0500 01/01/2024";
+        // Baseline: a single TZ-less pattern; no leak source. The calendar 
uses
+        // TimeZone.getDefault(), so the result is midnight 2024-01-01 in the 
default zone.
+        final Date baseline = DateUtils.parseDate(input, "'+0500 'MM/dd/yyyy");
+        assertNotNull(baseline, "baseline parse must succeed");
+        // Poisoning sequence:
+        //   pattern 1 = "Z 'X' MM/dd/yyyy"  -> Z consumes "+0500" then 
setTimeZone(GMT+05:00),
+        //                                       'X' literal mismatches " 01", 
returns false.
+        //   pattern 2 = "'+0500 'MM/dd/yyyy"-> matches the whole input via 
literal prefix +
+        //                                       MM/dd/yyyy. With the bug, 
calendar still has
+        //                                       GMT+05:00 leaked from pattern 
1, so the date
+        //                                       is interpreted in GMT+05:00 
instead of default.
+        final Date poisoned = DateUtils.parseDate(input, "Z 'X' MM/dd/yyyy", 
"'+0500 'MM/dd/yyyy");
+        assertNotNull(poisoned, "poisoning sequence parse must succeed via the 
second pattern");
+        // Compare INSTANTS, not Y/M/D. Calendar fields equal in both runs by 
coincidence
+        // even when the bug shifts the underlying instant.
+        assertEquals(baseline.getTime(), poisoned.getTime(),
+                "Calendar TZ leaked from a failed prior pattern must not 
affect the instant produced by a subsequent TZ-less pattern");
+    }
 }
 

Reply via email to