From bac6f8e69742585cc68e28315df989c199763454 Mon Sep 17 00:00:00 2001 From: zgj-ssslab Date: Sat, 5 Sep 2026 00:01:29 +0800 Subject: [PATCH 1/4] Fix date-only parsing failure when local midnight is skipped by DST Parsing a date-only string like '1966-11-01' with default time zone America/Sao_Paulo threw an exception ('HOUR_OF_DAY: 0 -> 1') because local midnight did not exist due to a DST transition at midnight. Strict parsing now only fails for dates which do not exist in the calendar (e.g. 2021-02-30); a valid date whose midnight is skipped by DST is resolved to the first existing instant of that day, following the semantics of java.time's LocalDate.atStartOfDay(ZoneId). Fixes #2539 --- .../gson/internal/bind/util/ISO8601Utils.java | 789 +++++++++--------- .../internal/bind/util/ISO8601UtilsTest.java | 289 ++++--- 2 files changed, 566 insertions(+), 512 deletions(-) diff --git a/gson/src/main/java/com/google/gson/internal/bind/util/ISO8601Utils.java b/gson/src/main/java/com/google/gson/internal/bind/util/ISO8601Utils.java index 606d6c7146..52a8ddde8c 100644 --- a/gson/src/main/java/com/google/gson/internal/bind/util/ISO8601Utils.java +++ b/gson/src/main/java/com/google/gson/internal/bind/util/ISO8601Utils.java @@ -1,386 +1,403 @@ -/* - * Copyright (C) 2015 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.gson.internal.bind.util; - -import java.text.ParseException; -import java.text.ParsePosition; -import java.util.Calendar; -import java.util.Date; -import java.util.GregorianCalendar; -import java.util.Locale; -import java.util.TimeZone; - -/** - * Utilities methods for manipulating dates in iso8601 format. This is much faster and GC friendly - * than using SimpleDateFormat so highly suitable if you (un)serialize lots of date objects. - * - *

Supported parse format: - * [yyyy-MM-dd|yyyyMMdd][T(hh:mm[:ss[.sss]]|hhmm[ss[.sss]])]?[Z|[+-]hh[:]mm]] - * - * @see this specification - */ -// Date parsing code from Jackson databind ISO8601Utils.java -// https://github.com/FasterXML/jackson-databind/blob/2.8/src/main/java/com/fasterxml/jackson/databind/util/ISO8601Utils.java -@SuppressWarnings("MemberName") // legacy class name -public final class ISO8601Utils { - private ISO8601Utils() {} - - /** - * ID to represent the 'UTC' string, default timezone since Jackson 2.7 - * - * @since 2.7 - */ - private static final String UTC_ID = "UTC"; - - /** - * The UTC timezone, prefetched to avoid more lookups. - * - * @since 2.7 - */ - private static final TimeZone TIMEZONE_UTC = TimeZone.getTimeZone(UTC_ID); - - /* - /********************************************************** - /* Formatting - /********************************************************** - */ - - /** - * Format a date into 'yyyy-MM-ddThh:mm:ssZ' (default timezone, no milliseconds precision) - * - * @param date the date to format - * @return the date formatted as 'yyyy-MM-ddThh:mm:ssZ' - */ - public static String format(Date date) { - return format(date, false, TIMEZONE_UTC); - } - - /** - * Format a date into 'yyyy-MM-ddThh:mm:ss[.sss]Z' (GMT timezone) - * - * @param date the date to format - * @param millis true to include millis precision otherwise false - * @return the date formatted as 'yyyy-MM-ddThh:mm:ss[.sss]Z' - */ - public static String format(Date date, boolean millis) { - return format(date, millis, TIMEZONE_UTC); - } - - /** - * Format date into yyyy-MM-ddThh:mm:ss[.sss][Z|[+-]hh:mm] - * - * @param date the date to format - * @param millis true to include millis precision otherwise false - * @param tz timezone to use for the formatting (UTC will produce 'Z') - * @return the date formatted as yyyy-MM-ddThh:mm:ss[.sss][Z|[+-]hh:mm] - */ - public static String format(Date date, boolean millis, TimeZone tz) { - Calendar calendar = new GregorianCalendar(tz, Locale.US); - calendar.setTime(date); - - // estimate capacity of buffer as close as we can (yeah, that's pedantic ;) - int capacity = "yyyy-MM-ddThh:mm:ss".length(); - capacity += millis ? ".sss".length() : 0; - capacity += tz.getRawOffset() == 0 ? "Z".length() : "+hh:mm".length(); - StringBuilder formatted = new StringBuilder(capacity); - - padInt(formatted, calendar.get(Calendar.YEAR), "yyyy".length()); - formatted.append('-'); - padInt(formatted, calendar.get(Calendar.MONTH) + 1, "MM".length()); - formatted.append('-'); - padInt(formatted, calendar.get(Calendar.DAY_OF_MONTH), "dd".length()); - formatted.append('T'); - padInt(formatted, calendar.get(Calendar.HOUR_OF_DAY), "hh".length()); - formatted.append(':'); - padInt(formatted, calendar.get(Calendar.MINUTE), "mm".length()); - formatted.append(':'); - padInt(formatted, calendar.get(Calendar.SECOND), "ss".length()); - if (millis) { - formatted.append('.'); - padInt(formatted, calendar.get(Calendar.MILLISECOND), "sss".length()); - } - - int offset = tz.getOffset(calendar.getTimeInMillis()); - if (offset != 0) { - int hours = Math.abs((offset / (60 * 1000)) / 60); - int minutes = Math.abs((offset / (60 * 1000)) % 60); - formatted.append(offset < 0 ? '-' : '+'); - padInt(formatted, hours, "hh".length()); - formatted.append(':'); - padInt(formatted, minutes, "mm".length()); - } else { - formatted.append('Z'); - } - - return formatted.toString(); - } - - /* - /********************************************************** - /* Parsing - /********************************************************** - */ - - /** - * Parse a date from ISO-8601 formatted string. It expects a format - * [yyyy-MM-dd|yyyyMMdd][T(hh:mm[:ss[.sss]]|hhmm[ss[.sss]])]?[Z|[+-]hh[:mm]]] - * - * @param date ISO string to parse in the appropriate format. - * @param pos The position to start parsing from, updated to where parsing stopped. - * @return the parsed date - * @throws ParseException if the date is not in the appropriate format - */ - public static Date parse(String date, ParsePosition pos) throws ParseException { - Exception fail = null; - try { - int offset = pos.getIndex(); - - // extract year - int year = parseInt(date, offset, offset += 4); - if (checkOffset(date, offset, '-')) { - offset += 1; - } - - // extract month - int month = parseInt(date, offset, offset += 2); - if (checkOffset(date, offset, '-')) { - offset += 1; - } - - // extract day - int day = parseInt(date, offset, offset += 2); - - // default time value - int hour = 0; - int minutes = 0; - int seconds = 0; - - // always use 0 otherwise returned date will include millis of current time - int milliseconds = 0; - - // if the value has no time component (and no time zone), we are done - boolean hasT = checkOffset(date, offset, 'T'); - - if (!hasT && (date.length() <= offset)) { - Calendar calendar = new GregorianCalendar(year, month - 1, day); - calendar.setLenient(false); - - pos.setIndex(offset); - return calendar.getTime(); - } - - if (hasT) { - - // extract hours, minutes, seconds and milliseconds - hour = parseInt(date, offset += 1, offset += 2); - if (checkOffset(date, offset, ':')) { - offset += 1; - } - - minutes = parseInt(date, offset, offset += 2); - if (checkOffset(date, offset, ':')) { - offset += 1; - } - // second and milliseconds can be optional - if (date.length() > offset) { - char c = date.charAt(offset); - if (c != 'Z' && c != '+' && c != '-') { - seconds = parseInt(date, offset, offset += 2); - if (seconds > 59 && seconds < 63) { - seconds = 59; // truncate up to 3 leap seconds - } - // milliseconds can be optional in the format - if (checkOffset(date, offset, '.')) { - offset += 1; - int endOffset = indexOfNonDigit(date, offset + 1); // assume at least one digit - int parseEndOffset = Math.min(endOffset, offset + 3); // parse up to 3 digits - int fraction = parseInt(date, offset, parseEndOffset); - // compensate for "missing" digits - switch (parseEndOffset - offset) { // number of digits parsed - case 2: - milliseconds = fraction * 10; - break; - case 1: - milliseconds = fraction * 100; - break; - default: - milliseconds = fraction; - } - offset = endOffset; - } - } - } - } - - // extract timezone - if (date.length() <= offset) { - throw new IllegalArgumentException("No time zone indicator"); - } - - TimeZone timezone = null; - char timezoneIndicator = date.charAt(offset); - - if (timezoneIndicator == 'Z') { - timezone = TIMEZONE_UTC; - offset += 1; - } else if (timezoneIndicator == '+' || timezoneIndicator == '-') { - String timezoneOffset = date.substring(offset); - - // When timezone has no minutes, we should append it, valid timezones are, for example: - // +00:00, +0000 and +00 - timezoneOffset = timezoneOffset.length() >= 5 ? timezoneOffset : timezoneOffset + "00"; - - offset += timezoneOffset.length(); - // 18-Jun-2015, tatu: Minor simplification, skip offset of "+0000"/"+00:00" - if (timezoneOffset.equals("+0000") || timezoneOffset.equals("+00:00")) { - timezone = TIMEZONE_UTC; - } else { - // 18-Jun-2015, tatu: Looks like offsets only work from GMT, not UTC... - // not sure why, but that's the way it looks. Further, Javadocs for - // `java.util.TimeZone` specifically instruct use of GMT as base for - // custom timezones... odd. - String timezoneId = "GMT" + timezoneOffset; - // String timezoneId = "UTC" + timezoneOffset; - - timezone = TimeZone.getTimeZone(timezoneId); - - String act = timezone.getID(); - if (!act.equals(timezoneId)) { - /* 22-Jan-2015, tatu: Looks like canonical version has colons, but we may be given - * one without. If so, don't sweat. - * Yes, very inefficient. Hopefully not hit often. - * If it becomes a perf problem, add 'loose' comparison instead. - */ - String cleaned = act.replace(":", ""); - if (!cleaned.equals(timezoneId)) { - throw new IndexOutOfBoundsException( - "Mismatching time zone indicator: " - + timezoneId - + " given, resolves to " - + timezone.getID()); - } - } - } - } else { - throw new IndexOutOfBoundsException( - "Invalid time zone indicator '" + timezoneIndicator + "'"); - } - - Calendar calendar = new GregorianCalendar(timezone); - calendar.setLenient(false); - calendar.set(Calendar.YEAR, year); - calendar.set(Calendar.MONTH, month - 1); - calendar.set(Calendar.DAY_OF_MONTH, day); - calendar.set(Calendar.HOUR_OF_DAY, hour); - calendar.set(Calendar.MINUTE, minutes); - calendar.set(Calendar.SECOND, seconds); - calendar.set(Calendar.MILLISECOND, milliseconds); - - pos.setIndex(offset); - return calendar.getTime(); - // If we get a ParseException it'll already have the right message/offset. - // Other exception types can convert here. - } catch (IndexOutOfBoundsException | IllegalArgumentException e) { - fail = e; - } - String input = (date == null) ? null : ('"' + date + '"'); - String msg = fail.getMessage(); - if (msg == null || msg.isEmpty()) { - msg = "(" + fail.getClass().getName() + ")"; - } - ParseException ex = - new ParseException("Failed to parse date [" + input + "]: " + msg, pos.getIndex()); - ex.initCause(fail); - throw ex; - } - - /** - * Check if the expected character exist at the given offset in the value. - * - * @param value the string to check at the specified offset - * @param offset the offset to look for the expected character - * @param expected the expected character - * @return true if the expected character exist at the given offset - */ - private static boolean checkOffset(String value, int offset, char expected) { - return (offset < value.length()) && (value.charAt(offset) == expected); - } - - /** - * Parse an integer located between 2 given offsets in a string - * - * @param value the string to parse - * @param beginIndex the start index for the integer in the string - * @param endIndex the end index for the integer in the string - * @return the int - * @throws NumberFormatException if the value is not a number - */ - private static int parseInt(String value, int beginIndex, int endIndex) - throws NumberFormatException { - if (beginIndex < 0 || endIndex > value.length() || beginIndex > endIndex) { - throw new NumberFormatException(value); - } - // use same logic as in Integer.parseInt() but less generic we're not supporting negative values - int i = beginIndex; - int result = 0; - int digit; - if (i < endIndex) { - digit = Character.digit(value.charAt(i++), 10); - if (digit < 0) { - throw new NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex)); - } - result = -digit; - } - while (i < endIndex) { - digit = Character.digit(value.charAt(i++), 10); - if (digit < 0) { - throw new NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex)); - } - result *= 10; - result -= digit; - } - return -result; - } - - /** - * Zero pad a number to a specified length - * - * @param buffer buffer to use for padding - * @param value the integer value to pad if necessary. - * @param length the length of the string we should zero pad - */ - private static void padInt(StringBuilder buffer, int value, int length) { - String strValue = Integer.toString(value); - for (int i = length - strValue.length(); i > 0; i--) { - buffer.append('0'); - } - buffer.append(strValue); - } - - /** - * Returns the index of the first character in the string that is not a digit, starting at offset. - */ - private static int indexOfNonDigit(String string, int offset) { - for (int i = offset; i < string.length(); i++) { - char c = string.charAt(i); - if (c < '0' || c > '9') { - return i; - } - } - return string.length(); - } -} +/* + * Copyright (C) 2015 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.gson.internal.bind.util; + +import java.text.ParseException; +import java.text.ParsePosition; +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.Locale; +import java.util.TimeZone; + +/** + * Utilities methods for manipulating dates in iso8601 format. This is much faster and GC friendly + * than using SimpleDateFormat so highly suitable if you (un)serialize lots of date objects. + * + *

