Skip to content

fix: ISODate.parseISODate no longer corrupts Feb 29th into March 1st - #779

Merged
ar merged 3 commits into
jpos:mainfrom
alcarraz:fix/778-parseISODate-feb29-rollover
Sep 15, 2026
Merged

ar merged 3 commits into
jpos:mainfrom
alcarraz:fix/778-parseISODate-feb29-rollover

Conversation

@alcarraz

@alcarraz alcarraz commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #778.

The Problem

parseISODate(String, long, TimeZone)'s year-less branch resolves a MMDDhhmmss field into the nearest of three candidate years (previous/current/next) by mutating a single shared Calendar in sequence:

Date thisYear = cal.getTime();
cal.set (Calendar.YEAR, cal.get (Calendar.YEAR)-1);
Date previousYear = cal.getTime();
cal.set (Calendar.YEAR, cal.get (Calendar.YEAR)+2);
Date nextYear = cal.getTime();

Calendar field mutations are lazy: set() doesn't normalize anything by itself. Normalization only happens on getTime()/get(), and when it does, it writes the normalized values back into the Calendar's own field array. So when MM/DD = "0229" and the real current year isn't a leap year, the first thisYear = cal.getTime() call silently rolls Feb 29th over to March 1st and mutates cal in place. Every later cal.set(YEAR, ...) for previousYear/nextYear then builds on that already-corrupted March 1st state instead of the original Feb 29th — so even a genuinely valid leap-year candidate among the three never gets a chance to be computed correctly. All three candidates can come back March 1st, silently, with no error.

The Fix

Each of the three candidate years is now computed independently, and a genuine date is preferred over one that only exists via rollover.

  • buildDate(cal, year, month, day) re-asserts YEAR/MONTH/DATE — the only fields a date rollover can touch — before every getTime() call, so one candidate's rollover can no longer leak into the next.

    It reuses the existing cal instance rather than allocating a fresh Calendar per candidate. parseISODate sits on a hot path — field 7/12/13 date parsing happens on essentially every ISO-8583 message — and a naive per-candidate fix (a brand-new Calendar for each of the three years, clear()ed to guarantee no shared state) would add 3-6 extra allocations per call versus the original's single shared instance. Reusing cal keeps allocation count unchanged from before the fix, and it's still safe: HOUR_OF_DAY/MINUTE/SECOND/MILLISECOND are set once above the branch and a date-only rollover never touches them, so re-asserting only YEAR/MONTH/DATE per candidate is sufficient to erase any state left over from the previous candidate's getTime() call.

  • diff(cal, now, candidate, year, month, day) computes the distance to now as before, but adds a large penalty when day doesn't actually exist in month/year (checked via Calendar.getActualMaximum(DATE)). This makes a real Feb 29th win the nearest-year comparison over a chronologically-closer-but-rolled-over March 1st, which is what the original bug reports as the expected behavior. The penalty is the same constant for all three candidates, so when none of the three years is a leap year, it cancels out of the comparison and the method falls back to the old rollover-based nearest-year result — the return contract (always a non-null Date) is unchanged, since library callers with no null-check shouldn't see new NPEs.

The 3-way candidate selection itself (if (previousDiff < thisDiff) ... if (thisDiff > nextDiff) ...) keeps the same shape as the original code, just fed by the corrected diff values.

Testing

Added to ISODateTest:

  • The two reproduction cases from ISODate.parseISODate's nearest-year inference silently corrupts Feb 29 into March 1st #778 (a non-leap "current year" with a valid leap year one year away in each direction) — both now resolve to the genuine Feb 29th instead of March 1st.
  • A case where none of the three neighboring years is a leap year, confirming the old rollover fallback (March 1st) still applies rather than returning null for backward compatibility.

./gradlew jpos:test (full suite) passes.

The year-less nearest-year branch mutated a single shared Calendar in
sequence to compute previous/current/next-year candidates. Calendar
field mutations are lazy and only normalize on getTime()/get(), and
that normalization writes back into the Calendar's own field array -
so when MM/DD is Feb 29th and the real current year isn't a leap
year, the first candidate's rollover to March 1st corrupted the
shared Calendar, and every later candidate built on that corrupted
state instead of the original Feb 29th. All three candidates could
come back March 1st, silently, even when a genuine leap year was one
year away.

Each candidate is now computed independently by re-asserting
YEAR/MONTH/DATE before every getTime() call, and a genuine date is
preferred over one that only exists via rollover, so the nearest
real Feb 29th wins over a chronologically-closer rollover.

Fixes jpos#778.

@ar-agt ar-agt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alcarraz The leap-day cases are fixed, but I reproduced a new DST regression on this head that should be addressed before merging.

buildDate() restores only YEAR/MONTH/DATE. Normalizing a candidate in a DST gap also changes HOUR_OF_DAY on the shared Calendar, and that changed hour carries into later candidates.

Reproduction

ZoneId zone = ZoneId.of("America/New_York");
long now = ZonedDateTime.of(2026, 3, 9, 12, 0, 0, 0, zone)
    .toInstant().toEpochMilli();
