Distinguish UTC vs. UTC+0:00 times in iCalendar library

This fixes a bug discussed on emacs-devel; see the thread at
https://https://lists.gnu.org/archive/html/emacs-devel/2026-07/msg00445.html

The issue was that the library did not distinguish between UTC time and
times that have an offset of 0 but are not in the UTC time zone,
e.g. standard times in Europe/London or Europe/Dublin.  This led to test
failures on systems in these time zones.  This fix represents true UTC
times by inserting the special value `t' in the zone slot of a decoded
time at parse time (which `encode-time' already handles correctly).
This distinguishes them from decoded times with an offset of 0 seconds.

* lisp/calendar/icalendar-ast.el (icalendar-make-component):
Ensure auto-generated DTSTAMP uses the new representation.
(icalendar-make-node-from-templates): Fix docstring.
* lisp/calendar/icalendar-parser.el (icalendar-read-time)
(icalendar-read-date-time): Read iCalendar UTC times to the new
representation.
(icalendar-print-time): Print the new representation.
(icalendar--decoded-time-p, icalendar--decoded-date-time-p)
(icalendar-date-time-is-utc-p): Check for the new
representation.
(icalendar-date-time): Docstring improvement.
(icalendar-requires-utc-validator): New validator function to
check that property values are in UTC.
(icalendar-completed, icalendar-created, icalendar-dtstamp): Use
it.
* lisp/calendar/icalendar-recur.el
(icalendar-recur-tz-decode-time): Decode to new representation;
explicitly support this.
* lisp/calendar/icalendar-utils.el (icalendar-date-time<)
(icalendar-date-time-simultaneous-p):
* lisp/calendar/diary-icalendar.el
(diary-icalendar-format-time-as-local): Work with the new
representation.
(diary-icalendar-convert-time-via-strategy)
(diary-icalendar-parse-entry): Ensure times decode to new
representation.
* test/lisp/calendar/icalendar-parser-tests.el
(icalendar-parser-test-bad-hyphenated-dates):
* test/lisp/calendar/icalendar-recur-tests.el
(icalendar-test-recur-find-secondly-interval)
(icalendar-test-recur-tz-observance-on): Update tests to use new
representation.
* test/lisp/calendar/icalendar-parser-tests.el
(icalendar-parse-test-utc+0-is-not-utc): New regression test.
* test/lisp/calendar/diary-icalendar-tests.el: Update 'to-utc test.
* test/lisp/calendar/diary-icalendar-resources/import-bug-24199.diary-all:
Update diary representation of UTC times in a recurrence rule.
This commit is contained in:
Richard Lawrence 2026-07-29 09:29:25 +02:00 committed by Sean Whitton
parent 1b500ce5a5
commit db6beef260
9 changed files with 97 additions and 43 deletions

View file