Supported parse format: + * [yyyy-MM-dd|yyyyMMdd][T(hh:mm[:ss[.sss]]|hhmm[ss[.sss]])]?[Z|[+-]hh[:]mm]] + * + * @see this specification + */ +// Date parsing code from Jackson databind ISO8601Utils.java +// https://github.com/FasterXML/jackson-databind/blob/2.8/src/main/java/com/fasterxml/jackson/databind/util/ISO8601Utils.java +@SuppressWarnings("MemberName") // legacy class name +public final class ISO8601Utils { + private ISO8601Utils() {} + + /** + * ID to represent the 'UTC' string, default timezone since Jackson 2.7 + * + * @since 2.7 + */ + private static final String UTC_ID = "UTC"; + + /** + * The UTC timezone, prefetched to avoid more lookups. + * + * @since 2.7 + */ + private static final TimeZone TIMEZONE_UTC = TimeZone.getTimeZone(UTC_ID); + + /* + /********************************************************** + /* Formatting + /********************************************************** + */ + + /** + * Format a date into 'yyyy-MM-ddThh:mm:ssZ' (default timezone, no milliseconds precision) + * + * @param date the date to format + * @return the date formatted as 'yyyy-MM-ddThh:mm:ssZ' + */ + public static String format(Date date) { + return format(date, false, TIMEZONE_UTC); + } + + /** + * Format a date into 'yyyy-MM-ddThh:mm:ss[.sss]Z' (GMT timezone) + * + * @param date the date to format + * @param millis true to include millis precision otherwise false + * @return the date formatted as 'yyyy-MM-ddThh:mm:ss[.sss]Z' + */ + public static String format(Date date, boolean millis) { + return format(date, millis, TIMEZONE_UTC); + } + + /** + * Format date into yyyy-MM-ddThh:mm:ss[.sss][Z|[+-]hh:mm] + * + * @param date the date to format + * @param millis true to include millis precision otherwise false + * @param tz timezone to use for the formatting (UTC will produce 'Z') + * @return the date formatted as yyyy-MM-ddThh:mm:ss[.sss][Z|[+-]hh:mm] + */ + public static String format(Date date, boolean millis, TimeZone tz) { + Calendar calendar = new GregorianCalendar(tz, Locale.US); + calendar.setTime(date); + + // estimate capacity of buffer as close as we can (yeah, that's pedantic ;) + int capacity = "yyyy-MM-ddThh:mm:ss".length(); + capacity += millis ? ".sss".length() : 0; + capacity += tz.getRawOffset() == 0 ? "Z".length() : "+hh:mm".length(); + StringBuilder formatted = new StringBuilder(capacity); + + padInt(formatted, calendar.get(Calendar.YEAR), "yyyy".length()); + formatted.append('-'); + padInt(formatted, calendar.get(Calendar.MONTH) + 1, "MM".length()); + formatted.append('-'); + padInt(formatted, calendar.get(Calendar.DAY_OF_MONTH), "dd".length()); + formatted.append('T'); + padInt(formatted, calendar.get(Calendar.HOUR_OF_DAY), "hh".length()); + formatted.append(':'); + padInt(formatted, calendar.get(Calendar.MINUTE), "mm".length()); + formatted.append(':'); + padInt(formatted, calendar.get(Calendar.SECOND), "ss".length()); + if (millis) { + formatted.append('.'); + padInt(formatted, calendar.get(Calendar.MILLISECOND), "sss".length()); + } + + int offset = tz.getOffset(calendar.getTimeInMillis()); + if (offset != 0) { + int hours = Math.abs((offset / (60 * 1000)) / 60); + int minutes = Math.abs((offset / (60 * 1000)) % 60); + formatted.append(offset < 0 ? '-' : '+'); + padInt(formatted, hours, "hh".length()); + formatted.append(':'); + padInt(formatted, minutes, "mm".length()); + } else { + formatted.append('Z'); + } + + return formatted.toString(); + } + + /* + /********************************************************** + /* Parsing + /********************************************************** + */ + + /** + * Parse a date from ISO-8601 formatted string. It expects a format + * [yyyy-MM-dd|yyyyMMdd][T(hh:mm[:ss[.sss]]|hhmm[ss[.sss]])]?[Z|[+-]hh[:mm]]] + * + * @param date ISO string to parse in the appropriate format. + * @param pos The position to start parsing from, updated to where parsing stopped. + * @return the parsed date + * @throws ParseException if the date is not in the appropriate format + */ + public static Date parse(String date, ParsePosition pos) throws ParseException { + Exception fail = null; + try { + int offset = pos.getIndex(); + + // extract year + int year = parseInt(date, offset, offset += 4); + if (checkOffset(date, offset, '-')) { + offset += 1; + } + + // extract month + int month = parseInt(date, offset, offset += 2); + if (checkOffset(date, offset, '-')) { + offset += 1; + } + + // extract day + int day = parseInt(date, offset, offset += 2); + + // default time value + int hour = 0; + int minutes = 0; + int seconds = 0; + + // always use 0 otherwise returned date will include millis of current time + int milliseconds = 0; + + // if the value has no time component (and no time zone), we are done + boolean hasT = checkOffset(date, offset, 'T'); + + if (!hasT && (date.length() <= offset)) { + Calendar calendar = new GregorianCalendar(year, month - 1, day); + calendar.setLenient(false); + try { + pos.setIndex(offset); + return calendar.getTime(); + } catch (IllegalArgumentException e) { + // The calendar date can be valid while local midnight does not exist in the default + // time zone, for example '1966-11-01' in America/Sao_Paulo where clocks were shifted + // forward from 0:00 to 1:00. In that case resolve the date like java.time's + // LocalDate.atStartOfDay(ZoneId) by using the first existing instant of that day. + // Truly invalid dates (such as '2021-02-30') also fail above, but lenient resolution + // would shift them to a different day, so they are re-thrown below. + Calendar lenientCalendar = new GregorianCalendar(year, month - 1, day); + Date resolved = lenientCalendar.getTime(); + if (lenientCalendar.get(Calendar.YEAR) != year + || lenientCalendar.get(Calendar.MONTH) != month - 1 + || lenientCalendar.get(Calendar.DAY_OF_MONTH) != day) { + throw e; + } + pos.setIndex(offset); + return resolved; + } + } + + if (hasT) { + + // extract hours, minutes, seconds and milliseconds + hour = parseInt(date, offset += 1, offset += 2); + if (checkOffset(date, offset, ':')) { + offset += 1; + } + + minutes = parseInt(date, offset, offset += 2); + if (checkOffset(date, offset, ':')) { + offset += 1; + } + // second and milliseconds can be optional + if (date.length() > offset) { + char c = date.charAt(offset); + if (c != 'Z' && c != '+' && c != '-') { + seconds = parseInt(date, offset, offset += 2); + if (seconds > 59 && seconds < 63) { + seconds = 59; // truncate up to 3 leap seconds + } + // milliseconds can be optional in the format + if (checkOffset(date, offset, '.')) { + offset += 1; + int endOffset = indexOfNonDigit(date, offset + 1); // assume at least one digit + int parseEndOffset = Math.min(endOffset, offset + 3); // parse up to 3 digits + int fraction = parseInt(date, offset, parseEndOffset); + // compensate for "missing" digits + switch (parseEndOffset - offset) { // number of digits parsed + case 2: + milliseconds = fraction * 10; + break; + case 1: + milliseconds = fraction * 100; + break; + default: + milliseconds = fraction; + } + offset = endOffset; + } + } + } + } + + // extract timezone + if (date.length() <= offset) { + throw new IllegalArgumentException("No time zone indicator"); + } + + TimeZone timezone = null; + char timezoneIndicator = date.charAt(offset); + + if (timezoneIndicator == 'Z') { + timezone = TIMEZONE_UTC; + offset += 1; + } else if (timezoneIndicator == '+' || timezoneIndicator == '-') { + String timezoneOffset = date.substring(offset); + + // When timezone has no minutes, we should append it, valid timezones are, for example: + // +00:00, +0000 and +00 + timezoneOffset = timezoneOffset.length() >= 5 ? timezoneOffset : timezoneOffset + "00"; + + offset += timezoneOffset.length(); + // 18-Jun-2015, tatu: Minor simplification, skip offset of "+0000"/"+00:00" + if (timezoneOffset.equals("+0000") || timezoneOffset.equals("+00:00")) { + timezone = TIMEZONE_UTC; + } else { + // 18-Jun-2015, tatu: Looks like offsets only work from GMT, not UTC... + // not sure why, but that's the way it looks. Further, Javadocs for + // `java.util.TimeZone` specifically instruct use of GMT as base for + // custom timezones... odd. + String timezoneId = "GMT" + timezoneOffset; + // String timezoneId = "UTC" + timezoneOffset; + + timezone = TimeZone.getTimeZone(timezoneId); + + String act = timezone.getID(); + if (!act.equals(timezoneId)) { + /* 22-Jan-2015, tatu: Looks like canonical version has colons, but we may be given + * one without. If so, don't sweat. + * Yes, very inefficient. Hopefully not hit often. + * If it becomes a perf problem, add 'loose' comparison instead. + */ + String cleaned = act.replace(":", ""); + if (!cleaned.equals(timezoneId)) { + throw new IndexOutOfBoundsException( + "Mismatching time zone indicator: " + + timezoneId + + " given, resolves to " + + timezone.getID()); + } + } + } + } else { + throw new IndexOutOfBoundsException( + "Invalid time zone indicator '" + timezoneIndicator + "'"); + } + + Calendar calendar = new GregorianCalendar(timezone); + calendar.setLenient(false); + calendar.set(Calendar.YEAR, year); + calendar.set(Calendar.MONTH, month - 1); + calendar.set(Calendar.DAY_OF_MONTH, day); + calendar.set(Calendar.HOUR_OF_DAY, hour); + calendar.set(Calendar.MINUTE, minutes); + calendar.set(Calendar.SECOND, seconds); + calendar.set(Calendar.MILLISECOND, milliseconds); + + pos.setIndex(offset); + return calendar.getTime(); + // If we get a ParseException it'll already have the right message/offset. + // Other exception types can convert here. + } catch (IndexOutOfBoundsException | IllegalArgumentException e) { + fail = e; + } + String input = (date == null) ? null : ('"' + date + '"'); + String msg = fail.getMessage(); + if (msg == null || msg.isEmpty()) { + msg = "(" + fail.getClass().getName() + ")"; + } + ParseException ex = + new ParseException("Failed to parse date [" + input + "]: " + msg, pos.getIndex()); + ex.initCause(fail); + throw ex; + } + + /** + * Check if the expected character exist at the given offset in the value. + * + * @param value the string to check at the specified offset + * @param offset the offset to look for the expected character + * @param expected the expected character + * @return true if the expected character exist at the given offset + */ + private static boolean checkOffset(String value, int offset, char expected) { + return (offset < value.length()) && (value.charAt(offset) == expected); + } + + /** + * Parse an integer located between 2 given offsets in a string + * + * @param value the string to parse + * @param beginIndex the start index for the integer in the string + * @param endIndex the end index for the integer in the string + * @return the int + * @throws NumberFormatException if the value is not a number + */ + private static int parseInt(String value, int beginIndex, int endIndex) + throws NumberFormatException { + if (beginIndex < 0 || endIndex > value.length() || beginIndex > endIndex) { + throw new NumberFormatException(value); + } + // use same logic as in Integer.parseInt() but less generic we're not supporting negative values + int i = beginIndex; + int result = 0; + int digit; + if (i < endIndex) { + digit = Character.digit(value.charAt(i++), 10); + if (digit < 0) { + throw new NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex)); + } + result = -digit; + } + while (i < endIndex) { + digit = Character.digit(value.charAt(i++), 10); + if (digit < 0) { + throw new NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex)); + } + result *= 10; + result -= digit; + } + return -result; + } + + /** + * Zero pad a number to a specified length + * + * @param buffer buffer to use for padding + * @param value the integer value to pad if necessary. + * @param length the length of the string we should zero pad + */ + private static void padInt(StringBuilder buffer, int value, int length) { + String strValue = Integer.toString(value); + for (int i = length - strValue.length(); i > 0; i--) { + buffer.append('0'); + } + buffer.append(strValue); + } + + /** + * Returns the index of the first character in the string that is not a digit, starting at offset. + */ + private static int indexOfNonDigit(String string, int offset) { + for (int i = offset; i < string.length(); i++) { + char c = string.charAt(i); + if (c < '0' || c > '9') { + return i; + } + } + return string.length(); + } +} diff --git a/gson/src/test/java/com/google/gson/internal/bind/util/ISO8601UtilsTest.java b/gson/src/test/java/com/google/gson/internal/bind/util/ISO8601UtilsTest.java index 1c2d254dec..269e3edf5a 100644 --- a/gson/src/test/java/com/google/gson/internal/bind/util/ISO8601UtilsTest.java +++ b/gson/src/test/java/com/google/gson/internal/bind/util/ISO8601UtilsTest.java @@ -1,126 +1,163 @@ -/* - * Copyright (C) 2020 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.gson.internal.bind.util; - -import static com.google.common.truth.Truth.assertThat; -import static org.junit.Assert.assertThrows; - -import java.text.ParseException; -import java.text.ParsePosition; -import java.util.Calendar; -import java.util.Date; -import java.util.GregorianCalendar; -import java.util.Locale; -import java.util.TimeZone; -import org.junit.Test; - -@SuppressWarnings("MemberName") // class name -public class ISO8601UtilsTest { - - private static TimeZone utcTimeZone() { - return TimeZone.getTimeZone("UTC"); - } - - private static GregorianCalendar createUtcCalendar() { - TimeZone utc = utcTimeZone(); - GregorianCalendar calendar = new GregorianCalendar(utc); - // Calendar was created with current time, must clear it - calendar.clear(); - return calendar; - } - - @Test - public void testDateFormatString() { - GregorianCalendar calendar = new GregorianCalendar(utcTimeZone(), Locale.US); - // Calendar was created with current time, must clear it - calendar.clear(); - calendar.set(2018, Calendar.JUNE, 25); - Date date = calendar.getTime(); - String dateStr = ISO8601Utils.format(date); - String expectedDate = "2018-06-25T00:00:00Z"; - assertThat(dateStr).isEqualTo(expectedDate); - } - - @Test - @SuppressWarnings("JavaUtilDate") - public void testDateFormatWithMilliseconds() { - long time = 1530209176870L; - Date date = new Date(time); - String dateStr = ISO8601Utils.format(date, true); - String expectedDate = "2018-06-28T18:06:16.870Z"; - assertThat(dateStr).isEqualTo(expectedDate); - } - - @Test - @SuppressWarnings("JavaUtilDate") - public void testDateFormatWithTimezone() { - long time = 1530209176870L; - Date date = new Date(time); - String dateStr = ISO8601Utils.format(date, true, TimeZone.getTimeZone("Brazil/East")); - String expectedDate = "2018-06-28T15:06:16.870-03:00"; - assertThat(dateStr).isEqualTo(expectedDate); - } - - @Test - @SuppressWarnings("UndefinedEquals") - public void testDateParseWithDefaultTimezone() throws ParseException { - String dateStr = "2018-06-25"; - Date date = ISO8601Utils.parse(dateStr, new ParsePosition(0)); - Date expectedDate = new GregorianCalendar(2018, Calendar.JUNE, 25).getTime(); - assertThat(date).isEqualTo(expectedDate); - } - - @Test - public void testDateParseInvalidDay() { - String dateStr = "2022-12-33"; - assertThrows(ParseException.class, () -> ISO8601Utils.parse(dateStr, new ParsePosition(0))); - } - - @Test - public void testDateParseInvalidMonth() { - String dateStr = "2022-14-30"; - assertThrows(ParseException.class, () -> ISO8601Utils.parse(dateStr, new ParsePosition(0))); - } - - @Test - @SuppressWarnings("UndefinedEquals") - public void testDateParseWithTimezone() throws ParseException { - String dateStr = "2018-06-25T00:00:00-03:00"; - Date date = ISO8601Utils.parse(dateStr, new ParsePosition(0)); - GregorianCalendar calendar = createUtcCalendar(); - calendar.set(2018, Calendar.JUNE, 25, 3, 0); - Date expectedDate = calendar.getTime(); - assertThat(date).isEqualTo(expectedDate); - } - - @Test - @SuppressWarnings("UndefinedEquals") - public void testDateParseSpecialTimezone() throws ParseException { - String dateStr = "2018-06-25T00:02:00-02:58"; - Date date = ISO8601Utils.parse(dateStr, new ParsePosition(0)); - GregorianCalendar calendar = createUtcCalendar(); - calendar.set(2018, Calendar.JUNE, 25, 3, 0); - Date expectedDate = calendar.getTime(); - assertThat(date).isEqualTo(expectedDate); - } - - @Test - public void testDateParseInvalidTime() { - String dateStr = "2018-06-25T61:60:62-03:00"; - assertThrows(ParseException.class, () -> ISO8601Utils.parse(dateStr, new ParsePosition(0))); - } -} +/* + * Copyright (C) 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.gson.internal.bind.util; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import java.text.ParseException; +import java.text.ParsePosition; +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.Locale; +import java.util.TimeZone; +import org.junit.Test; + +@SuppressWarnings("MemberName") // class name +public class ISO8601UtilsTest { + + private static TimeZone utcTimeZone() { + return TimeZone.getTimeZone("UTC"); + } + + private static GregorianCalendar createUtcCalendar() { + TimeZone utc = utcTimeZone(); + GregorianCalendar calendar = new GregorianCalendar(utc); + // Calendar was created with current time, must clear it + calendar.clear(); + return calendar; + } + + @Test + public void testParseDateOnlyDSTGap() throws ParseException { + TimeZone defaultTimeZone = TimeZone.getDefault(); + try { + // 1966-11-01 00:00 does not exist in America/Sao_Paulo because clocks were shifted + // forward from 0:00 to 1:00 at midnight; parsing must not fail for this valid date + TimeZone.setDefault(TimeZone.getTimeZone("America/Sao_Paulo")); + Date date = ISO8601Utils.parse("1966-11-01", new ParsePosition(0)); + + GregorianCalendar calendar = new GregorianCalendar(TimeZone.getTimeZone("America/Sao_Paulo"), Locale.US); + // Calendar was created with current time, must clear it + calendar.clear(); + calendar.setTime(date); + assertThat(calendar.get(Calendar.YEAR)).isEqualTo(1966); + assertThat(calendar.get(Calendar.MONTH)).isEqualTo(Calendar.NOVEMBER); + assertThat(calendar.get(Calendar.DAY_OF_MONTH)).isEqualTo(1); + assertThat(calendar.get(Calendar.HOUR_OF_DAY)).isEqualTo(1); + } finally { + TimeZone.setDefault(defaultTimeZone); + } + } + + @Test + public void testParseInvalidDateOnlyStillFails() { + TimeZone defaultTimeZone = TimeZone.getDefault(); + try { + // Strict parsing introduced for date-only values must keep rejecting dates which do + // not exist, even in time zones where midnight is skipped by a DST transition + TimeZone.setDefault(TimeZone.getTimeZone("America/Sao_Paulo")); + assertThrows(ParseException.class, () -> ISO8601Utils.parse("2021-02-30", new ParsePosition(0))); + assertThrows(ParseException.class, () -> ISO8601Utils.parse("2021-13-01", new ParsePosition(0))); + assertThrows(ParseException.class, () -> ISO8601Utils.parse("1966-00-01", new ParsePosition(0))); + } finally { + TimeZone.setDefault(defaultTimeZone); + } + } + + @Test + public void testDateFormatString() { + GregorianCalendar calendar = new GregorianCalendar(utcTimeZone(), Locale.US); + // Calendar was created with current time, must clear it + calendar.clear(); + calendar.set(2018, Calendar.JUNE, 25); + Date date = calendar.getTime(); + String dateStr = ISO8601Utils.format(date); + String expectedDate = "2018-06-25T00:00:00Z"; + assertThat(dateStr).isEqualTo(expectedDate); + } + + @Test + @SuppressWarnings("JavaUtilDate") + public void testDateFormatWithMilliseconds() { + long time = 1530209176870L; + Date date = new Date(time); + String dateStr = ISO8601Utils.format(date, true); + String expectedDate = "2018-06-28T18:06:16.870Z"; + assertThat(dateStr).isEqualTo(expectedDate); + } + + @Test + @SuppressWarnings("JavaUtilDate") + public void testDateFormatWithTimezone() { + long time = 1530209176870L; + Date date = new Date(time); + String dateStr = ISO8601Utils.format(date, true, TimeZone.getTimeZone("Brazil/East")); + String expectedDate = "2018-06-28T15:06:16.870-03:00"; + assertThat(dateStr).isEqualTo(expectedDate); + } + + @Test + @SuppressWarnings("UndefinedEquals") + public void testDateParseWithDefaultTimezone() throws ParseException { + String dateStr = "2018-06-25"; + Date date = ISO8601Utils.parse(dateStr, new ParsePosition(0)); + Date expectedDate = new GregorianCalendar(2018, Calendar.JUNE, 25).getTime(); + assertThat(date).isEqualTo(expectedDate); + } + + @Test + public void testDateParseInvalidDay() { + String dateStr = "2022-12-33"; + assertThrows(ParseException.class, () -> ISO8601Utils.parse(dateStr, new ParsePosition(0))); + } + + @Test + public void testDateParseInvalidMonth() { + String dateStr = "2022-14-30"; + assertThrows(ParseException.class, () -> ISO8601Utils.parse(dateStr, new ParsePosition(0))); + } + + @Test + @SuppressWarnings("UndefinedEquals") + public void testDateParseWithTimezone() throws ParseException { + String dateStr = "2018-06-25T00:00:00-03:00"; + Date date = ISO8601Utils.parse(dateStr, new ParsePosition(0)); + GregorianCalendar calendar = createUtcCalendar(); + calendar.set(2018, Calendar.JUNE, 25, 3, 0); + Date expectedDate = calendar.getTime(); + assertThat(date).isEqualTo(expectedDate); + } + + @Test + @SuppressWarnings("UndefinedEquals") + public void testDateParseSpecialTimezone() throws ParseException { + String dateStr = "2018-06-25T00:02:00-02:58"; + Date date = ISO8601Utils.parse(dateStr, new ParsePosition(0)); + GregorianCalendar calendar = createUtcCalendar(); + calendar.set(2018, Calendar.JUNE, 25, 3, 0); + Date expectedDate = calendar.getTime(); + assertThat(date).isEqualTo(expectedDate); + } + + @Test + public void testDateParseInvalidTime() { + String dateStr = "2018-06-25T61:60:62-03:00"; + assertThrows(ParseException.class, () -> ISO8601Utils.parse(dateStr, new ParsePosition(0))); + } +} From 3a11c27568f33cd1fa3c8b621eabfd43231047d2 Mon Sep 17 00:00:00 2001 From: zgj-ssslab Date: Sat, 5 Sep 2026 01:09:16 +0800 Subject: [PATCH 2/4] Make DST-gap test assertion robust across timezone data versions The exact resolved hour depends on the DST rules of the JDK's bundled timezone data, which differs between JDK versions. Assert only that the date fields are preserved and the resolved hour is within the day. --- .../com/google/gson/internal/bind/util/ISO8601UtilsTest.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/gson/src/test/java/com/google/gson/internal/bind/util/ISO8601UtilsTest.java b/gson/src/test/java/com/google/gson/internal/bind/util/ISO8601UtilsTest.java index 269e3edf5a..3605c0ae63 100644 --- a/gson/src/test/java/com/google/gson/internal/bind/util/ISO8601UtilsTest.java +++ b/gson/src/test/java/com/google/gson/internal/bind/util/ISO8601UtilsTest.java @@ -59,7 +59,10 @@ public void testParseDateOnlyDSTGap() throws ParseException { assertThat(calendar.get(Calendar.YEAR)).isEqualTo(1966); assertThat(calendar.get(Calendar.MONTH)).isEqualTo(Calendar.NOVEMBER); assertThat(calendar.get(Calendar.DAY_OF_MONTH)).isEqualTo(1); - assertThat(calendar.get(Calendar.HOUR_OF_DAY)).isEqualTo(1); + // The resolved hour depends on the DST rules of the JDK's bundled timezone data + // (midnight itself, or the first hour after the 0:00 -> 1:00 transition), so only + // assert that the date fields are preserved while the hour stays within the day + assertThat(calendar.get(Calendar.HOUR_OF_DAY)).isAnyOf(0, 1); } finally { TimeZone.setDefault(defaultTimeZone); } From 321a5de8231ac1b23a4ad7d63342fa73926077c1 Mon Sep 17 00:00:00 2001 From: zgj-ssslab <1874977055@qq.com> Date: Tue, 15 Sep 2026 20:50:56 +0800 Subject: [PATCH 3/4] Normalize line endings of modified files to LF The two modified files had been committed with CRLF line endings, which fails the spotless check. Re-applied via `mvn spotless:apply`; the only change is removal, no formatting or code changes. --- .../gson/internal/bind/util/ISO8601Utils.java | 806 +++++++++--------- .../internal/bind/util/ISO8601UtilsTest.java | 332 ++++---- 2 files changed, 569 insertions(+), 569 deletions(-) diff --git a/gson/src/main/java/com/google/gson/internal/bind/util/ISO8601Utils.java b/gson/src/main/java/com/google/gson/internal/bind/util/ISO8601Utils.java index 52a8ddde8c..099b38deaa 100644 --- a/gson/src/main/java/com/google/gson/internal/bind/util/ISO8601Utils.java +++ b/gson/src/main/java/com/google/gson/internal/bind/util/ISO8601Utils.java @@ -1,403 +1,403 @@ -/* - * Copyright (C) 2015 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.gson.internal.bind.util; - -import java.text.ParseException; -import java.text.ParsePosition; -import java.util.Calendar; -import java.util.Date; -import java.util.GregorianCalendar; -import java.util.Locale; -import java.util.TimeZone; - -/** - * Utilities methods for manipulating dates in iso8601 format. This is much faster and GC friendly - * than using SimpleDateFormat so highly suitable if you (un)serialize lots of date objects. - * - *

