Fix Calendar: No expanded occurrences are returned for a daily recurrences crossing DST
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<i64> {
|
||||
tz.from_local_datetime(&DateTime::from_timestamp(naive_secs, 0)?.naive_local())
|
||||
.earliest()
|
||||
.map(|dt| dt.timestamp())
|
||||
}
|
||||
|
||||
@@ -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::<rkyv::rancor::Error>(&event_data).unwrap();
|
||||
let archive = rkyv_unarchive::<CalendarEventData>(&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
|
||||
|
||||
Reference in New Issue
Block a user