From 23ad2ad797af86e67bb3e2f1f66ebcd176e28a9b Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 20 Aug 2026 06:40:58 +0200 Subject: [PATCH] fix: reject a year Date.UTC would silently move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `iso()` read back the month and the day to catch February 30 rolling over to March 2, and took the year on trust. `Date.UTC` maps years 0–99 into the 1900s, so a four-digit "0050" came back as 1950 with the month and day intact — past both existing checks, because nothing about the resulting date is impossible. Same class of failure as the rollover the guard was written for: a boundary the source never stated, published as though it had. Reading the year back too is the skip-rather-than-guess rule this module runs on, applied to the one field that was not checked. It narrows only — `parseShortSlashRange`'s two-digit pivot produces full years and is untouched. Co-Authored-By: Claude Opus 5 (1M context) --- src/ingest/dates.ts | 15 +++++++++++++-- test/dates.test.ts | 14 ++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/ingest/dates.ts b/src/ingest/dates.ts index d98b207..0a7e4e0 100644 --- a/src/ingest/dates.ts +++ b/src/ingest/dates.ts @@ -40,8 +40,19 @@ function iso( return null; } const date = new Date(Date.UTC(y, m - 1, d, hh, mm, ss, 0)); - // Rejects impossible calendar dates such as February 30. - if (date.getUTCMonth() !== m - 1 || date.getUTCDate() !== d) return null; + // Rejects impossible calendar dates such as February 30 — and a year that did + // not survive the round trip. `Date.UTC` maps 0–99 into the 1900s, so a + // four-digit "0050" becomes 1950 with the month and day intact, which the two + // checks beside this one cannot see. Reading the year back is the same + // skip-rather-than-guess rule the rest of this module runs on, applied to the + // one field that was taken on trust. + if ( + date.getUTCFullYear() !== y || + date.getUTCMonth() !== m - 1 || + date.getUTCDate() !== d + ) { + return null; + } return date.toISOString(); } diff --git a/test/dates.test.ts b/test/dates.test.ts index 577a54d..e5b7c18 100644 --- a/test/dates.test.ts +++ b/test/dates.test.ts @@ -43,6 +43,20 @@ describe("parseMonthDayYear", () => { test("rejects an unknown month name", () => { expect(parseMonthDayYear("Smarch 3, 2026")).toBeNull(); }); + + test("rejects a year that Date.UTC would silently move", () => { + // `Date.UTC` maps years 0–99 into the 1900s, so "0050" came back as 1950 + // with the month and day intact — which the impossible-date guard cannot + // see, because nothing about the date is impossible. It is the same class of + // failure as February 30 rolling over to March 2: a boundary the source + // never stated, published as though it had. + expect(parseMonthDayYear("August 12, 0050")).toBeNull(); + expect(parseMonthDayYear("August 12, 0099")).toBeNull(); + // And a real four-digit year is untouched. + expect(parseMonthDayYear("August 12, 2026")?.iso).toBe( + "2026-08-12T00:00:00.000Z", + ); + }); }); describe("parseMonthDayRange", () => {