Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions ical.js
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,13 @@ 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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
newDate.setFullYear(year);
} else {
const fallbackWithStackTimezone = () => {
const vTimezone = findVtimezoneInStack(stack);
Expand Down
10 changes: 7 additions & 3 deletions lib/tz-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 13 additions & 0 deletions test/advanced.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
45 changes: 45 additions & 0 deletions test/fixtures/vtimezone-year-0001-dtstart.ics
Original file line number Diff line number Diff line change
@@ -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
33 changes: 33 additions & 0 deletions test/tz-utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
Loading