Terminplaner: ICS-Faltung prueft Surrogatpaare statt wirkungslosem UTF-8-Decode

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-03 08:17:43 +00:00
1 parent 181445a69d
commit 171b5086b1
1 file changed
+36 -5
+36 -5
View File
@@ -28,8 +28,17 @@ const termin = (extra = {}) => ({
}) })
const enc = new TextEncoder() const enc = new TextEncoder()
const dec = new TextDecoder('utf-8', { fatal: true })
const octets = (s) => enc.encode(s).length const octets = (s) => enc.encode(s).length
/**
* Jede physische Zeile hoechstens 75 Oktette und wohlgeformtes UTF-16 (kein halbes Surrogatpaar). Ein Decode-Test
* (TextDecoder) taugt dafuer nicht: JS-Strings kodieren immer, einzelne Surrogate werden still zu U+FFFD.
*/
const pruefeZeilen = (text) => {
for (const zeile of text.split('\r\n')) {
assert.ok(octets(zeile) <= 75, `Zeile zu lang (${octets(zeile)}): ${zeile}`)
assert.ok(zeile.isWellFormed(), `Zeile mit halbem Surrogatpaar: ${JSON.stringify(zeile)}`)
}
}
/** Zeilenfaltung rueckgaengig machen: CRLF + Leerzeichen faellt weg. */ /** Zeilenfaltung rueckgaengig machen: CRLF + Leerzeichen faellt weg. */
const entfalte = (text) => text.replace(/\r\n /g, '') const entfalte = (text) => text.replace(/\r\n /g, '')
/** Logische Zeilen (entfaltet, ohne das abschliessende CRLF). */ /** Logische Zeilen (entfaltet, ohne das abschliessende CRLF). */
@@ -80,16 +89,38 @@ test('Zeilenlaenge: jede Zeile hoechstens 75 Oktette, nie mitten in einem UTF-8-
for (let n = 40; n < 130; n += 1) { for (let n = 40; n < 130; n += 1) {
const titel = baustein.repeat(n) const titel = baustein.repeat(n)
const ics = icsTermin(termin({ titel, ort: titel, beschreibung: titel, organisator: { name: titel, email: 'info@salon-muster.de' }, teilnehmer: { name: titel, email: 'anna@example.com' } })) const ics = icsTermin(termin({ titel, ort: titel, beschreibung: titel, organisator: { name: titel, email: 'info@salon-muster.de' }, teilnehmer: { name: titel, email: 'anna@example.com' } }))
for (const zeile of ics.split('\r\n')) { pruefeZeilen(ics)
assert.ok(octets(zeile) <= 75, `Zeile zu lang (${octets(zeile)}): ${zeile}`)
assert.doesNotThrow(() => dec.decode(enc.encode(zeile)))
}
for (const folge of ics.split('\r\n').filter((l) => l.startsWith(' '))) assert.ok(folge.length > 1, 'Folgezeile nicht leer') for (const folge of ics.split('\r\n').filter((l) => l.startsWith(' '))) assert.ok(folge.length > 1, 'Folgezeile nicht leer')
assert.equal(entescape(wert(ics, 'SUMMARY').slice('SUMMARY:'.length)), titel, 'Entfalten ergibt den Titel wieder') assert.equal(entescape(wert(ics, 'SUMMARY').slice('SUMMARY:'.length)), titel, 'Entfalten ergibt den Titel wieder')
} }
} }
}) })
test('Zeilenlaenge: Emoji (Surrogatpaare) werden nie zwischen ihren Haelften gefaltet, in Termin und Abo', () => {
let gefaltet = 0
for (const vorsatz of ['', 'a', 'ab', 'abc']) {
for (let n = 1; n < 60; n += 1) {
const titel = vorsatz + '😀'.repeat(n)
const felder = { titel, ort: titel, beschreibung: titel, organisator: { name: titel, email: 'info@salon-muster.de' }, teilnehmer: { name: titel, email: 'anna@example.com' } }
const ics = icsTermin(termin(felder))
pruefeZeilen(ics)
gefaltet += ics.split('\r\n').filter((l) => l.startsWith(' ')).length
assert.equal(entescape(wert(ics, 'SUMMARY').slice('SUMMARY:'.length)), titel)
assert.equal(entescape(wert(ics, 'DESCRIPTION').slice('DESCRIPTION:'.length)), titel)
const feed = icsKalender([termin(felder)], { name: titel, jetzt: JETZT })
pruefeZeilen(feed)
assert.equal(entescape(wert(feed, 'X-WR-CALNAME').slice('X-WR-CALNAME:'.length)), titel)
}
}
assert.ok(gefaltet > 500, `der Test faltet wirklich (${gefaltet} Folgezeilen)`)
// Beispiel: DESCRIPTION: ist 12 Oktette lang, danach passen 15 Emoji (60 Oktette) in die erste Zeile, das 16. beginnt die Folgezeile
const beschreibung = '😀'.repeat(16)
const zeilen = icsTermin(termin({ beschreibung })).split('\r\n')
const i = zeilen.findIndex((l) => l.startsWith('DESCRIPTION:'))
assert.equal(zeilen[i], `DESCRIPTION:${'😀'.repeat(15)}`)
assert.equal(zeilen[i + 1], ' 😀')
})
test('Zeilenlaenge: eine Zeile von genau 75 Oktetten bleibt ungefaltet, 76 wird gefaltet', () => { test('Zeilenlaenge: eine Zeile von genau 75 Oktetten bleibt ungefaltet, 76 wird gefaltet', () => {
const zeileMit = (n) => icsTermin(termin({ titel: 'x'.repeat(n - 'SUMMARY:'.length) })).split('\r\n').find((l) => l.startsWith('SUMMARY:')) const zeileMit = (n) => icsTermin(termin({ titel: 'x'.repeat(n - 'SUMMARY:'.length) })).split('\r\n').find((l) => l.startsWith('SUMMARY:'))
assert.equal(octets(zeileMit(75)), 75) assert.equal(octets(zeileMit(75)), 75)