@ -1366,7 +1366,7 @@ the iCalendar data."
(local-dt (decode-time ts local-tz))
(local-str (di:format-time local-dt)))
(if (and original-tzname original-offset
(not (= original-offset local-offset)))
(not (eql original-offset local-offset)))
(format "%s (%s)" local-str (di:format-time dt original-tzname))
local-str)))))
@ -2508,8 +2508,8 @@ zone export strategy requires it."
(icr:tz-decode-time (encode-time dt) vtimezone)
(icr:tz-set-zone dt vtimezone :error)))
((or (eq 'to-utc di:time-zone-export-strategy)
(di:-tz-is-utc-p)) ; we're already in UTC, so mark dt as such
(decode-time (encode-time dt) t))
(di:-tz-is-utc-p))
(icr:tz-decode-time (encode-time dt) t)) ; ensure dt is in UTC
((eq 'floating di:time-zone-export-strategy)
(setf (decoded-time-zone dt) nil)
dt)))))
@ -3341,7 +3341,10 @@ recursive calls to this function made by
;; Collect the remaining properties:
(setq all-props (append (di:parse-summary-and-description) all-props))
(setq all-props (append (di:parse-attendees-and-organizer) all-props))
(push (ical:make-property ical:dtstamp (decode-time nil t)) all-props)
(push
(ical:make-property ical:dtstamp
(icr:tz-decode-time (current-time) t)) ; ensure UTC
all-props)
(let ((class (di:parse-class))
(location (di:parse-location))
(status (di:parse-status))

View file

@ -569,8 +569,13 @@ The resulting syntax node is checked for validity by
;; Add templates for required properties automatically if we can:
(when (memq type '(ical:vevent ical:vtodo ical:vjournal ical:vfreebusy))
(unless (assq 'ical:dtstamp templates)
(push '(ical:dtstamp (decode-time nil t))
templates))
(push
'(icalendar-make-property icalendar-dtstamp
(let ((stamp (decode-time nil t)))
;; Ensure we return UTC, not just :zone 0:
(setf (decoded-time-zone stamp) t)
stamp))
templates))
(unless (assq 'ical:uid templates)
(push `(ical:uid ,(ical:make-uid templates))
templates)))
@ -652,7 +657,6 @@ added to the component or property node.
For example, an iCalendar VEVENT could be written like this:
(icalendar-make-node-from-templates icalendar-vevent
(icalendar-dtstamp (decode-time (current-time) 0))
(icalendar-uid \"some-unique-id\")
(icalendar-summary \"Party\")
(icalendar-location \"Robot House\")

View file

@ -896,7 +896,7 @@ S should be a match against rx `icalendar-time'."
(second (string-to-number (substring s 4 6)))
(utcoffset (if (and (length= s 7)
(equal "Z" (substring s 6 7)))
0
t ; UTC
;; unknown/'floating' time zone:
nil)))
(ical:make-date-time :second second
@ -910,7 +910,7 @@ S should be a match against rx `icalendar-time'."
(decoded-time-hour time)
(decoded-time-minute time)
(decoded-time-second time)
(if (eql 0 (decoded-time-zone time))
(if (eq t (decoded-time-zone time))
"Z" "")))
(defun ical:-decoded-time-p (val)
@ -923,7 +923,7 @@ for that, see `icalendar--decoded-date-time-p'."
(cl-typep (decoded-time-minute val) 'ical:numeric-minute)
(cl-typep (decoded-time-hour val) 'ical:numeric-hour)
(cl-typep (decoded-time-dst val) '(member t nil -1))
(cl-typep (decoded-time-zone val) '(or integer null))))
(cl-typep (decoded-time-zone val) '(or integer boolean))))
(ical:define-type ical:time "TIME"
"Type for Time values.
@ -967,7 +967,7 @@ fields and DST are ignored when printed."
;; `make-decoded-time':
;; (cl-typep (decoded-time-weekday val) '(integer 0 6))
(cl-typep (decoded-time-dst val) '(member t nil -1))
(cl-typep (decoded-time-zone val) '(or integer null))))
(cl-typep (decoded-time-zone val) '(or integer boolean))))
(defun ical:read-date-time (s)
"Read an `icalendar-date-time' from a string S.
@ -982,7 +982,7 @@ S should be a match against rx `icalendar-date-time'."
(second (string-to-number (substring s 13 15)))
(utcoffset (if (and (length= s 16)
(equal "Z" (substring s 15 16)))
0
t ; UTC
;; unknown/'floating' time zone:
nil)))
(ical:make-date-time :second second
@ -1007,16 +1007,15 @@ S should be a match against rx `icalendar-date-time'."
(defun ical:date-time-is-utc-p (datetime)
"Return non-nil if DATETIME is in UTC time."
(let ((offset (decoded-time-zone datetime)))
(and offset (= 0 offset))))
(eq t (decoded-time-zone datetime)))
(ical:define-type ical:date-time "DATE-TIME"
"Type for Date-Time values.
When printed, a date-time is a string of digits like:
YYYYMMDDTHHMMSS
where the 'T' is literal, and separates the date string from the
time string.
where the 'T' is literal, and separates the date string from the time
string. If followed by a 'Z', the string represents a UTC date-time.
When read, a date-time is a decoded time, i.e. a list in the format
(SEC MINUTE HOUR DAY MONTH YEAR DOW DST UTCOFF). See
@ -2933,6 +2932,7 @@ an `icalendar-vtodo' was actually completed. The value must be an
`icalendar-date-time' with a UTC time."
ical:date-time
:child-spec (:zero-or-more (ical:otherparam))
:other-validator ical:requires-utc-validator
:link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.2.1")
(ical:define-property ical:dtend "DTEND"
@ -3381,6 +3381,7 @@ initially created an `icalendar-vevent', `icalendar-vtodo', or
in UTC time."
ical:date-time
:child-spec (:zero-or-more (ical:otherparam))
:other-validator ical:requires-utc-validator
:link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.7.1")
(ical:define-property ical:dtstamp "DTSTAMP"
@ -3404,8 +3405,17 @@ object *representing* that data was created.
The value must be in UTC time."
ical:date-time
:child-spec (:zero-or-more (ical:otherparam))
:other-validator ical:requires-utc-validator
:link "https://www.rfc-editor.org/rfc/rfc5545#section-3.8.7.2")
(defun ical:requires-utc-validator (node)
"Validate that a property NODE's value is a UTC date-time"
(ical:with-property node nil
(unless (ical:date-time-is-utc-p value)
(ical:signal-validation-error
(format "An `%s's value must be in UTC" (ical:ast-node-type node))
:node node))))
(ical:define-property ical:last-modified "LAST-MODIFIED"
"Last Modified timestamp.

View file

@ -1971,19 +1971,21 @@ based on whether the observance is an `icalendar-standard' or
observance that applies to TS, it is decoded into UTC time.
VTIMEZONE may also be an `icalendar-utc-offset'. In this case TS is
decoded directly into this UTC offset, and its dst slot is set to -1."
decoded directly into this UTC offset, and its dst slot is set to -1.
If VTIMEZONE is t, TS is decoded to UTC time."
(let* ((observance (when (ical:vtimezone-component-p vtimezone)
(car (icr:tz-observance-on ts vtimezone))))
(offset (cond (observance (icr:tz-offset-in observance))
((cl-typep vtimezone 'ical:utc-offset)
vtimezone)
(t 0))))
(t t)))) ; decode to UTC
(ical:date-time-variant ; ensures weekday gets set, too
(decode-time ts offset)
:zone offset
:dst (if observance (ical:daylight-component-p observance)
-1))))
:dst (cond (observance (ical:daylight-component-p observance))
((eq t vtimezone) nil) ; UTC
(t -1)))))
(defun icr:tz-set-zone (dt vtimezone &optional nonexistent)
"Set the time zone offset and dst flag in DT based on VTIMEZONE.

View file

@ -289,7 +289,7 @@ returned from `current-time-zone' is used as the missing offset; if
signaled."
(let ((zone1 (decoded-time-zone dt1))
(zone2 (decoded-time-zone dt2)))
(cond ((and (integerp zone1) (integerp zone2))
(cond ((and zone1 zone2)
(time-less-p (encode-time dt1) (encode-time dt2)))
((and (null zone1) (null zone2))
(ical:date-time-locally< dt1 dt2))
@ -357,11 +357,11 @@ non-simultaneous if they represent different clock times according to
signaled."
(let ((zone1 (decoded-time-zone dt1))
(zone2 (decoded-time-zone dt2)))
(cond ((and (integerp zone1) (integerp zone2))
(cond ((and zone1 zone2)
(time-equal-p (encode-time dt1) (encode-time dt2)))
((and (null zone1) (null zone2))
(time-equal-p (encode-time (ical:date-time-variant dt1 :zone 0))
(encode-time (ical:date-time-variant dt2 :zone 0))))
(time-equal-p (encode-time (ical:date-time-variant dt1 :zone t))
(encode-time (ical:date-time-variant dt2 :zone t))))
(t
;; Best effort:
;; TODO: I'm not convinced this is the right thing to do yet.

View file

@ -1,8 +1,8 @@
&%%(diary-rrule :rule '((FREQ MONTHLY) (BYDAY ((3 . 1))) (INTERVAL 1))
:exclude
'((0 46 11 6 1 2016 3 -1 0) (0 46 11 3 2 2016 3 -1 0)
(0 46 11 2 3 2016 3 -1 0) (0 46 10 4 5 2016 3 -1 0)
(0 46 10 1 6 2016 3 -1 0))
'((0 46 11 6 1 2016 3 -1 t) (0 46 11 3 2 2016 3 -1 t)
(0 46 11 2 3 2016 3 -1 t) (0 46 10 4 5 2016 3 -1 t)
(0 46 10 1 6 2016 3 -1 t))
:start '(0 46 12 2 12 2015 3 -1 nil) :duration
'(0 14 3 0 nil nil nil -1 nil)) Summary
Location: Loc

View file

@ -1153,7 +1153,7 @@ SOURCE, if given, should be a symbol; it is used to name the test."
(unwind-protect
(ical:with-component (car parsed)
((ical:dtstart :first start-node :value start))
(should (= 0 (decoded-time-zone start)))
(should (ical:date-time-is-utc-p start))
(should (= (- 16 2) (decoded-time-hour start)))
(should-not (ical:with-param-of start-node 'ical:tzidparam)))
;; restore time zone

View file

@ -1962,7 +1962,7 @@ END:VCALENDAR
(expected-dtstamp
(ical:make-date-time :year 2023 :month 7 :day 30
:hour 19 :minute 47 :second 0
:zone 0)))
:zone t)))
(should (not (ical:errors-p)))
(should (ical:ast-node-valid-p vcal t))
(ical:with-component vcal
@ -2023,6 +2023,41 @@ END:VCALENDAR
((ical:sentbyparam :value sent-by))
(should (equal sent-by expected-sender))))))))))
;; Tests for bugfixes:
(ert-deftest ipt:utc+0-is-not-utc ()
"Are UTC times parsed distinctly from times in other zones with 0 offset?"
;; An explicit UTC time should parse with `t' in its zone field; this
;; distinguishes it from times that merely have a 0 second offset from
;; UTC, but might have a defined non-UTC time zone, e.g. in Europe/London
;; or Europe/Dublin. Bug discussion:
;; https://https://lists.gnu.org/archive/html/emacs-devel/2026-07/msg00445.html
(let* ((s-utc "20260101T111111Z")
(s-non "20260101T111111")
(parsed-utc (ical:ast-node-value
(ical:parse-from-string 'ical:date-time s-utc)))
(expected-utc (ical:make-date-time :year 2026 :month 1 :day 1
:hour 11 :minute 11 :second 11
:zone t))
(parsed-non (ical:ast-node-value
(ical:parse-from-string 'ical:date-time s-non)))
(parsed-non-zoned (ical:date-time-variant parsed-non :zone 0))
(expected-non (ical:make-date-time :year 2026 :month 1 :day 1
:hour 11 :minute 11 :second 11
:zone nil))
(expected-non-zoned (ical:date-time-variant expected-non :zone 0)))
;; Test that the two times are parsed distinctly:
(should (equal parsed-utc expected-utc))
(should (equal parsed-non expected-non))
(should-not (equal (decoded-time-zone parsed-utc)
(decoded-time-zone parsed-non)))
;; Test that `icalendar-date-time-is-utc-p' distinguishes the two cases:
(should (ical:date-time-is-utc-p parsed-utc))
(should-not (ical:date-time-is-utc-p parsed-non))
(should-not (ical:date-time-is-utc-p parsed-non-zoned))
;; but also that `icalendar-date-time-simultaneous-p' does not:
(should (ical:date-time-simultaneous-p parsed-utc expected-utc))
(should (ical:date-time-simultaneous-p parsed-utc parsed-non-zoned))))

View file

@ -271,7 +271,7 @@ END:VTIMEZONE
;; Use UTC for the tests with no
;; time zone, so that the results
;; don't depend on system's local time
:zone 0))
:zone t))
(dtstart/tz (ical:date-time-variant dtstart :zone ict:est :dst nil)))
;; Year numbers are monotonically increasing in the following test cases,
@ -279,24 +279,24 @@ END:VTIMEZONE
;; No timezone, just clock time, around a target that doesn't fall on
;; an interval boundary:
(let* ((target (ical:date-time-variant dtstart :year 2026 :second 5 :zone 0))
(let* ((target (ical:date-time-variant dtstart :year 2026 :second 5 :zone t))
(expected-int
(icr:make-interval
(ical:date-time-variant target :second 0 :tz 'preserve)
(ical:date-time-variant target :second 1 :tz 'preserve)
(ical:date-time-variant target :second 10 :tz 'preserve))))
(ical:date-time-variant target :second 0 :dst nil :tz 'preserve)
(ical:date-time-variant target :second 1 :dst nil :tz 'preserve)
(ical:date-time-variant target :second 10 :dst nil :tz 'preserve))))
(should
(equal expected-int
(icr:find-secondly-interval target dtstart 10))))
;; No timezone, just clock time, around a target that does fall on
;; an interval boundary:
(let* ((target (ical:date-time-variant dtstart :year 2027 :second 10 :zone 0))
(let* ((target (ical:date-time-variant dtstart :year 2027 :second 10 :zone t))
(expected-int
(icr:make-interval
(ical:date-time-variant target :second 10 :tz 'preserve)
(ical:date-time-variant target :second 11 :tz 'preserve)
(ical:date-time-variant target :second 20 :tz 'preserve))))
(ical:date-time-variant target :second 10 :dst nil :tz 'preserve)
(ical:date-time-variant target :second 11 :dst nil :tz 'preserve)
(ical:date-time-variant target :second 20 :dst nil :tz 'preserve))))
(should
(equal expected-int
(icr:find-secondly-interval target dtstart 10))))
@ -1334,7 +1334,7 @@ END:VTIMEZONE
;; A date matching the end of a STANDARD observance:
(let* ((ut (ical:make-date-time :year 2006 :month 10 :day 29
:hour 6 :minute 0 :second 0
:zone 0 :dst nil)) ; UNTIL is in UTC
:zone t :dst nil)) ; UNTIL is in UTC
(dt (ical:make-date-time :year 2006 :month 10 :day 29
:hour 2 :minute 0 :second 0
:zone ict:edt :dst t))
@ -1352,7 +1352,7 @@ END:VTIMEZONE
;; A date matching the end of a DAYLIGHT observance:
(let* ((ut (ical:make-date-time :year 2006 :month 4 :day 2
:hour 7 :minute 0 :second 0
:zone 0 :dst nil)) ; UNTIL is in UTC
:zone t :dst nil)) ; UNTIL is in UTC
(dt (ical:make-date-time :year 2006 :month 4 :day 2
:hour 2 :minute 0 :second 0
:zone ict:est :dst nil))
@ -1374,7 +1374,7 @@ END:VTIMEZONE
:zone ict:est :dst nil))
(end (ical:make-date-time :year 1986 :month 4 :day 27
:hour 7 :minute 0 :second 0
:zone 0)) ; UNTIL is in UTC
:zone t)) ; UNTIL is in UTC
(obs/onset (icr:tz-observance-on dt ict:tz-eastern))
(obs (car obs/onset))
(onset (cadr obs/onset))
@ -2009,7 +2009,7 @@ SOURCE should be a symbol; it is used to name the test."
:hour 9 :minute 0 :second 0
:zone ict:edt :dst t)
:high (ical:make-date-time :year 1997 :month 10 :day 8
:hour 0 :minute 0 :second 0 :zone 0)
:hour 0 :minute 0 :second 0 :zone t)
:members
(list
;; ==> (1997 9:00 AM EDT) September 2,4,9,11,16,18,23,25,30;
@ -2034,7 +2034,7 @@ SOURCE should be a symbol; it is used to name the test."
:hour 9 :minute 0 :second 0
:zone ict:edt :dst t)
:high (ical:make-date-time :year 1997 :month 10 :day 8
:hour 0 :minute 0 :second 0 :zone 0)
:hour 0 :minute 0 :second 0 :zone t)
:members
(list
;; ==> (1997 9:00 AM EDT) September 2,4,9,11,16,18,23,25,30;