fix(calendar): the ICS address parser survives a multibyte prefix

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.
This commit is contained in:
vhaudiquet committed 2026-11-05 19:05:16 +01:00
1 parent 00adf092ff
commit dad99c7664
1 file changed
+39 -4
@@ -540,12 +540,17 @@ fn parse_event<'a>(block: &[ContentLine], provider: &'a str) -> Result<Parsed<'a
let text = |name: &str| first(name).map(|line| unescape(&line.value));
// `mailto:` is the scheme ICS addresses ride; strip it wherever it
// appears, case-insensitively.
let bare_address = |value: &str| match value.trim() {
trimmed if trimmed.len() >= 7 && trimmed[..7].eq_ignore_ascii_case("mailto:") => {
// 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 => trimmed.to_owned(),
_ => 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\