diff --git a/src/ExcelReader.Core/Reader/XlsxReader.Enumerator.cs b/src/ExcelReader.Core/Reader/XlsxReader.Enumerator.cs index f0f8c88..7600f3a 100644 --- a/src/ExcelReader.Core/Reader/XlsxReader.Enumerator.cs +++ b/src/ExcelReader.Core/Reader/XlsxReader.Enumerator.cs @@ -726,8 +726,21 @@ private static bool TryParseIsoDate(ReadOnlySpan utf8, out DateTime value) { chars[i] = (char)utf8[i]; } - return DateTime.TryParse(chars[..utf8.Length], CultureInfo.InvariantCulture, - DateTimeStyles.RoundtripKind | DateTimeStyles.AllowWhiteSpaces, out value); + const DateTimeStyles dateParseFlag = DateTimeStyles.RoundtripKind | DateTimeStyles.AllowWhiteSpaces; + if (!DateTime.TryParse(chars[..utf8.Length], CultureInfo.InvariantCulture, dateParseFlag, out value)) + { + return false; + } + // DateTime spans years 1..9999 but ToOADate only accepts 0100-01-01 and later — it + // throws OverflowException ("Not a legal OleAut date") below that. A hand-written + // t="d" like "0024-02-29" parses fine and would have taken down the whole read, so + // treat anything with no serial representation as unparseable and keep it as text. + if (value.Year < 100) + { + value = default; + return false; + } + return true; } private static ReadOnlySpan ElementText(ReadOnlySpan inner, ReadOnlySpan openTag, ReadOnlySpan closeTag) diff --git a/tests/ExcelReader.Fuzz/README.md b/tests/ExcelReader.Fuzz/README.md index 389d94c..c28f8a3 100644 --- a/tests/ExcelReader.Fuzz/README.md +++ b/tests/ExcelReader.Fuzz/README.md @@ -92,6 +92,7 @@ the working corpus for both jobs. |---|---|---| | `xls-minifat-overflow.bin` | `xls` | `OverflowException` out of `XlsCompoundFile.ReadIntSectors`. `miniFatSectorCount` (16,777,215 in a 3.4 KB file) was the one header sector count not bounded against the container length, and `ReadIntSectors` multiplies it by `sectorSize` inside a `checked` block. Now rejected as `InvalidDataException`; regression test in `ExcelOpenAndOleErrorTests`. | | `xlsx-truncated-cellxfs.bin` | `xlsx` | `ArgumentOutOfRangeException` out of `XlsxReader.ParseStyleDateFlags`. A styles part truncated mid-`'` search at -1, which then anchored the search for `` before being checked. Now returns no date flags; the `IdxOf` helpers also treat a negative anchor as "not found". Regression test in `XlsxReaderTests`. | +| `xlsx-isodate-year-below-100.bin` | `xlsx`, `xlsx-memory` | `OverflowException` ("Not a legal OleAut date") out of `XlsxReader.Enumerator.EmitIsoDate`. A `t="d"` cell holding `0024-02-29T21:00:00.000Z` parsed fine into a `DateTime` but has no OLE automation serial — `ToOADate` only accepts `0100-01-01` and later. Such values are now kept verbatim as text like any other unparseable `t="d"`. Regression test in `NamespacePrefixAndIsoDateTests`. | ## Seeds diff --git a/tests/ExcelReader.Fuzz/corpus/xlsx-isodate-year-below-100.bin b/tests/ExcelReader.Fuzz/corpus/xlsx-isodate-year-below-100.bin new file mode 100644 index 0000000..e7259bc Binary files /dev/null and b/tests/ExcelReader.Fuzz/corpus/xlsx-isodate-year-below-100.bin differ diff --git a/tests/ExcelReader.Tests/NamespacePrefixAndIsoDateTests.cs b/tests/ExcelReader.Tests/NamespacePrefixAndIsoDateTests.cs index 6fa38c7..fb1aad1 100644 --- a/tests/ExcelReader.Tests/NamespacePrefixAndIsoDateTests.cs +++ b/tests/ExcelReader.Tests/NamespacePrefixAndIsoDateTests.cs @@ -159,6 +159,39 @@ public void UnparseableIsoDateCellIsKeptAsString() Assert.Equal("not-a-date", e.Current[0].GetString()); } + // Regression: found by the xlsx-memory fuzz target. DateTime spans years 1..9999 but + // ToOADate only accepts 0100-01-01 and later, so a t="d" whose text parsed into an earlier + // year threw OverflowException ("Not a legal OleAut date") out of MoveNext. Such a value has + // no Excel serial at all, so it is kept verbatim as text like any other unparseable t="d". + [Theory] + [InlineData("0024-02-29T21:00:00.000Z")] + [InlineData("0001-01-01")] + [InlineData("0099-12-31T23:59:59")] + public void IsoDateBelowOaDateRangeIsKeptAsString(string text) + { + using MemoryStream ms = WorkbookBuilder.Build( + $"""{text}"""); + using XlsxReader reader = Excel.From(ms); + using XlsxReader.Enumerator e = reader.GetEnumerator(); + + Assert.True(e.MoveNext()); + Assert.Equal(CellType.ExcelString, e.Current[0].Type); + Assert.Equal(text, e.Current[0].GetString()); + } + + // The first legal OADate value still round-trips as a date, so the guard is not off by a day. + [Fact] + public void IsoDateAtOaDateFloorIsStillParsedAsDate() + { + using MemoryStream ms = WorkbookBuilder.Build( + """0100-01-01"""); + using XlsxReader reader = Excel.From(ms); + using XlsxReader.Enumerator e = reader.GetEnumerator(); + + Assert.True(e.MoveNext()); + Assert.Equal(CellType.Date, e.Current[0].Type); + } + [Fact] public async Task IsoDateCellIsParsedAsDateAsync() {