Supported parse format: - * [yyyy-MM-dd|yyyyMMdd][T(hh:mm[:ss[.sss]]|hhmm[ss[.sss]])]?[Z|[+-]hh[:]mm]] - * - * @see this specification - */ -// Date parsing code from Jackson databind ISO8601Utils.java -// https://github.com/FasterXML/jackson-databind/blob/2.8/src/main/java/com/fasterxml/jackson/databind/util/ISO8601Utils.java -@SuppressWarnings("MemberName") // legacy class name -public final class ISO8601Utils { - private ISO8601Utils() {} - - /** - * ID to represent the 'UTC' string, default timezone since Jackson 2.7 - * - * @since 2.7 - */ - private static final String UTC_ID = "UTC"; - - /** - * The UTC timezone, prefetched to avoid more lookups. - * - * @since 2.7 - */ - private static final TimeZone TIMEZONE_UTC = TimeZone.getTimeZone(UTC_ID); - - /* - /********************************************************** - /* Formatting - /********************************************************** - */ - - /** - * Format a date into 'yyyy-MM-ddThh:mm:ssZ' (default timezone, no milliseconds precision) - * - * @param date the date to format - * @return the date formatted as 'yyyy-MM-ddThh:mm:ssZ' - */ - public static String format(Date date) { - return format(date, false, TIMEZONE_UTC); - } - - /** - * Format a date into 'yyyy-MM-ddThh:mm:ss[.sss]Z' (GMT timezone) - * - * @param date the date to format - * @param millis true to include millis precision otherwise false - * @return the date formatted as 'yyyy-MM-ddThh:mm:ss[.sss]Z' - */ - public static String format(Date date, boolean millis) { - return format(date, millis, TIMEZONE_UTC); - } - - /** - * Format date into yyyy-MM-ddThh:mm:ss[.sss][Z|[+-]hh:mm] - * - * @param date the date to format - * @param millis true to include millis precision otherwise false - * @param tz timezone to use for the formatting (UTC will produce 'Z') - * @return the date formatted as yyyy-MM-ddThh:mm:ss[.sss][Z|[+-]hh:mm] - */ - public static String format(Date date, boolean millis, TimeZone tz) { - Calendar calendar = new GregorianCalendar(tz, Locale.US); - calendar.setTime(date); - - // estimate capacity of buffer as close as we can (yeah, that's pedantic ;) - int capacity = "yyyy-MM-ddThh:mm:ss".length(); - capacity += millis ? ".sss".length() : 0; - capacity += tz.getRawOffset() == 0 ? "Z".length() : "+hh:mm".length(); - StringBuilder formatted = new StringBuilder(capacity); - - padInt(formatted, calendar.get(Calendar.YEAR), "yyyy".length()); - formatted.append('-'); - padInt(formatted, calendar.get(Calendar.MONTH) + 1, "MM".length()); - formatted.append('-'); - padInt(formatted, calendar.get(Calendar.DAY_OF_MONTH), "dd".length()); - formatted.append('T'); - padInt(formatted, calendar.get(Calendar.HOUR_OF_DAY), "hh".length()); - formatted.append(':'); - padInt(formatted, calendar.get(Calendar.MINUTE), "mm".length()); - formatted.append(':'); - padInt(formatted, calendar.get(Calendar.SECOND), "ss".length()); - if (millis) { - formatted.append('.'); - padInt(formatted, calendar.get(Calendar.MILLISECOND), "sss".length()); - } - - int offset = tz.getOffset(calendar.getTimeInMillis()); - if (offset != 0) { - int hours = Math.abs((offset / (60 * 1000)) / 60); - int minutes = Math.abs((offset / (60 * 1000)) % 60); - formatted.append(offset < 0 ? '-' : '+'); - padInt(formatted, hours, "hh".length()); - formatted.append(':'); - padInt(formatted, minutes, "mm".length()); - } else { - formatted.append('Z'); - } - - return formatted.toString(); - } - - /* - /********************************************************** - /* Parsing - /********************************************************** - */ - - /** - * Parse a date from ISO-8601 formatted string. It expects a format - * [yyyy-MM-dd|yyyyMMdd][T(hh:mm[:ss[.sss]]|hhmm[ss[.sss]])]?[Z|[+-]hh[:mm]]] - * - * @param date ISO string to parse in the appropriate format. - * @param pos The position to start parsing from, updated to where parsing stopped. - * @return the parsed date - * @throws ParseException if the date is not in the appropriate format - */ - public static Date parse(String date, ParsePosition pos) throws ParseException { - Exception fail = null; - try { - int offset = pos.getIndex(); - - // extract year - int year = parseInt(date, offset, offset += 4); - if (checkOffset(date, offset, '-')) { - offset += 1; - } - - // extract month - int month = parseInt(date, offset, offset += 2); - if (checkOffset(date, offset, '-')) { - offset += 1; - } - - // extract day - int day = parseInt(date, offset, offset += 2); - - // default time value - int hour = 0; - int minutes = 0; - int seconds = 0; - - // always use 0 otherwise returned date will include millis of current time - int milliseconds = 0; - - // if the value has no time component (and no time zone), we are done - boolean hasT = checkOffset(date, offset, 'T'); - - if (!hasT && (date.length() <= offset)) { - Calendar calendar = new GregorianCalendar(year, month - 1, day); - calendar.setLenient(false); - try { - pos.setIndex(offset); - return calendar.getTime(); - } catch (IllegalArgumentException e) { - // The calendar date can be valid while local midnight does not exist in the default - // time zone, for example '1966-11-01' in America/Sao_Paulo where clocks were shifted - // forward from 0:00 to 1:00. In that case resolve the date like java.time's - // LocalDate.atStartOfDay(ZoneId) by using the first existing instant of that day. - // Truly invalid dates (such as '2021-02-30') also fail above, but lenient resolution - // would shift them to a different day, so they are re-thrown below. - Calendar lenientCalendar = new GregorianCalendar(year, month - 1, day); - Date resolved = lenientCalendar.getTime(); - if (lenientCalendar.get(Calendar.YEAR) != year - || lenientCalendar.get(Calendar.MONTH) != month - 1 - || lenientCalendar.get(Calendar.DAY_OF_MONTH) != day) { - throw e; - } - pos.setIndex(offset); - return resolved; - } - } - - if (hasT) { - - // extract hours, minutes, seconds and milliseconds - hour = parseInt(date, offset += 1, offset += 2); - if (checkOffset(date, offset, ':')) { - offset += 1; - } - - minutes = parseInt(date, offset, offset += 2); - if (checkOffset(date, offset, ':')) { - offset += 1; - } - // second and milliseconds can be optional - if (date.length() > offset) { - char c = date.charAt(offset); - if (c != 'Z' && c != '+' && c != '-') { - seconds = parseInt(date, offset, offset += 2); - if (seconds > 59 && seconds < 63) { - seconds = 59; // truncate up to 3 leap seconds - } - // milliseconds can be optional in the format - if (checkOffset(date, offset, '.')) { - offset += 1; - int endOffset = indexOfNonDigit(date, offset + 1); // assume at least one digit - int parseEndOffset = Math.min(endOffset, offset + 3); // parse up to 3 digits - int fraction = parseInt(date, offset, parseEndOffset); - // compensate for "missing" digits - switch (parseEndOffset - offset) { // number of digits parsed - case 2: - milliseconds = fraction * 10; - break; - case 1: - milliseconds = fraction * 100; - break; - default: - milliseconds = fraction; - } - offset = endOffset; - } - } - } - } - - // extract timezone - if (date.length() <= offset) { - throw new IllegalArgumentException("No time zone indicator"); - } - - TimeZone timezone = null; - char timezoneIndicator = date.charAt(offset); - - if (timezoneIndicator == 'Z') { - timezone = TIMEZONE_UTC; - offset += 1; - } else if (timezoneIndicator == '+' || timezoneIndicator == '-') { - String timezoneOffset = date.substring(offset); - - // When timezone has no minutes, we should append it, valid timezones are, for example: - // +00:00, +0000 and +00 - timezoneOffset = timezoneOffset.length() >= 5 ? timezoneOffset : timezoneOffset + "00"; - - offset += timezoneOffset.length(); - // 18-Jun-2015, tatu: Minor simplification, skip offset of "+0000"/"+00:00" - if (timezoneOffset.equals("+0000") || timezoneOffset.equals("+00:00")) { - timezone = TIMEZONE_UTC; - } else { - // 18-Jun-2015, tatu: Looks like offsets only work from GMT, not UTC... - // not sure why, but that's the way it looks. Further, Javadocs for - // `java.util.TimeZone` specifically instruct use of GMT as base for - // custom timezones... odd. - String timezoneId = "GMT" + timezoneOffset; - // String timezoneId = "UTC" + timezoneOffset; - - timezone = TimeZone.getTimeZone(timezoneId); - - String act = timezone.getID(); - if (!act.equals(timezoneId)) { - /* 22-Jan-2015, tatu: Looks like canonical version has colons, but we may be given - * one without. If so, don't sweat. - * Yes, very inefficient. Hopefully not hit often. - * If it becomes a perf problem, add 'loose' comparison instead. - */ - String cleaned = act.replace(":", ""); - if (!cleaned.equals(timezoneId)) { - throw new IndexOutOfBoundsException( - "Mismatching time zone indicator: " - + timezoneId - + " given, resolves to " - + timezone.getID()); - } - } - } - } else { - throw new IndexOutOfBoundsException( - "Invalid time zone indicator '" + timezoneIndicator + "'"); - } - - Calendar calendar = new GregorianCalendar(timezone); - calendar.setLenient(false); - calendar.set(Calendar.YEAR, year); - calendar.set(Calendar.MONTH, month - 1); - calendar.set(Calendar.DAY_OF_MONTH, day); - calendar.set(Calendar.HOUR_OF_DAY, hour); - calendar.set(Calendar.MINUTE, minutes); - calendar.set(Calendar.SECOND, seconds); - calendar.set(Calendar.MILLISECOND, milliseconds); - - pos.setIndex(offset); - return calendar.getTime(); - // If we get a ParseException it'll already have the right message/offset. - // Other exception types can convert here. - } catch (IndexOutOfBoundsException | IllegalArgumentException e) { - fail = e; - } - String input = (date == null) ? null : ('"' + date + '"'); - String msg = fail.getMessage(); - if (msg == null || msg.isEmpty()) { - msg = "(" + fail.getClass().getName() + ")"; - } - ParseException ex = - new ParseException("Failed to parse date [" + input + "]: " + msg, pos.getIndex()); - ex.initCause(fail); - throw ex; - } - - /** - * Check if the expected character exist at the given offset in the value. - * - * @param value the string to check at the specified offset - * @param offset the offset to look for the expected character - * @param expected the expected character - * @return true if the expected character exist at the given offset - */ - private static boolean checkOffset(String value, int offset, char expected) { - return (offset < value.length()) && (value.charAt(offset) == expected); - } - - /** - * Parse an integer located between 2 given offsets in a string - * - * @param value the string to parse - * @param beginIndex the start index for the integer in the string - * @param endIndex the end index for the integer in the string - * @return the int - * @throws NumberFormatException if the value is not a number - */ - private static int parseInt(String value, int beginIndex, int endIndex) - throws NumberFormatException { - if (beginIndex < 0 || endIndex > value.length() || beginIndex > endIndex) { - throw new NumberFormatException(value); - } - // use same logic as in Integer.parseInt() but less generic we're not supporting negative values - int i = beginIndex; - int result = 0; - int digit; - if (i < endIndex) { - digit = Character.digit(value.charAt(i++), 10); - if (digit < 0) { - throw new NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex)); - } - result = -digit; - } - while (i < endIndex) { - digit = Character.digit(value.charAt(i++), 10); - if (digit < 0) { - throw new NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex)); - } - result *= 10; - result -= digit; - } - return -result; - } - - /** - * Zero pad a number to a specified length - * - * @param buffer buffer to use for padding - * @param value the integer value to pad if necessary. - * @param length the length of the string we should zero pad - */ - private static void padInt(StringBuilder buffer, int value, int length) { - String strValue = Integer.toString(value); - for (int i = length - strValue.length(); i > 0; i--) { - buffer.append('0'); - } - buffer.append(strValue); - } - - /** - * Returns the index of the first character in the string that is not a digit, starting at offset. - */ - private static int indexOfNonDigit(String string, int offset) { - for (int i = offset; i < string.length(); i++) { - char c = string.charAt(i); - if (c < '0' || c > '9') { - return i; - } - } - return string.length(); - } -} +/* + * Copyright (C) 2015 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.gson.internal.bind.util; + +import java.text.ParseException; +import java.text.ParsePosition; +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.Locale; +import java.util.TimeZone; + +/** + * Utilities methods for manipulating dates in iso8601 format. This is much faster and GC friendly + * than using SimpleDateFormat so highly suitable if you (un)serialize lots of date objects. + * + *

