From dad99c7664b4961236114c78729fa973d5274689 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Thu, 5 Nov 2026 19:05:16 +0100 Subject: [PATCH] fix(calendar): the ICS address parser survives a multibyte prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bare_address sliced trimmed[..7] after a len() >= 7 check — byte count against a byte slice is fine, but the guard and the slice disagree once the text is lossy-decoded elsewhere; an ATTENDEE line whose value opens with a multibyte run panicked the reminder source on a feed the operator does not control. The slice is get(..7) now, and a test feeds multibyte prefixes through both the ATTENDEE and ORGANIZER paths. --- .../rhema-calendar-integration/src/ics.rs | 45 ++++++++++++++++--- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/integrations/rhema-calendar-integration/src/ics.rs b/integrations/rhema-calendar-integration/src/ics.rs index 232fe50..3a34386 100644 --- a/integrations/rhema-calendar-integration/src/ics.rs +++ b/integrations/rhema-calendar-integration/src/ics.rs @@ -540,12 +540,17 @@ fn parse_event<'a>(block: &[ContentLine], provider: &'a str) -> Result= 7 && trimmed[..7].eq_ignore_ascii_case("mailto:") => { - trimmed[7..].trim().to_owned() + // appears, case-insensitively. `get` over indexing: the prefix can + // end inside a multi-byte character, and a slice there would panic + // on a remote feed's value. + let bare_address = |value: &str| { + let trimmed = value.trim(); + match trimmed.get(..7) { + Some(prefix) if prefix.eq_ignore_ascii_case("mailto:") => { + trimmed[7..].trim().to_owned() + } + _ => trimmed.to_owned(), } - trimmed => trimmed.to_owned(), }; let mut raw = Map::new(); @@ -984,6 +989,36 @@ mod tests { ); } + #[test] + fn a_multibyte_address_prefix_parses_without_panicking() { + // The first seven bytes of `üüüüüüüü:x` end inside a + // character, so a plain seven-byte slice would panic on a + // remote feed's deterministic value. + let raw = "BEGIN:VEVENT\nUID:a@x\nDTSTART:20261020T140000Z\n\ + ORGANIZER:üüüüüüüü:x\n\ + ATTENDEE:üüüüüüüü:x\n\ + ATTENDEE:üüüx:alice@example.net\n\ + ATTENDEE:mailto:alice@example.net\n\ + ATTENDEE:MAILTO:Bob@Example.net\n\ + END:VEVENT\n"; + let events = expand(raw, &window("2026-10-20T00:00:00Z", 24)).expect("expands"); + assert_eq!( + events[0].organizer.as_deref(), + Some("üüüüüüüü:x"), + "no mailto: prefix reads verbatim" + ); + assert_eq!( + events[0].attendees, + vec![ + "üüüüüüüü:x".to_owned(), + "üüüx:alice@example.net".to_owned(), + "alice@example.net".to_owned(), + "Bob@Example.net".to_owned(), + ], + "the mailto: strip is case-insensitive; anything else is kept whole" + ); + } + #[test] fn a_weekly_byday_rule_expands_across_the_window() { let raw = "BEGIN:VEVENT\nUID:standup@x\nDTSTART:20261005T090000Z\nDTEND:20261005T093000Z\n\