Skip to content

fix: do not trigger TZ resolution for VTIMEZONE observance DTSTART#497

Merged
jens-maus merged 2 commits into
jens-maus:masterfrom
KristjanESPERANTO:fix/vtimezone-year-0001-crash
Apr 3, 2026
Merged

fix: do not trigger TZ resolution for VTIMEZONE observance DTSTART#497
jens-maus merged 2 commits into
jens-maus:masterfrom
KristjanESPERANTO:fix/vtimezone-year-0001-crash

Conversation

@KristjanESPERANTO
Copy link
Copy Markdown
Contributor

@KristjanESPERANTO KristjanESPERANTO commented Apr 3, 2026

Problem

ICS files originating from Outlook or Exchange often contain Microsoft-style VTIMEZONE blocks with year 0001 in DTSTART. These can also appear in other calendar systems (Nextcloud, Google Calendar, etc.) when events are imported or shared from Microsoft sources.

BEGIN:STANDARD
DTSTART:00010325T020000
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
END:STANDARD

Year 0001 is Microsoft's convention for "this rule has always been in effect". This is valid per RFC 5545.

Since node-ical 0.25.x, parsing these files throws:

RangeError: Cannot parse: 1-01-15T12:00:00Z

The generic DTSTART handler was treating observance DTSTARTs the same as event DTSTARTs — trying to resolve the enclosing VTIMEZONE to an IANA zone. That resolution builds a probe timestamp from the year (parseInt('0001') → 1), producing the malformed ISO string 1-01-15T12:00:00Z which Temporal.Instant.from() rejects.

Fix

Root cause (ical.js): DTSTART inside STANDARD/DAYLIGHT sub-components is now recognized as a local wall-clock time for the observance rule. It skips timezone resolution entirely, which is the correct behavior — these timestamps define when a DST rule takes effect, not an event in a timezone.

Defensive guard (lib/tz-utils.js): If resolveVTimezoneToIana is ever called with a year < 1000, the probe year is clamped to 1970 and zero-padded to 4 digits. IANA zone data before 1970 is unreliable anyway (RFC 5545 itself only guarantees data since 1970), so this has no practical impact on zone matching.

Tests

  • Unit test: resolveVTimezoneToIana with year=1 no longer throws
  • Integration test: full ICS file with year-0001 VTIMEZONE parses correctly and produces the expected UTC timestamp
  • ICS fixture: two VTIMEZONE blocks (one normal, one with year 0001) + a VEVENT referencing the problematic zone

Fixes #495

Summary by CodeRabbit

  • Bug Fixes

    • Prevented crashes and parsing errors for iCalendar timezone definitions with very old years (e.g., year 0001).
    • Improved handling of DTSTART values inside timezone observance blocks so event times are resolved correctly.
  • Tests

    • Added regression and unit tests covering VTIMEZONE/DTSTART edge cases with extremely old years.

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 jens-maus#495
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 3, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d03fa983-1576-4a8a-bd76-26b4a831569d

📥 Commits

Reviewing files that changed from the base of the PR and between cbf1658 and f66a86a.

📒 Files selected for processing (1)
  • ical.js
✅ Files skipped from review due to trivial changes (1)
  • ical.js

📝 Walkthrough

Walkthrough

Parses DTSTARTs inside VTIMEZONE STANDARD/DAYLIGHT observances as local wall-clock Date objects instead of resolving them via the VTIMEZONE-to-IANA fallback; also clamps probe years to a minimum of 1970 and zero-pads years when building Temporal.Instant probes to avoid parsing errors for years < 1000. Tests and fixtures added.

Changes

Cohort / File(s) Summary
DTSTART parsing in VTIMEZONE observances
ical.js
When a non-Z DTSTART occurs inside a VTIMEZONE STANDARD/DAYLIGHT block, construct a local Date from parsed components (using new Date(year, monthIndex, ...) and setFullYear) instead of falling back to VTIMEZONE stack resolution.
Timezone probe year formatting
lib/tz-utils.js
resolveVTimezoneToIana() now clamps probe years to a minimum of 1970 and formats them as zero-padded 4-digit ISO years before calling Temporal.Instant.from(), preventing malformed ISO strings and pre-1970 issues.
Tests & fixtures
test/advanced.test.js, test/tz-utils.test.js, test/fixtures/vtimezone-year-0001-dtstart.ics
Added regression/integration fixture and tests asserting parsing succeeds for VTIMEZONE DTSTART year 0001 and that timezone resolution handles year < 1000 without throwing.

Sequence Diagram(s)

sequenceDiagram
    participant Parser as Parser\n(parseICS / dateParameter)
    participant VTimeResolver as VTIMEZONE Resolver\n(fallbackWithStackTimezone / resolveVTimezoneToIana)
    participant Temporal as Temporal\n(Temporal.Instant.from)
    participant JSDate as JS Date\n(new Date / setFullYear)

    Parser->>VTimeResolver: needs timezone info for DTSTART in VTIMEZONE
    alt DTSTART inside STANDARD/DAYLIGHT and not Z
        Parser->>JSDate: construct local wall-clock Date(year, month, day, ...)
        JSDate-->>Parser: Date object (normalized via setFullYear)
    else fallback path
        VTimeResolver->>Temporal: build probe instants (clamp year ≥1970, zero-pad)
        Temporal-->>VTimeResolver: probe instants -> offsets
        VTimeResolver-->>Parser: resolved IANA tz / offset
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐰 A tiny year of one, so small and bold,
Temporal balked at a string untold.
We clamped the probe and used wall-clock art,
Old DTSTARTs now parse without falling apart. 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: preventing timezone resolution for DTSTART values within VTIMEZONE observance blocks.
Linked Issues check ✅ Passed The PR comprehensively addresses issue #495: prevents crash on year-0001 DTSTART by skipping TZ resolution in observance blocks and adding defensive guards for sub-1970 years.
Out of Scope Changes check ✅ Passed All changes directly support fixing the regression: observance DTSTART handling in ical.js, year clamping in tz-utils.js, and corresponding test coverage.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ical.js`:
- Line 384: The constructed Date for VTIMEZONE observances uses new Date(year,
monthIndex, day, hour, minute, second) which misinterprets years 0–99; after the
existing assignment to newDate in ical.js (the newDate variable created from
year, monthIndex, day, hour, minute, second), call newDate.setFullYear(year) to
preserve the literal year value (so years 0001–0099 remain correct) while
keeping the existing time components and behavior used by pickApplicableBlock.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b02c577e-af31-4633-b41d-552e4254860a

📥 Commits

Reviewing files that changed from the base of the PR and between 1da5962 and cbf1658.

📒 Files selected for processing (5)
  • ical.js
  • lib/tz-utils.js
  • test/advanced.test.js
  • test/fixtures/vtimezone-year-0001-dtstart.ics
  • test/tz-utils.test.js

Comment thread ical.js
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.
@KristjanESPERANTO
Copy link
Copy Markdown
Contributor Author

@jens-maus Coderabbit seems satisfied. If you are fine with this changes, a new release would be nice after merging 🙂

@jens-maus jens-maus merged commit 02a3b78 into jens-maus:master Apr 3, 2026
14 checks passed
@KristjanESPERANTO KristjanESPERANTO deleted the fix/vtimezone-year-0001-crash branch April 3, 2026 17:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

node-ical 0.25.6: Crash parsing VTIMEZONE with DTSTART year 0001 (RangeError "Cannot parse: 1-01-15T12:00:00Z")

2 participants