Date result = ISODate.parseISODate("0309023000", now, TimeZone.getTimeZone(zone));
System.out.println(result.toInstant().atZone(zone));
  • Expected and unchanged main (91c3ac10d): 2026-03-09T02:30-04:00[America/New_York]
  • This PR (ef72bc834): 2026-03-09T03:30-04:00[America/New_York]

The previous-year candidate, March 9, 2025 at 02:30, falls in a DST gap. Its normalization changes the shared hour to 03:30, contaminating the valid 2026 candidate.

Please capture the reference year before setting the input fields and initialize every candidate from a clean state using all original date/time fields. Reusing one Calendar with clear() and complete field initialization is an option; separate instances are another. The validity calculation should likewise not depend on fields left behind by candidate normalization.

Please add this DST regression with an explicit time zone and a full date/time assertion, plus a case where the valid leap day is in the next candidate year. The added leap-day cases currently cover previous/current leap years and the no-leap fallback, not the next-year case.

Verification: all 21 ISODateTest tests passed locally using the pinned Java 26.0.2 / Gradle 9.7.1 toolchain. A separate harness compiled against the PR and unchanged main reproduced the different outputs above. I did not rerun the full suite.

The prior commit's buildDate() only re-asserted YEAR/MONTH/DATE on the
reused Calendar, assuming a date rollover never touches the time-of-day
fields. That assumption breaks for DST gaps: normalizing a candidate
whose local time falls in a DST spring-forward gap also rewrites
HOUR_OF_DAY, and that leaked into the next candidate built on the same
Calendar. buildDate() now clears and fully re-sets every field per
candidate, and the day-validity check moved out of Calendar entirely
(plain Gregorian leap-year arithmetic via java.time.Year.isLeap) so it
can no longer be skewed by state left behind by a candidate's own
normalization.

Also fixes an out-of-bounds array access: isValidDay() indexed
DAYS_IN_MONTH by month without checking it was in range, so a
malformed 2-digit month (e.g. "00" or "13"+) in a year-less MMDDhhmmss
field threw ArrayIndexOutOfBoundsException instead of falling back to
the old lenient-rollover behavior like every other malformed input.

Adds regression tests for the DST leak, the out-of-range month cases,
and a leap-year candidate in the next (rather than previous/current)
year.
@alcarraz

alcarraz commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Fixed. buildDate() now clear()s and fully re-sets every field per candidate (not just YEAR/MONTH/DATE), so a DST-gap normalization can't leak HOUR_OF_DAY into the next candidate either. The day-validity check also moved off Calendar entirely onto plain java.time.Year.isLeap arithmetic, so it can't read leftover state at all.

Also found and fixed a related bug: the validity check indexed a days-in-month array by month without bounds-checking, so a malformed month ("00"/"13"+) threw ArrayIndexOutOfBoundsException instead of falling back to the old lenient rollover like every other malformed input.

Added tests for your DST repro, a next-year leap candidate, and both out-of-range month cases. All pass, ISODateTest and the full suite.

@alcarraz

Copy link
Copy Markdown
Contributor Author

I'd missed the first point of your suggested correction: the reference year was still being read via the same shared Calendar that later had the input's MM/DD/hh/mm/ss written onto it, so a rollover in those input fields (reproducible with a malformed month like "00" or "13", not just Feb 29th) could shift the computed "current year" before it was ever read — the same class of ordering hazard as the DST leak, just on a different field.

Rather than just reordering the one read, the last commit splits the shared Calendar into one scoped to each branch (explicit-year vs. year-less), so nothing set in one branch can affect a read anywhere else — removes the ordering hazard by construction instead of by convention.

While in there, the three year-less candidates and their comparison now carry plain epoch millis instead of Date objects until the winning one is chosen, so it's down to one Date instantiation instead of three.

Added regression tests for out-of-range months (too small and too large), confirming the computed reference year now matches the real current year rather than one shifted by the input's own rollover.

Capturing the reference year via the same Calendar that later has the
input's MM/DD/hh/mm/ss written onto it meant a rollover in those input
fields (e.g. an out-of-range month) could shift the computed "current
year" before it was ever read -- the exact ordering hazard the DST fix
was about, just on a different field. Each branch (explicit year,
year-less) now gets its own freshly-scoped Calendar, so nothing set in
one branch can affect a read in another.

Also: isValidDay() now defers to java.time.YearMonth.lengthOfMonth()
instead of a hand-rolled leap-year/days-in-month table; the year-less
branch's three candidates and their comparison now carry plain epoch
millis instead of Date objects until the winning candidate is chosen,
cutting two throwaway Date allocations; and the helper that used to be
misnamed buildDate() (it returns millis, not a Date) is now
toEpochMillis().

No behavior change beyond correctly anchoring the reference year --
verified against the full test suite plus ISODateTest.

Signed-off-by: Andrés Alcarraz <andres.alcarraz@transactility.com>
@alcarraz
alcarraz force-pushed the fix/778-parseISODate-feb29-rollover branch from 1aab3ed to 8089998 Compare September 10, 2026 01:09
@ar
ar merged commit b7c017d into jpos:main Sep 15, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ISODate.parseISODate's nearest-year inference silently corrupts Feb 29 into March 1st

3 participants