From 2d08933fbcb89c9a925f93931e4844a083020ec0 Mon Sep 17 00:00:00 2001 From: Maurus Decimus <11444311+mdecimus@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:59:03 +0200 Subject: [PATCH] Fix Calendar: No expanded occurrences are returned for a daily recurrences crossing DST --- CHANGELOG.md | 1 + crates/groupware/src/calendar/alarm.rs | 55 ++++-------- crates/groupware/src/calendar/expand.rs | 114 +++++++++--------------- tests/src/webdav/cal_query.rs | 53 +++++++++++ 4 files changed, 115 insertions(+), 108 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3568ce9..2f0365b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If - CalDAV: `calendar-query` REPORT returns empty calendar-data for JMAP-created events. - MTA: DMARC is skipped when MAIL FROM SPF is unavailable. - Calendar: + - No expanded occurrences are returned for a daily recurrences crossing DST. - Uppercase `MAILTO` calendar addresses become invalid SMTP recipients. - Scheduling invitations on a shared, non-owned calendar fail with `MAIL FROM unauthorized`. - HTTP: Disable `allowedEndpoints` expression in recovery mode. diff --git a/crates/groupware/src/calendar/alarm.rs b/crates/groupware/src/calendar/alarm.rs index 86e22874..2bc59fd3 100644 --- a/crates/groupware/src/calendar/alarm.rs +++ b/crates/groupware/src/calendar/alarm.rs @@ -4,7 +4,9 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use super::{Alarm, AlarmDelta, ArchivedAlarmDelta, ArchivedCalendarEventData}; +use super::{ + Alarm, AlarmDelta, ArchivedAlarmDelta, ArchivedCalendarEventData, expand::resolve_local, +}; use calcard::{ common::timezone::Tz, icalendar::{ @@ -12,7 +14,6 @@ use calcard::{ ICalendarRelated, ICalendarValue, }, }; -use chrono::{DateTime, TimeZone}; use std::str::FromStr; use store::write::bitpack::BitpackIterator; use utils::codec::leb128::Leb128Reader; @@ -74,18 +75,12 @@ impl ArchivedCalendarEventData { for start_offset in unpacker { let start_date_naive = start_offset as i64 + base_offset; let end_date_naive = start_date_naive + duration; - let start = start_tz - .from_local_datetime( - &DateTime::from_timestamp(start_date_naive, 0)?.naive_local(), - ) - .single()? - .timestamp(); - let end = end_tz - .from_local_datetime( - &DateTime::from_timestamp(end_date_naive, 0)?.naive_local(), - ) - .single()? - .timestamp(); + let (Some(start), Some(end)) = ( + resolve_local(start_tz, start_date_naive), + resolve_local(end_tz, end_date_naive), + ) else { + continue; + }; if let Some(alarm_time) = alarm.delta.to_timestamp(start, end, default_tz) && alarm_time > start_time @@ -124,18 +119,12 @@ impl ArchivedCalendarEventData { // Single event let start_date_naive = offset_or_count as i64 + base_offset; let end_date_naive = start_date_naive + duration; - let start = start_tz - .from_local_datetime( - &DateTime::from_timestamp(start_date_naive, 0)?.naive_local(), - ) - .single()? - .timestamp(); - let end = end_tz - .from_local_datetime( - &DateTime::from_timestamp(end_date_naive, 0)?.naive_local(), - ) - .single()? - .timestamp(); + let (Some(start), Some(end)) = ( + resolve_local(start_tz, start_date_naive), + resolve_local(end_tz, end_date_naive), + ) else { + continue; + }; if let Some(alarm_time) = alarm.delta.to_timestamp(start, end, default_tz) && alarm_time > start_time @@ -265,10 +254,7 @@ impl AlarmDelta { AlarmDelta::Start(delta) => Some(start + delta), AlarmDelta::End(delta) => Some(end + delta), AlarmDelta::FixedUtc(timestamp) => Some(*timestamp), - AlarmDelta::FixedFloating(timestamp) => default_tz - .from_local_datetime(&DateTime::from_timestamp(*timestamp, 0)?.naive_local()) - .single() - .map(|dt| dt.timestamp()), + AlarmDelta::FixedFloating(timestamp) => resolve_local(default_tz, *timestamp), } } } @@ -279,12 +265,9 @@ impl ArchivedAlarmDelta { ArchivedAlarmDelta::Start(delta) => Some(start + delta.to_native()), ArchivedAlarmDelta::End(delta) => Some(end + delta.to_native()), ArchivedAlarmDelta::FixedUtc(timestamp) => Some(timestamp.to_native()), - ArchivedAlarmDelta::FixedFloating(timestamp) => default_tz - .from_local_datetime( - &DateTime::from_timestamp(timestamp.to_native(), 0)?.naive_local(), - ) - .single() - .map(|dt| dt.timestamp()), + ArchivedAlarmDelta::FixedFloating(timestamp) => { + resolve_local(default_tz, timestamp.to_native()) + } } } } diff --git a/crates/groupware/src/calendar/expand.rs b/crates/groupware/src/calendar/expand.rs index 632ba67b..e1458c34 100644 --- a/crates/groupware/src/calendar/expand.rs +++ b/crates/groupware/src/calendar/expand.rs @@ -55,18 +55,13 @@ impl ArchivedCalendarEventData { for start_offset in unpacker { let start_date_naive = start_offset as i64 + base_offset; let end_date_naive = start_date_naive + duration; - let start = start_tz - .from_local_datetime( - &DateTime::from_timestamp(start_date_naive, 0)?.naive_local(), - ) - .single()? - .timestamp(); - let end = end_tz - .from_local_datetime( - &DateTime::from_timestamp(end_date_naive, 0)?.naive_local(), - ) - .single()? - .timestamp(); + let (Some(start), Some(end)) = ( + resolve_local(start_tz, start_date_naive), + resolve_local(end_tz, end_date_naive), + ) else { + expansion_id += 1; + continue; + }; if limit.is_in_range(is_todo, start, end) { expansion.push(CalendarEventExpansion { @@ -85,20 +80,11 @@ impl ArchivedCalendarEventData { // Single event let start_date_naive = offset_or_count as i64 + base_offset; let end_date_naive = start_date_naive + duration; - let start = start_tz - .from_local_datetime( - &DateTime::from_timestamp(start_date_naive, 0)?.naive_local(), - ) - .single()? - .timestamp(); - let end = end_tz - .from_local_datetime( - &DateTime::from_timestamp(end_date_naive, 0)?.naive_local(), - ) - .single()? - .timestamp(); - - if limit.is_in_range(is_todo, start, end) { + if let (Some(start), Some(end)) = ( + resolve_local(start_tz, start_date_naive), + resolve_local(end_tz, end_date_naive), + ) && limit.is_in_range(is_todo, start, end) + { expansion.push(CalendarEventExpansion { comp_id, expansion_id: base_expansion_id, @@ -157,25 +143,17 @@ impl CalendarEventData { if expansion_ids.remove(&expansion_id) { let start_date_naive = start_offset as i64 + base_offset; let end_date_naive = start_date_naive + range.duration as i64; - let start = start_tz - .from_local_datetime( - &DateTime::from_timestamp(start_date_naive, 0)?.naive_local(), - ) - .single()? - .timestamp(); - let end = end_tz - .from_local_datetime( - &DateTime::from_timestamp(end_date_naive, 0)?.naive_local(), - ) - .single()? - .timestamp(); - - expansion.push(CalendarEventExpansion { - comp_id: range.id as u32, - expansion_id, - start, - end, - }); + if let (Some(start), Some(end)) = ( + resolve_local(start_tz, start_date_naive), + resolve_local(end_tz, end_date_naive), + ) { + expansion.push(CalendarEventExpansion { + comp_id: range.id as u32, + expansion_id, + start, + end, + }); + } match_count -= 1; if match_count == 0 { @@ -194,25 +172,17 @@ impl CalendarEventData { // Single event let start_date_naive = offset_or_count as i64 + base_offset; let end_date_naive = start_date_naive + range.duration as i64; - let start = start_tz - .from_local_datetime( - &DateTime::from_timestamp(start_date_naive, 0)?.naive_local(), - ) - .single()? - .timestamp(); - let end = end_tz - .from_local_datetime( - &DateTime::from_timestamp(end_date_naive, 0)?.naive_local(), - ) - .single()? - .timestamp(); - - expansion.push(CalendarEventExpansion { - comp_id: range.id as u32, - expansion_id: base_expansion_id, - start, - end, - }); + if let (Some(start), Some(end)) = ( + resolve_local(start_tz, start_date_naive), + resolve_local(end_tz, end_date_naive), + ) { + expansion.push(CalendarEventExpansion { + comp_id: range.id as u32, + expansion_id: base_expansion_id, + start, + end, + }); + } if expansion_ids.is_empty() { break 'outer; @@ -263,14 +233,8 @@ impl CalendarEventData { }; let start_date_naive = start_offset as i64 + self.base_offset; let end_date_naive = start_date_naive + range.duration as i64; - let start = start_tz - .from_local_datetime(&DateTime::from_timestamp(start_date_naive, 0)?.naive_local()) - .single()? - .timestamp(); - let end = end_tz - .from_local_datetime(&DateTime::from_timestamp(end_date_naive, 0)?.naive_local()) - .single()? - .timestamp(); + let start = resolve_local(start_tz, start_date_naive)?; + let end = resolve_local(end_tz, end_date_naive)?; Some(CalendarEventExpansion { comp_id, @@ -297,3 +261,9 @@ impl CalendarEventExpansion { self.comp_id != u32::MAX && self.start != i64::MAX && self.end != i64::MAX } } + +pub(crate) fn resolve_local(tz: Tz, naive_secs: i64) -> Option { + tz.from_local_datetime(&DateTime::from_timestamp(naive_secs, 0)?.naive_local()) + .earliest() + .map(|dt| dt.timestamp()) +} diff --git a/tests/src/webdav/cal_query.rs b/tests/src/webdav/cal_query.rs index 0f79b65d..1f6159b0 100644 --- a/tests/src/webdav/cal_query.rs +++ b/tests/src/webdav/cal_query.rs @@ -13,6 +13,7 @@ use groupware::{ calendar::{CalendarEventData, alarm::ExpandAlarm, expand::CalendarEventExpansion}, }; use hyper::StatusCode; +use std::str::FromStr; use store::write::serialize::rkyv_unarchive; use types::TimeRange; @@ -342,6 +343,44 @@ fn roundtrip_expansion(ics: &str, ignore_errors: bool) { assert_eq!(events, events_archive); } +#[test] +fn calendar_expand_dst_fallback() { + let akl = Tz::from_str("Pacific/Auckland").unwrap(); + let ical = ICalendar::parse(ICAL_DST_FALLBACK_ICS).unwrap(); + let event_data = CalendarEventData::new(ical, akl, 1000, &mut None); + let expanded_bytes = rkyv::to_bytes::(&event_data).unwrap(); + let archive = rkyv_unarchive::(&expanded_bytes).unwrap(); + + let window = archive + .expand( + akl, + TimeRange { + start: 1782777600, + end: 1786579200, + }, + ) + .unwrap(); + assert!( + !window.is_empty(), + "recurrence expansion aborted across the Pacific/Auckland DST fall-back overlap" + ); + + let across_overlap = archive + .expand( + akl, + TimeRange { + start: 1775260800, + end: 1775433600, + }, + ) + .unwrap(); + assert_eq!( + across_overlap.len(), + 1, + "the ambiguous fall-back occurrence must resolve to its earliest instant" + ); +} + fn rfc_file_name(num: usize) -> String { format!( "{}/john%40example.com/default/abcd{num}.ics", @@ -823,6 +862,20 @@ END:VFREEBUSY END:VCALENDAR "#; +const ICAL_DST_FALLBACK_ICS: &str = r#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VEVENT +DTSTAMP:20260108T000000Z +DTSTART;TZID=Pacific/Auckland:20260108T022000 +DURATION:PT2H10M +RRULE:FREQ=DAILY;INTERVAL=3 +SUMMARY:Auckland DST fall-back recurrence +UID:auckland-dst-fallback@example.com +END:VEVENT +END:VCALENDAR +"#; + const ICAL_RFC_ABCD1_ICS: &str = r#"BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example Corp.//CalDAV Client//EN