From cbf1658c4e8210cdff9319705469785462ce8919 Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Fri, 3 Apr 2026 10:25:11 +0200 Subject: [PATCH 1/2] fix: do not trigger TZ resolution for VTIMEZONE observance DTSTART MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DTSTART inside a STANDARD or DAYLIGHT sub-component is a plain local wall-clock time that marks when a DST rule takes effect. It must never go through fallbackWithStackTimezone(), which looks up the enclosing VTIMEZONE and calls resolveVTimezoneToIana() — crashing when the year is < 1000 (e.g. Microsoft's conventional "00010325T020000"). Root cause fix (ical.js): add an explicit STANDARD/DAYLIGHT branch in dateParameter that constructs the Date directly, skipping TZ resolution. Defensive guard (lib/tz-utils.js): clamp the probe year to ≥ 1970 and pad to 4 digits so any future call path reaching resolveVTimezoneToIana with a small year cannot produce the malformed ISO string "1-01-15T12:00:00Z" that Temporal.Instant.from() rejects. Fixes #495 --- ical.js | 6 +++ lib/tz-utils.js | 10 +++-- test/advanced.test.js | 13 ++++++ test/fixtures/vtimezone-year-0001-dtstart.ics | 45 +++++++++++++++++++ test/tz-utils.test.js | 33 ++++++++++++++ 5 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 test/fixtures/vtimezone-year-0001-dtstart.ics diff --git a/ical.js b/ical.js index 1c12e8f..f02e5ca 100644 --- a/ical.js +++ b/ical.js @@ -376,6 +376,12 @@ const dateParameter = function (name) { // GMT newDate = new Date(Date.UTC(year, monthIndex, day, hour, minute, second)); tzUtil.attachTz(newDate, 'Etc/UTC'); + } else if (curr.type === 'STANDARD' || curr.type === 'DAYLIGHT') { + // Inside a VTIMEZONE observance block the DTSTART is a plain local + // wall-clock time that defines when the rule takes effect — it must + // NOT trigger timezone resolution (which would look up the *enclosing* + // VTIMEZONE and could crash on exotic years like 0001). + newDate = new Date(year, monthIndex, day, hour, minute, second); } else { const fallbackWithStackTimezone = () => { const vTimezone = findVtimezoneInStack(stack); diff --git a/lib/tz-utils.js b/lib/tz-utils.js index 2ee98df..f0e8285 100644 --- a/lib/tz-utils.js +++ b/lib/tz-utils.js @@ -578,9 +578,13 @@ function resolveVTimezoneToIana(vTimezone, year) { return vtimezoneIanaCache.get(cacheKey); } - // Probe two dates: mid-January (winter in NH / summer in SH) and mid-July (inverse) - const probeJan = Temporal.Instant.from(`${yearNumber}-01-15T12:00:00Z`); - const probeJul = Temporal.Instant.from(`${yearNumber}-07-15T12:00:00Z`); + // Probe two dates: mid-January (winter in NH / summer in SH) and mid-July (inverse). + // Clamp to 1970: IANA zone data is unreliable before then (DST wasn't widely observed), + // and ISO 8601 requires a 4-digit year — years < 1000 would produce a malformed string + // (e.g. "1-01-15T12:00:00Z") that Temporal.Instant.from() cannot parse. + const probeYear = String(Math.max(yearNumber, 1970)).padStart(4, '0'); + const probeJan = Temporal.Instant.from(`${probeYear}-01-15T12:00:00Z`); + const probeJul = Temporal.Instant.from(`${probeYear}-07-15T12:00:00Z`); for (const zone of getZoneNames()) { try { diff --git a/test/advanced.test.js b/test/advanced.test.js index bb8aac1..b93088a 100644 --- a/test/advanced.test.js +++ b/test/advanced.test.js @@ -825,5 +825,18 @@ END:VCALENDAR`; assert.equal(diffDays, 1); } }); + // Regression test for https://github.com/jens-maus/node-ical/issues/495 + // VTIMEZONE with DTSTART year 0001 (e.g. emClient "W. Europe Standard Time") caused a + // RangeError: "Cannot parse: 1-01-15T12:00:00Z" in Temporal.Instant.from() because the + // template literal produced a non-4-digit ISO year string. + it('does not crash on VTIMEZONE with DTSTART year 0001 (issue #495)', () => { + const data = ical.parseFile('./test/fixtures/vtimezone-year-0001-dtstart.ics'); + const event = Object.values(data).find(x => x.type === 'VEVENT'); + assert.ok(event, 'event should be parsed without throwing'); + assert.equal(event.summary, 'Test event with year-0001 VTIMEZONE'); + assert.ok(event.start instanceof Date, 'start should be a Date'); + // 10:00 MESZ (UTC+2) on 2024-06-01 = 08:00 UTC + assert.strictEqual(event.start.toISOString(), '2024-06-01T08:00:00.000Z'); + }); }); }); diff --git a/test/fixtures/vtimezone-year-0001-dtstart.ics b/test/fixtures/vtimezone-year-0001-dtstart.ics new file mode 100644 index 0000000..9dd01fc --- /dev/null +++ b/test/fixtures/vtimezone-year-0001-dtstart.ics @@ -0,0 +1,45 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//emClient//EN +BEGIN:VTIMEZONE +TZID:Europe/Berlin +BEGIN:DAYLIGHT +DTSTART:19810329T020000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +TZNAME:MESZ +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:19961027T030000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +TZNAME:MEZ +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +END:STANDARD +END:VTIMEZONE +BEGIN:VTIMEZONE +TZID:W. Europe Standard Time +X-EM-DISPLAYNAME:(UTC+01:00) Amsterdam\, Berlin\, Bern\, Rom\, Stockholm\, Wien +BEGIN:DAYLIGHT +TZNAME:Mitteleuropäische Sommerzeit +DTSTART:00010325T020000 +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 +END:DAYLIGHT +BEGIN:STANDARD +TZNAME:Mitteleuropäische Zeit +DTSTART:00011028T030000 +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +DTSTART;TZID="W. Europe Standard Time":20240601T100000 +DTEND;TZID="W. Europe Standard Time":20240601T110000 +SUMMARY:Test event with year-0001 VTIMEZONE +UID:vtimezone-year-0001-test@node-ical +END:VEVENT +END:VCALENDAR diff --git a/test/tz-utils.test.js b/test/tz-utils.test.js index 2f45a3f..2234558 100644 --- a/test/tz-utils.test.js +++ b/test/tz-utils.test.js @@ -173,5 +173,38 @@ describe('unit: tz-utils', () => { assert.deepEqual(tz.resolveVTimezoneToIana(undefined, 2020), {iana: undefined, offset: undefined}); assert.deepEqual(tz.resolveVTimezoneToIana({type: 'VTIMEZONE'}, 2020), {iana: undefined, offset: undefined}); }); + + it('does not crash when year is < 1000 (e.g. DTSTART:00010325T020000)', () => { + // Regression test for https://github.com/jens-maus/node-ical/issues/495 + // VTIMEZONE blocks like "W. Europe Standard Time" from emClient use DTSTART year 0001. + // Temporal.Instant.from() requires a 4-digit ISO year; "1-01-15T12:00:00Z" is invalid. + // + // Note: The parser calls `new Date(1, ...)` for year 0001 which JS interprets as 1901, + // but the `year` argument to resolveVTimezoneToIana comes from parseInt('0001') = 1. + const cetVTimezone = { + type: 'VTIMEZONE', + tzid: 'W. Europe Standard Time', + daylight: { + type: 'DAYLIGHT', + start: new Date(1, 2, 25, 2, 0, 0), // JS interprets as 1901 (matches real parser behavior) + tzoffsetfrom: '+0100', + tzoffsetto: '+0200', + }, + standard: { + type: 'STANDARD', + start: new Date(1, 9, 28, 3, 0, 0), // JS interprets as 1901 (matches real parser behavior) + tzoffsetfrom: '+0200', + tzoffsetto: '+0100', + }, + }; + // Must not throw; should resolve to a valid CET/CEST IANA zone. + // The key scenario: year=1 would produce "1-01-15T12:00:00Z" without the fix. + let result; + assert.doesNotThrow(() => { + result = tz.resolveVTimezoneToIana(cetVTimezone, 1); + }); + assert.ok(result.iana, 'should resolve to an IANA zone despite year 0001 DTSTART'); + assert.equal(result.offset, '+01:00'); + }); }); }); From f66a86ad64011cdb6a70cef41ab0b72b0d248541 Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Fri, 3 Apr 2026 10:51:52 +0200 Subject: [PATCH 2/2] fix: preserve literal year for VTIMEZONE observance DTSTART MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new Date(year, ...) maps years 0–99 to 1900–1999; add setFullYear() to keep the actual year (e.g. 0001) so pickApplicableBlock sorts observance blocks correctly. --- ical.js | 1 + 1 file changed, 1 insertion(+) diff --git a/ical.js b/ical.js index f02e5ca..1e5d7dd 100644 --- a/ical.js +++ b/ical.js @@ -382,6 +382,7 @@ const dateParameter = function (name) { // NOT trigger timezone resolution (which would look up the *enclosing* // VTIMEZONE and could crash on exotic years like 0001). newDate = new Date(year, monthIndex, day, hour, minute, second); + newDate.setFullYear(year); } else { const fallbackWithStackTimezone = () => { const vTimezone = findVtimezoneInStack(stack);