From a03872c68a5a9c13cfc7a5710b9cc3a5bd230d2d Mon Sep 17 00:00:00 2001 From: BigDataDZ <76271875+BigDataDZ@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:52:34 +0800 Subject: [PATCH] fix: reject invalid calendar dates in default date parsing instead of normalizing them Default string-to-date conversion was lenient: SimpleDateFormat kept its default lenient behavior and DateTimeFormatter used the SMART resolver, so malformed spreadsheet text like 2024-02-31 silently became a different valid date (2024-03-02 / 2024-02-29). Disable lenient parsing on the cached SimpleDateFormat instances and parse through ResolverStyle.STRICT on the java.time paths. Strict resolution cannot resolve the year-of-era letter y without era information, so the strict cache rewrites y to the proleptic-year letter u (unless the pattern carries an era section); strict formatters are cached under a prefixed key so formatting behavior is unchanged. Closes #1157 --- .../apache/fesod/sheet/util/DateUtils.java | 72 +++++++++++++-- .../date/DateStringConverterTest.java | 69 ++++++++++++++ .../LocalDateStringConverterTest.java | 91 +++++++++++++++++++ 3 files changed, 226 insertions(+), 6 deletions(-) create mode 100644 fesod-sheet/src/test/java/org/apache/fesod/sheet/converters/date/DateStringConverterTest.java create mode 100644 fesod-sheet/src/test/java/org/apache/fesod/sheet/converters/localdate/LocalDateStringConverterTest.java diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/util/DateUtils.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/util/DateUtils.java index 9fd5365a1..e296d4d8b 100644 --- a/fesod-sheet/src/main/java/org/apache/fesod/sheet/util/DateUtils.java +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/util/DateUtils.java @@ -33,6 +33,7 @@ import java.time.LocalDateTime; import java.time.LocalTime; import java.time.format.DateTimeFormatter; +import java.time.format.ResolverStyle; import java.util.Calendar; import java.util.Date; import java.util.HashMap; @@ -58,6 +59,12 @@ public class DateUtils { private static final int MAX_FORMAT_CACHE_SIZE = 64; + /** + * Prefix marking strict-parsing formatters in the format cache, so they never collide with the + * non-strict formatter of an equal pattern. + */ + private static final String STRICT_CACHE_PREFIX = "strict:"; + /** * Is a cache of dates */ @@ -131,7 +138,7 @@ public static LocalDateTime parseLocalDateTime(String dateString, String dateFor if (StringUtils.isEmpty(dateFormat)) { dateFormat = switchDateFormat(dateString); } - return LocalDateTime.parse(dateString, getCacheDateTimeFormat(dateFormat, local)); + return LocalDateTime.parse(dateString, getCacheDateTimeFormat(dateFormat, local, true)); } /** @@ -146,7 +153,7 @@ public static LocalDate parseLocalDate(String dateString, String dateFormat, Loc if (StringUtils.isEmpty(dateFormat)) { dateFormat = switchDateFormat(dateString); } - return LocalDate.parse(dateString, getCacheDateTimeFormat(dateFormat, local)); + return LocalDate.parse(dateString, getCacheDateTimeFormat(dateFormat, local, true)); } /** @@ -161,7 +168,7 @@ public static LocalTime parseLocalTime(String timeString, String timeFormat, Loc if (StringUtils.isEmpty(timeFormat)) { timeFormat = switchTimeFormat(timeString); } - return LocalTime.parse(timeString, getCacheDateTimeFormat(timeFormat, local)); + return LocalTime.parse(timeString, getCacheDateTimeFormat(timeFormat, local, true)); } /** @@ -376,6 +383,10 @@ public static boolean isDate1904(ExcelContentProperty contentProperty, GlobalCon } private static DateTimeFormatter getCacheDateTimeFormat(String dateFormat, Locale locale) { + return getCacheDateTimeFormat(dateFormat, locale, false); + } + + private static DateTimeFormatter getCacheDateTimeFormat(String dateFormat, Locale locale, boolean strictParsing) { Locale actualLocale = locale == null ? Locale.getDefault(Locale.Category.FORMAT) : locale; Map> localeCache = DATE_TIME_FORMATTER_THREAD_LOCAL.get(); if (localeCache == null) { @@ -387,14 +398,61 @@ private static DateTimeFormatter getCacheDateTimeFormat(String dateFormat, Local formatCache = MapUtils.newBoundedMap(MAX_FORMAT_CACHE_SIZE); localeCache.put(actualLocale, formatCache); } - DateTimeFormatter formatter = formatCache.get(dateFormat); + String cacheKey = strictParsing ? STRICT_CACHE_PREFIX + toStrictParsePattern(dateFormat) : dateFormat; + DateTimeFormatter formatter = formatCache.get(cacheKey); if (formatter == null) { - formatter = DateTimeFormatter.ofPattern(dateFormat, actualLocale); - formatCache.put(dateFormat, formatter); + String pattern = strictParsing ? cacheKey.substring(STRICT_CACHE_PREFIX.length()) : dateFormat; + formatter = DateTimeFormatter.ofPattern(pattern, actualLocale); + if (strictParsing) { + formatter = formatter.withResolverStyle(ResolverStyle.STRICT); + } + formatCache.put(cacheKey, formatter); } return formatter; } + /** + * Rewrites a date pattern for strict parsing. The year-of-era letter {@code y} cannot be + * resolved without era information that simple patterns do not carry, so it is replaced by the + * proleptic-year letter {@code u} unless the pattern already contains an era section. Quoted + * literals are kept as-is. + * + * @param dateFormat the original pattern + * @return a pattern equivalent to the original one but resolvable under {@code STRICT} + */ + private static String toStrictParsePattern(String dateFormat) { + boolean containsEra = false; + boolean containsYearOfEra = false; + boolean inQuote = false; + for (int i = 0; i < dateFormat.length(); i++) { + char currentChar = dateFormat.charAt(i); + if (currentChar == '\'') { + inQuote = !inQuote; + } else if (!inQuote) { + if (currentChar == 'G') { + containsEra = true; + } else if (currentChar == 'y') { + containsYearOfEra = true; + } + } + } + if (!containsYearOfEra || containsEra) { + return dateFormat; + } + StringBuilder strictPattern = new StringBuilder(dateFormat.length()); + inQuote = false; + for (int i = 0; i < dateFormat.length(); i++) { + char currentChar = dateFormat.charAt(i); + if (currentChar == '\'') { + inQuote = !inQuote; + } else if (!inQuote && currentChar == 'y') { + currentChar = 'u'; + } + strictPattern.append(currentChar); + } + return strictPattern.toString(); + } + private static DateFormat getCacheDateFormat(String dateFormat) { Map dateFormatMap = DATE_FORMAT_THREAD_LOCAL.get(); if (dateFormatMap == null) { @@ -407,6 +465,8 @@ private static DateFormat getCacheDateFormat(String dateFormat) { } } SimpleDateFormat simpleDateFormat = new SimpleDateFormat(dateFormat); + // Strictly match the format, e.g. "2024-02-31" is invalid instead of being normalized to 2024-03-02. + simpleDateFormat.setLenient(false); dateFormatMap.put(dateFormat, simpleDateFormat); return simpleDateFormat; } diff --git a/fesod-sheet/src/test/java/org/apache/fesod/sheet/converters/date/DateStringConverterTest.java b/fesod-sheet/src/test/java/org/apache/fesod/sheet/converters/date/DateStringConverterTest.java new file mode 100644 index 000000000..a6461fec4 --- /dev/null +++ b/fesod-sheet/src/test/java/org/apache/fesod/sheet/converters/date/DateStringConverterTest.java @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.fesod.sheet.converters.date; + +import java.text.ParseException; +import org.apache.fesod.sheet.metadata.data.ReadCellData; +import org.apache.fesod.sheet.testkit.Tags; +import org.apache.fesod.sheet.util.DateUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link DateStringConverter}. + */ +@Tag(Tags.UNIT) +class DateStringConverterTest { + + private final DateStringConverter converter = new DateStringConverter(); + + @AfterEach + void tearDown() { + DateUtils.removeThreadLocalCache(); + } + + @Test + void convertToJavaDataRejectsInvalidMonthEndInsteadOfNormalizingIt() { + Assertions.assertThrows( + ParseException.class, () -> converter.convertToJavaData(new ReadCellData<>("2024-02-31"), null, null)); + } + + @Test + void convertToJavaDataRejectsInvalidLeapDayInsteadOfNormalizingIt() { + Assertions.assertThrows( + ParseException.class, () -> converter.convertToJavaData(new ReadCellData<>("2023-02-29"), null, null)); + } + + @Test + void convertToJavaDataRejectsInvalidDayInDateTimeInsteadOfNormalizingIt() { + Assertions.assertThrows( + ParseException.class, + () -> converter.convertToJavaData(new ReadCellData<>("2024-02-31 10:20:30"), null, null)); + } + + @Test + void convertToJavaDataAcceptsValidLeapDay() throws ParseException { + String formatted = DateUtils.format( + converter.convertToJavaData(new ReadCellData<>("2024-02-29"), null, null), "yyyy-MM-dd"); + Assertions.assertEquals("2024-02-29", formatted); + } +} diff --git a/fesod-sheet/src/test/java/org/apache/fesod/sheet/converters/localdate/LocalDateStringConverterTest.java b/fesod-sheet/src/test/java/org/apache/fesod/sheet/converters/localdate/LocalDateStringConverterTest.java new file mode 100644 index 000000000..2037b4773 --- /dev/null +++ b/fesod-sheet/src/test/java/org/apache/fesod/sheet/converters/localdate/LocalDateStringConverterTest.java @@ -0,0 +1,91 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.fesod.sheet.converters.localdate; + +import java.text.ParseException; +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import org.apache.fesod.sheet.metadata.GlobalConfiguration; +import org.apache.fesod.sheet.metadata.data.ReadCellData; +import org.apache.fesod.sheet.metadata.property.DateTimeFormatProperty; +import org.apache.fesod.sheet.metadata.property.ExcelContentProperty; +import org.apache.fesod.sheet.testkit.Tags; +import org.apache.fesod.sheet.util.DateUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link LocalDateStringConverter}. + */ +@Tag(Tags.UNIT) +class LocalDateStringConverterTest { + + private final LocalDateStringConverter converter = new LocalDateStringConverter(); + + @AfterEach + void tearDown() { + DateUtils.removeThreadLocalCache(); + } + + @Test + void convertToJavaDataRejectsInvalidMonthEndInsteadOfNormalizingIt() { + Assertions.assertThrows( + DateTimeParseException.class, + () -> converter.convertToJavaData(new ReadCellData<>("2024-02-31"), null, new GlobalConfiguration())); + } + + @Test + void convertToJavaDataRejectsInvalidLeapDayInsteadOfNormalizingIt() { + Assertions.assertThrows( + DateTimeParseException.class, + () -> converter.convertToJavaData(new ReadCellData<>("2023-02-29"), null, new GlobalConfiguration())); + } + + @Test + void convertToJavaDataAcceptsValidLeapDay() throws ParseException { + LocalDate actual = + converter.convertToJavaData(new ReadCellData<>("2024-02-29"), null, new GlobalConfiguration()); + Assertions.assertEquals(LocalDate.of(2024, 2, 29), actual); + } + + @Test + void convertToJavaDataRejectsInvalidDayWithCustomPattern() { + ExcelContentProperty contentProperty = new ExcelContentProperty(); + contentProperty.setDateTimeFormatProperty(new DateTimeFormatProperty("yyyy/MM/dd", null)); + + Assertions.assertThrows( + DateTimeParseException.class, () -> convert(new ReadCellData<>("2024/02/31"), contentProperty)); + } + + @Test + void convertToJavaDataAcceptsValidDayWithCustomPattern() throws ParseException { + ExcelContentProperty contentProperty = new ExcelContentProperty(); + contentProperty.setDateTimeFormatProperty(new DateTimeFormatProperty("yyyy/MM/dd", null)); + + LocalDate actual = convert(new ReadCellData<>("2024/02/29"), contentProperty); + Assertions.assertEquals(LocalDate.of(2024, 2, 29), actual); + } + + private LocalDate convert(ReadCellData cellData, ExcelContentProperty contentProperty) throws ParseException { + return converter.convertToJavaData(cellData, contentProperty, new GlobalConfiguration()); + } +}