Supported parse format: + * [yyyy-MM-dd|yyyyMMdd][T(hh:mm[:ss[.sss]]|hhmm[ss[.sss]])]?[Z|[+-]hh[:]mm]] + * + * @see this specification + */ +// Date parsing code from Jackson databind ISO8601Utils.java +// https://github.com/FasterXML/jackson-databind/blob/2.8/src/main/java/com/fasterxml/jackson/databind/util/ISO8601Utils.java +@SuppressWarnings("MemberName") // legacy class name +public final class ISO8601Utils { + private ISO8601Utils() {} + + /** + * ID to represent the 'UTC' string, default timezone since Jackson 2.7 + * + * @since 2.7 + */ + private static final String UTC_ID = "UTC"; + + /** + * The UTC timezone, prefetched to avoid more lookups. + * + * @since 2.7 + */ + private static final TimeZone TIMEZONE_UTC = TimeZone.getTimeZone(UTC_ID); + + /* + /********************************************************** + /* Formatting + /********************************************************** + */ + + /** + * Format a date into 'yyyy-MM-ddThh:mm:ssZ' (default timezone, no milliseconds precision) + * + * @param date the date to format + * @return the date formatted as 'yyyy-MM-ddThh:mm:ssZ' + */ + public static String format(Date date) { + return format(date, false, TIMEZONE_UTC); + } + + /** + * Format a date into 'yyyy-MM-ddThh:mm:ss[.sss]Z' (GMT timezone) + * + * @param date the date to format + * @param millis true to include millis precision otherwise false + * @return the date formatted as 'yyyy-MM-ddThh:mm:ss[.sss]Z' + */ + public static String format(Date date, boolean millis) { + return format(date, millis, TIMEZONE_UTC); + } + + /** + * Format date into yyyy-MM-ddThh:mm:ss[.sss][Z|[+-]hh:mm] + * + * @param date the date to format + * @param millis true to include millis precision otherwise false + * @param tz timezone to use for the formatting (UTC will produce 'Z') + * @return the date formatted as yyyy-MM-ddThh:mm:ss[.sss][Z|[+-]hh:mm] + */ + public static String format(Date date, boolean millis, TimeZone tz) { + Calendar calendar = new GregorianCalendar(tz, Locale.US); + calendar.setTime(date); + + // estimate capacity of buffer as close as we can (yeah, that's pedantic ;) + int capacity = "yyyy-MM-ddThh:mm:ss".length(); + capacity += millis ? ".sss".length() : 0; + capacity += tz.getRawOffset() == 0 ? "Z".length() : "+hh:mm".length(); + StringBuilder formatted = new StringBuilder(capacity); + + padInt(formatted, calendar.get(Calendar.YEAR), "yyyy".length()); + formatted.append('-'); + padInt(formatted, calendar.get(Calendar.MONTH) + 1, "MM".length()); + formatted.append('-'); + padInt(formatted, calendar.get(Calendar.DAY_OF_MONTH), "dd".length()); + formatted.append('T'); + padInt(formatted, calendar.get(Calendar.HOUR_OF_DAY), "hh".length()); + formatted.append(':'); + padInt(formatted, calendar.get(Calendar.MINUTE), "mm".length()); + formatted.append(':'); + padInt(formatted, calendar.get(Calendar.SECOND), "ss".length()); + if (millis) { + formatted.append('.'); + padInt(formatted, calendar.get(Calendar.MILLISECOND), "sss".length()); + } + + int offset = tz.getOffset(calendar.getTimeInMillis()); + if (offset != 0) { + int hours = Math.abs((offset / (60 * 1000)) / 60); + int minutes = Math.abs((offset / (60 * 1000)) % 60); + formatted.append(offset < 0 ? '-' : '+'); + padInt(formatted, hours, "hh".length()); + formatted.append(':'); + padInt(formatted, minutes, "mm".length()); + } else { + formatted.append('Z'); + } + + return formatted.toString(); + } + + /* + /********************************************************** + /* Parsing + /********************************************************** + */ + + /** + * Parse a date from ISO-8601 formatted string. It expects a format + * [yyyy-MM-dd|yyyyMMdd][T(hh:mm[:ss[.sss]]|hhmm[ss[.sss]])]?[Z|[+-]hh[:mm]]] + * + * @param date ISO string to parse in the appropriate format. + * @param pos The position to start parsing from, updated to where parsing stopped. + * @return the parsed date + * @throws ParseException if the date is not in the appropriate format + */ + public static Date parse(String date, ParsePosition pos) throws ParseException { + Exception fail = null; + try { + int offset = pos.getIndex(); + + // extract year + int year = parseInt(date, offset, offset += 4); + if (checkOffset(date, offset, '-')) { + offset += 1; + } + + // extract month + int month = parseInt(date, offset, offset += 2); + if (checkOffset(date, offset, '-')) { + offset += 1; + } + + // extract day + int day = parseInt(date, offset, offset += 2); + + // default time value + int hour = 0; + int minutes = 0; + int seconds = 0; + + // always use 0 otherwise returned date will include millis of current time + int milliseconds = 0; + + // if the value has no time component (and no time zone), we are done + boolean hasT = checkOffset(date, offset, 'T'); + + if (!hasT && (date.length() <= offset)) { + Calendar calendar = new GregorianCalendar(year, month - 1, day); + calendar.setLenient(false); + try { + pos.setIndex(offset); + return calendar.getTime(); + } catch (IllegalArgumentException e) { + // The calendar date can be valid while local midnight does not exist in the default + // time zone, for example '1966-11-01' in America/Sao_Paulo where clocks were shifted + // forward from 0:00 to 1:00. In that case resolve the date like java.time's + // LocalDate.atStartOfDay(ZoneId) by using the first existing instant of that day. + // Truly invalid dates (such as '2021-02-30') also fail above, but lenient resolution + // would shift them to a different day, so they are re-thrown below. + Calendar lenientCalendar = new GregorianCalendar(year, month - 1, day); + Date resolved = lenientCalendar.getTime(); + if (lenientCalendar.get(Calendar.YEAR) != year + || lenientCalendar.get(Calendar.MONTH) != month - 1 + || lenientCalendar.get(Calendar.DAY_OF_MONTH) != day) { + throw e; + } + pos.setIndex(offset); + return resolved; + } + } + + if (hasT) { + + // extract hours, minutes, seconds and milliseconds + hour = parseInt(date, offset += 1, offset += 2); + if (checkOffset(date, offset, ':')) { + offset += 1; + } + + minutes = parseInt(date, offset, offset += 2); + if (checkOffset(date, offset, ':')) { + offset += 1; + } + // second and milliseconds can be optional + if (date.length() > offset) { + char c = date.charAt(offset); + if (c != 'Z' && c != '+' && c != '-') { + seconds = parseInt(date, offset, offset += 2); + if (seconds > 59 && seconds < 63) { + seconds = 59; // truncate up to 3 leap seconds + } + // milliseconds can be optional in the format + if (checkOffset(date, offset, '.')) { + offset += 1; + int endOffset = indexOfNonDigit(date, offset + 1); // assume at least one digit + int parseEndOffset = Math.min(endOffset, offset + 3); // parse up to 3 digits + int fraction = parseInt(date, offset, parseEndOffset); + // compensate for "missing" digits + switch (parseEndOffset - offset) { // number of digits parsed + case 2: + milliseconds = fraction * 10; + break; + case 1: + milliseconds = fraction * 100; + break; + default: + milliseconds = fraction; + } + offset = endOffset; + } + } + } + } + + // extract timezone + if (date.length() <= offset) { + throw new IllegalArgumentException("No time zone indicator"); + } + + TimeZone timezone = null; + char timezoneIndicator = date.charAt(offset); + + if (timezoneIndicator == 'Z') { + timezone = TIMEZONE_UTC; + offset += 1; + } else if (timezoneIndicator == '+' || timezoneIndicator == '-') { + String timezoneOffset = date.substring(offset); + + // When timezone has no minutes, we should append it, valid timezones are, for example: + // +00:00, +0000 and +00 + timezoneOffset = timezoneOffset.length() >= 5 ? timezoneOffset : timezoneOffset + "00"; + + offset += timezoneOffset.length(); + // 18-Jun-2015, tatu: Minor simplification, skip offset of "+0000"/"+00:00" + if (timezoneOffset.equals("+0000") || timezoneOffset.equals("+00:00")) { + timezone = TIMEZONE_UTC; + } else { + // 18-Jun-2015, tatu: Looks like offsets only work from GMT, not UTC... + // not sure why, but that's the way it looks. Further, Javadocs for + // `java.util.TimeZone` specifically instruct use of GMT as base for + // custom timezones... odd. + String timezoneId = "GMT" + timezoneOffset; + // String timezoneId = "UTC" + timezoneOffset; + + timezone = TimeZone.getTimeZone(timezoneId); + + String act = timezone.getID(); + if (!act.equals(timezoneId)) { + /* 22-Jan-2015, tatu: Looks like canonical version has colons, but we may be given + * one without. If so, don't sweat. + * Yes, very inefficient. Hopefully not hit often. + * If it becomes a perf problem, add 'loose' comparison instead. + */ + String cleaned = act.replace(":", ""); + if (!cleaned.equals(timezoneId)) { + throw new IndexOutOfBoundsException( + "Mismatching time zone indicator: " + + timezoneId + + " given, resolves to " + + timezone.getID()); + } + } + } + } else { + throw new IndexOutOfBoundsException( + "Invalid time zone indicator '" + timezoneIndicator + "'"); + } + + Calendar calendar = new GregorianCalendar(timezone); + calendar.setLenient(false); + calendar.set(Calendar.YEAR, year); + calendar.set(Calendar.MONTH, month - 1); + calendar.set(Calendar.DAY_OF_MONTH, day); + calendar.set(Calendar.HOUR_OF_DAY, hour); + calendar.set(Calendar.MINUTE, minutes); + calendar.set(Calendar.SECOND, seconds); + calendar.set(Calendar.MILLISECOND, milliseconds); + + pos.setIndex(offset); + return calendar.getTime(); + // If we get a ParseException it'll already have the right message/offset. + // Other exception types can convert here. + } catch (IndexOutOfBoundsException | IllegalArgumentException e) { + fail = e; + } + String input = (date == null) ? null : ('"' + date + '"'); + String msg = fail.getMessage(); + if (msg == null || msg.isEmpty()) { + msg = "(" + fail.getClass().getName() + ")"; + } + ParseException ex = + new ParseException("Failed to parse date [" + input + "]: " + msg, pos.getIndex()); + ex.initCause(fail); + throw ex; + } + + /** + * Check if the expected character exist at the given offset in the value. + * + * @param value the string to check at the specified offset + * @param offset the offset to look for the expected character + * @param expected the expected character + * @return true if the expected character exist at the given offset + */ + private static boolean checkOffset(String value, int offset, char expected) { + return (offset < value.length()) && (value.charAt(offset) == expected); + } + + /** + * Parse an integer located between 2 given offsets in a string + * + * @param value the string to parse + * @param beginIndex the start index for the integer in the string + * @param endIndex the end index for the integer in the string + * @return the int + * @throws NumberFormatException if the value is not a number + */ + private static int parseInt(String value, int beginIndex, int endIndex) + throws NumberFormatException { + if (beginIndex < 0 || endIndex > value.length() || beginIndex > endIndex) { + throw new NumberFormatException(value); + } + // use same logic as in Integer.parseInt() but less generic we're not supporting negative values + int i = beginIndex; + int result = 0; + int digit; + if (i < endIndex) { + digit = Character.digit(value.charAt(i++), 10); + if (digit < 0) { + throw new NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex)); + } + result = -digit; + } + while (i < endIndex) { + digit = Character.digit(value.charAt(i++), 10); + if (digit < 0) { + throw new NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex)); + } + result *= 10; + result -= digit; + } + return -result; + } + + /** + * Zero pad a number to a specified length + * + * @param buffer buffer to use for padding + * @param value the integer value to pad if necessary. + * @param length the length of the string we should zero pad + */ + private static void padInt(StringBuilder buffer, int value, int length) { + String strValue = Integer.toString(value); + for (int i = length - strValue.length(); i > 0; i--) { + buffer.append('0'); + } + buffer.append(strValue); + } + + /** + * Returns the index of the first character in the string that is not a digit, starting at offset. + */ + private static int indexOfNonDigit(String string, int offset) { + for (int i = offset; i < string.length(); i++) { + char c = string.charAt(i); + if (c < '0' || c > '9') { + return i; + } + } + return string.length(); + } +} diff --git a/gson/src/test/java/com/google/gson/internal/bind/util/ISO8601UtilsTest.java b/gson/src/test/java/com/google/gson/internal/bind/util/ISO8601UtilsTest.java index 3605c0ae63..76d7ee7d87 100644 --- a/gson/src/test/java/com/google/gson/internal/bind/util/ISO8601UtilsTest.java +++ b/gson/src/test/java/com/google/gson/internal/bind/util/ISO8601UtilsTest.java @@ -1,166 +1,166 @@ -/* - * Copyright (C) 2020 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.gson.internal.bind.util; - -import static com.google.common.truth.Truth.assertThat; -import static org.junit.Assert.assertThrows; - -import java.text.ParseException; -import java.text.ParsePosition; -import java.util.Calendar; -import java.util.Date; -import java.util.GregorianCalendar; -import java.util.Locale; -import java.util.TimeZone; -import org.junit.Test; - -@SuppressWarnings("MemberName") // class name -public class ISO8601UtilsTest { - - private static TimeZone utcTimeZone() { - return TimeZone.getTimeZone("UTC"); - } - - private static GregorianCalendar createUtcCalendar() { - TimeZone utc = utcTimeZone(); - GregorianCalendar calendar = new GregorianCalendar(utc); - // Calendar was created with current time, must clear it - calendar.clear(); - return calendar; - } - - @Test - public void testParseDateOnlyDSTGap() throws ParseException { - TimeZone defaultTimeZone = TimeZone.getDefault(); - try { - // 1966-11-01 00:00 does not exist in America/Sao_Paulo because clocks were shifted - // forward from 0:00 to 1:00 at midnight; parsing must not fail for this valid date - TimeZone.setDefault(TimeZone.getTimeZone("America/Sao_Paulo")); - Date date = ISO8601Utils.parse("1966-11-01", new ParsePosition(0)); - - GregorianCalendar calendar = new GregorianCalendar(TimeZone.getTimeZone("America/Sao_Paulo"), Locale.US); - // Calendar was created with current time, must clear it - calendar.clear(); - calendar.setTime(date); - assertThat(calendar.get(Calendar.YEAR)).isEqualTo(1966); - assertThat(calendar.get(Calendar.MONTH)).isEqualTo(Calendar.NOVEMBER); - assertThat(calendar.get(Calendar.DAY_OF_MONTH)).isEqualTo(1); - // The resolved hour depends on the DST rules of the JDK's bundled timezone data - // (midnight itself, or the first hour after the 0:00 -> 1:00 transition), so only - // assert that the date fields are preserved while the hour stays within the day - assertThat(calendar.get(Calendar.HOUR_OF_DAY)).isAnyOf(0, 1); - } finally { - TimeZone.setDefault(defaultTimeZone); - } - } - - @Test - public void testParseInvalidDateOnlyStillFails() { - TimeZone defaultTimeZone = TimeZone.getDefault(); - try { - // Strict parsing introduced for date-only values must keep rejecting dates which do - // not exist, even in time zones where midnight is skipped by a DST transition - TimeZone.setDefault(TimeZone.getTimeZone("America/Sao_Paulo")); - assertThrows(ParseException.class, () -> ISO8601Utils.parse("2021-02-30", new ParsePosition(0))); - assertThrows(ParseException.class, () -> ISO8601Utils.parse("2021-13-01", new ParsePosition(0))); - assertThrows(ParseException.class, () -> ISO8601Utils.parse("1966-00-01", new ParsePosition(0))); - } finally { - TimeZone.setDefault(defaultTimeZone); - } - } - - @Test - public void testDateFormatString() { - GregorianCalendar calendar = new GregorianCalendar(utcTimeZone(), Locale.US); - // Calendar was created with current time, must clear it - calendar.clear(); - calendar.set(2018, Calendar.JUNE, 25); - Date date = calendar.getTime(); - String dateStr = ISO8601Utils.format(date); - String expectedDate = "2018-06-25T00:00:00Z"; - assertThat(dateStr).isEqualTo(expectedDate); - } - - @Test - @SuppressWarnings("JavaUtilDate") - public void testDateFormatWithMilliseconds() { - long time = 1530209176870L; - Date date = new Date(time); - String dateStr = ISO8601Utils.format(date, true); - String expectedDate = "2018-06-28T18:06:16.870Z"; - assertThat(dateStr).isEqualTo(expectedDate); - } - - @Test - @SuppressWarnings("JavaUtilDate") - public void testDateFormatWithTimezone() { - long time = 1530209176870L; - Date date = new Date(time); - String dateStr = ISO8601Utils.format(date, true, TimeZone.getTimeZone("Brazil/East")); - String expectedDate = "2018-06-28T15:06:16.870-03:00"; - assertThat(dateStr).isEqualTo(expectedDate); - } - - @Test - @SuppressWarnings("UndefinedEquals") - public void testDateParseWithDefaultTimezone() throws ParseException { - String dateStr = "2018-06-25"; - Date date = ISO8601Utils.parse(dateStr, new ParsePosition(0)); - Date expectedDate = new GregorianCalendar(2018, Calendar.JUNE, 25).getTime(); - assertThat(date).isEqualTo(expectedDate); - } - - @Test - public void testDateParseInvalidDay() { - String dateStr = "2022-12-33"; - assertThrows(ParseException.class, () -> ISO8601Utils.parse(dateStr, new ParsePosition(0))); - } - - @Test - public void testDateParseInvalidMonth() { - String dateStr = "2022-14-30"; - assertThrows(ParseException.class, () -> ISO8601Utils.parse(dateStr, new ParsePosition(0))); - } - - @Test - @SuppressWarnings("UndefinedEquals") - public void testDateParseWithTimezone() throws ParseException { - String dateStr = "2018-06-25T00:00:00-03:00"; - Date date = ISO8601Utils.parse(dateStr, new ParsePosition(0)); - GregorianCalendar calendar = createUtcCalendar(); - calendar.set(2018, Calendar.JUNE, 25, 3, 0); - Date expectedDate = calendar.getTime(); - assertThat(date).isEqualTo(expectedDate); - } - - @Test - @SuppressWarnings("UndefinedEquals") - public void testDateParseSpecialTimezone() throws ParseException { - String dateStr = "2018-06-25T00:02:00-02:58"; - Date date = ISO8601Utils.parse(dateStr, new ParsePosition(0)); - GregorianCalendar calendar = createUtcCalendar(); - calendar.set(2018, Calendar.JUNE, 25, 3, 0); - Date expectedDate = calendar.getTime(); - assertThat(date).isEqualTo(expectedDate); - } - - @Test - public void testDateParseInvalidTime() { - String dateStr = "2018-06-25T61:60:62-03:00"; - assertThrows(ParseException.class, () -> ISO8601Utils.parse(dateStr, new ParsePosition(0))); - } -} +/* + * Copyright (C) 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.gson.internal.bind.util; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import java.text.ParseException; +import java.text.ParsePosition; +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.Locale; +import java.util.TimeZone; +import org.junit.Test; + +@SuppressWarnings("MemberName") // class name +public class ISO8601UtilsTest { + + private static TimeZone utcTimeZone() { + return TimeZone.getTimeZone("UTC"); + } + + private static GregorianCalendar createUtcCalendar() { + TimeZone utc = utcTimeZone(); + GregorianCalendar calendar = new GregorianCalendar(utc); + // Calendar was created with current time, must clear it + calendar.clear(); + return calendar; + } + + @Test + public void testParseDateOnlyDSTGap() throws ParseException { + TimeZone defaultTimeZone = TimeZone.getDefault(); + try { + // 1966-11-01 00:00 does not exist in America/Sao_Paulo because clocks were shifted + // forward from 0:00 to 1:00 at midnight; parsing must not fail for this valid date + TimeZone.setDefault(TimeZone.getTimeZone("America/Sao_Paulo")); + Date date = ISO8601Utils.parse("1966-11-01", new ParsePosition(0)); + + GregorianCalendar calendar = new GregorianCalendar(TimeZone.getTimeZone("America/Sao_Paulo"), Locale.US); + // Calendar was created with current time, must clear it + calendar.clear(); + calendar.setTime(date); + assertThat(calendar.get(Calendar.YEAR)).isEqualTo(1966); + assertThat(calendar.get(Calendar.MONTH)).isEqualTo(Calendar.NOVEMBER); + assertThat(calendar.get(Calendar.DAY_OF_MONTH)).isEqualTo(1); + // The resolved hour depends on the DST rules of the JDK's bundled timezone data + // (midnight itself, or the first hour after the 0:00 -> 1:00 transition), so only + // assert that the date fields are preserved while the hour stays within the day + assertThat(calendar.get(Calendar.HOUR_OF_DAY)).isAnyOf(0, 1); + } finally { + TimeZone.setDefault(defaultTimeZone); + } + } + + @Test + public void testParseInvalidDateOnlyStillFails() { + TimeZone defaultTimeZone = TimeZone.getDefault(); + try { + // Strict parsing introduced for date-only values must keep rejecting dates which do + // not exist, even in time zones where midnight is skipped by a DST transition + TimeZone.setDefault(TimeZone.getTimeZone("America/Sao_Paulo")); + assertThrows(ParseException.class, () -> ISO8601Utils.parse("2021-02-30", new ParsePosition(0))); + assertThrows(ParseException.class, () -> ISO8601Utils.parse("2021-13-01", new ParsePosition(0))); + assertThrows(ParseException.class, () -> ISO8601Utils.parse("1966-00-01", new ParsePosition(0))); + } finally { + TimeZone.setDefault(defaultTimeZone); + } + } + + @Test + public void testDateFormatString() { + GregorianCalendar calendar = new GregorianCalendar(utcTimeZone(), Locale.US); + // Calendar was created with current time, must clear it + calendar.clear(); + calendar.set(2018, Calendar.JUNE, 25); + Date date = calendar.getTime(); + String dateStr = ISO8601Utils.format(date); + String expectedDate = "2018-06-25T00:00:00Z"; + assertThat(dateStr).isEqualTo(expectedDate); + } + + @Test + @SuppressWarnings("JavaUtilDate") + public void testDateFormatWithMilliseconds() { + long time = 1530209176870L; + Date date = new Date(time); + String dateStr = ISO8601Utils.format(date, true); + String expectedDate = "2018-06-28T18:06:16.870Z"; + assertThat(dateStr).isEqualTo(expectedDate); + } + + @Test + @SuppressWarnings("JavaUtilDate") + public void testDateFormatWithTimezone() { + long time = 1530209176870L; + Date date = new Date(time); + String dateStr = ISO8601Utils.format(date, true, TimeZone.getTimeZone("Brazil/East")); + String expectedDate = "2018-06-28T15:06:16.870-03:00"; + assertThat(dateStr).isEqualTo(expectedDate); + } + + @Test + @SuppressWarnings("UndefinedEquals") + public void testDateParseWithDefaultTimezone() throws ParseException { + String dateStr = "2018-06-25"; + Date date = ISO8601Utils.parse(dateStr, new ParsePosition(0)); + Date expectedDate = new GregorianCalendar(2018, Calendar.JUNE, 25).getTime(); + assertThat(date).isEqualTo(expectedDate); + } + + @Test + public void testDateParseInvalidDay() { + String dateStr = "2022-12-33"; + assertThrows(ParseException.class, () -> ISO8601Utils.parse(dateStr, new ParsePosition(0))); + } + + @Test + public void testDateParseInvalidMonth() { + String dateStr = "2022-14-30"; + assertThrows(ParseException.class, () -> ISO8601Utils.parse(dateStr, new ParsePosition(0))); + } + + @Test + @SuppressWarnings("UndefinedEquals") + public void testDateParseWithTimezone() throws ParseException { + String dateStr = "2018-06-25T00:00:00-03:00"; + Date date = ISO8601Utils.parse(dateStr, new ParsePosition(0)); + GregorianCalendar calendar = createUtcCalendar(); + calendar.set(2018, Calendar.JUNE, 25, 3, 0); + Date expectedDate = calendar.getTime(); + assertThat(date).isEqualTo(expectedDate); + } + + @Test + @SuppressWarnings("UndefinedEquals") + public void testDateParseSpecialTimezone() throws ParseException { + String dateStr = "2018-06-25T00:02:00-02:58"; + Date date = ISO8601Utils.parse(dateStr, new ParsePosition(0)); + GregorianCalendar calendar = createUtcCalendar(); + calendar.set(2018, Calendar.JUNE, 25, 3, 0); + Date expectedDate = calendar.getTime(); + assertThat(date).isEqualTo(expectedDate); + } + + @Test + public void testDateParseInvalidTime() { + String dateStr = "2018-06-25T61:60:62-03:00"; + assertThrows(ParseException.class, () -> ISO8601Utils.parse(dateStr, new ParsePosition(0))); + } +} From f7d81047c4e99ff6ddc94c7699d1ae7fc12ff622 Mon Sep 17 00:00:00 2001 From: zgj-ssslab <1874977055@qq.com> Date: Tue, 15 Sep 2026 20:56:35 +0800 Subject: [PATCH 4/4] Apply spotless formatting --- .../gson/internal/bind/util/ISO8601UtilsTest.java | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/gson/src/test/java/com/google/gson/internal/bind/util/ISO8601UtilsTest.java b/gson/src/test/java/com/google/gson/internal/bind/util/ISO8601UtilsTest.java index 76d7ee7d87..1867917de6 100644 --- a/gson/src/test/java/com/google/gson/internal/bind/util/ISO8601UtilsTest.java +++ b/gson/src/test/java/com/google/gson/internal/bind/util/ISO8601UtilsTest.java @@ -52,7 +52,8 @@ public void testParseDateOnlyDSTGap() throws ParseException { TimeZone.setDefault(TimeZone.getTimeZone("America/Sao_Paulo")); Date date = ISO8601Utils.parse("1966-11-01", new ParsePosition(0)); - GregorianCalendar calendar = new GregorianCalendar(TimeZone.getTimeZone("America/Sao_Paulo"), Locale.US); + GregorianCalendar calendar = + new GregorianCalendar(TimeZone.getTimeZone("America/Sao_Paulo"), Locale.US); // Calendar was created with current time, must clear it calendar.clear(); calendar.setTime(date); @@ -75,9 +76,12 @@ public void testParseInvalidDateOnlyStillFails() { // Strict parsing introduced for date-only values must keep rejecting dates which do // not exist, even in time zones where midnight is skipped by a DST transition TimeZone.setDefault(TimeZone.getTimeZone("America/Sao_Paulo")); - assertThrows(ParseException.class, () -> ISO8601Utils.parse("2021-02-30", new ParsePosition(0))); - assertThrows(ParseException.class, () -> ISO8601Utils.parse("2021-13-01", new ParsePosition(0))); - assertThrows(ParseException.class, () -> ISO8601Utils.parse("1966-00-01", new ParsePosition(0))); + assertThrows( + ParseException.class, () -> ISO8601Utils.parse("2021-02-30", new ParsePosition(0))); + assertThrows( + ParseException.class, () -> ISO8601Utils.parse("2021-13-01", new ParsePosition(0))); + assertThrows( + ParseException.class, () -> ISO8601Utils.parse("1966-00-01", new ParsePosition(0))); } finally { TimeZone.setDefault(defaultTimeZone); }