fix(ics): reject an RRULE carrying both UNTIL and COUNT (#225)

EventRecurrence.parse ends with two global checks, not one: a missing FREQ
throws, and so does UNTIL together with COUNT. Only the first was mirrored, so a
rule with both still threw out of insert — the failure sanitizeRrule exists to
prevent. UNTIL is kept; it bounds the series at a date a stale COUNT can't
outrun.

Also mark a part that carries text but no '=' as a repair. It took the branch
meant for a trailing ';', so the rule was altered while the user was told
nothing had changed.
This commit is contained in:
2026-08-31 19:11:18 +02:00
parent 7e7356e4c3
commit 89262a0be6
2 changed files with 47 additions and 2 deletions

View File

@@ -80,10 +80,12 @@ fun sanitizeRrule(raw: String?): SanitizedRrule {
}
for (part in rule.split(';')) {
// A stray separator ("FREQ=DAILY;") normalises away; nothing was lost.
// A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part
// being dropped, so it falls through and marks the rule repaired.
if (part.isBlank()) continue
val key = part.substringBefore('=', "").trim().uppercase()
val value = if ('=' in part) part.substringAfter('=').trim() else ""
// A stray separator ("FREQ=DAILY;") normalises away; nothing was lost.
if (key.isEmpty() && value.isEmpty()) continue
val kept = when {
key.isEmpty() || value.isEmpty() -> null
key == "FREQ" ->
@@ -105,6 +107,15 @@ fun sanitizeRrule(raw: String?): SanitizedRrule {
}
if (kept == null) repaired = true else parts += kept
}
// `EventRecurrence.parse` ends with *two* global checks, not one: a missing
// FREQ throws, and so does an UNTIL that arrives together with a COUNT. A
// rule carrying both is malformed per RFC 5545 §3.3.10 but real producers
// emit it, and left alone it would throw straight out of `insert` — the very
// failure this function exists to prevent. UNTIL is kept: it bounds the
// series at an absolute date, so a stale COUNT can't over-generate past it.
if (parts.any { it.startsWith("UNTIL=") }) {
if (parts.removeAll { it.startsWith("COUNT=") }) repaired = true
}
return if (freqSeen) {
SanitizedRrule(rule = parts.joinToString(";"), repaired = repaired)
} else {

View File

@@ -129,4 +129,38 @@ class IcsRecurrenceTest {
.isEqualTo("FREQ=DAILY;UNTIL=20260819T235959Z")
assertThat(sanitizeRrule("FREQ=DAILY;UNTIL=2026-08-19").rule).isEqualTo("FREQ=DAILY")
}
@Test
fun `UNTIL and COUNT together keep UNTIL`() {
// EventRecurrence.parse throws outright on a rule carrying both.
val both = sanitizeRrule("FREQ=WEEKLY;COUNT=10;UNTIL=20260101T000000Z")
assertThat(both.rule).isEqualTo("FREQ=WEEKLY;UNTIL=20260101T000000Z")
assertThat(both.repaired).isTrue()
}
@Test
fun `a COUNT the UNTIL check never sees is left alone`() {
// An UNTIL dropped for its shape doesn't take the COUNT down with it.
val staleUntil = sanitizeRrule("FREQ=WEEKLY;COUNT=10;UNTIL=2026-01-01")
assertThat(staleUntil.rule).isEqualTo("FREQ=WEEKLY;COUNT=10")
assertThat(staleUntil.repaired).isTrue()
}
@Test
fun `a part without a value separator is reported as repaired`() {
val noEquals = sanitizeRrule("FREQ=WEEKLY;BYDAY")
assertThat(noEquals.rule).isEqualTo("FREQ=WEEKLY")
assertThat(noEquals.repaired).isTrue()
}
@Test
fun `a trailing separator is normalisation, not a repair`() {
val trailing = sanitizeRrule("FREQ=DAILY;")
assertThat(trailing.rule).isEqualTo("FREQ=DAILY")
assertThat(trailing.repaired).isFalse()
}
}