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");
+ }
}