diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-27 21:27:10 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-27 21:27:10 +0200 |
| commit | 2901c73fde7ff19e8cdea00f933dc628d11c6ec6 (patch) | |
| tree | 5ea265b5f27c0dc05f888cb84ae345e1751307e7 | |
| parent | 45e0b7652552867f7f99ffe22c968641510d5143 (diff) | |
| download | lectio-2901c73fde7ff19e8cdea00f933dc628d11c6ec6.tar.gz lectio-2901c73fde7ff19e8cdea00f933dc628d11c6ec6.zip | |
feat(calfeed): RFC-5545 iCal renderer with injection-safe escaping
| -rw-r--r-- | internal/calfeed/ical.go | 92 | ||||
| -rw-r--r-- | internal/calfeed/ical_test.go | 52 |
2 files changed, 144 insertions, 0 deletions
diff --git a/internal/calfeed/ical.go b/internal/calfeed/ical.go new file mode 100644 index 0000000..bce3710 --- /dev/null +++ b/internal/calfeed/ical.go @@ -0,0 +1,92 @@ +package calfeed + +import ( + "strconv" + "strings" + "time" +) + +// icalEscape neutralises RFC-5545 TEXT specials AND all CR/LF, so untrusted +// celebration names / citations cannot inject iCal lines or properties. +func icalEscape(s string) string { + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, ";", "\\;") + s = strings.ReplaceAll(s, ",", "\\,") + s = strings.ReplaceAll(s, "\r\n", "\\n") + s = strings.ReplaceAll(s, "\r", "\\n") + s = strings.ReplaceAll(s, "\n", "\\n") + return s +} + +// foldLine folds a content line at 75 octets with a leading space on +// continuations (RFC 5545 §3.1). Counts bytes; folding runs after escaping. +func foldLine(line string) string { + if len(line) <= 75 { + return line + } + var b strings.Builder + for i := 0; i < len(line); { + end := i + 75 + if i > 0 { + end = i + 74 // account for the leading space + } + if end > len(line) { + end = len(line) + } + if i > 0 { + b.WriteString("\r\n ") + } + b.WriteString(line[i:end]) + i = end + } + return b.String() +} + +func calName(form string) string { + if form == "old" { + return "Lectio — Extraordinary Form" + } + return "Lectio — Ordinary Form" +} + +// ICal renders days as an RFC-5545 VCALENDAR, one all-day VEVENT per day. +func ICal(form string, days []DayView, stamp time.Time) []byte { + var lines []string + add := func(s string) { lines = append(lines, foldLine(s)) } + add("BEGIN:VCALENDAR") + add("VERSION:2.0") + add("PRODID:-//lectio//calendar//EN") + add("CALSCALE:GREGORIAN") + add("METHOD:PUBLISH") + add("X-WR-CALNAME:" + icalEscape(calName(form))) + ds := stamp.UTC().Format("20060102T150405Z") + for _, d := range days { + date := strings.ReplaceAll(d.Date, "-", "") // YYYYMMDD + next, _ := time.Parse("2006-01-02", d.Date) + end := next.AddDate(0, 0, 1).Format("20060102") + var desc []string + desc = append(desc, "Season: "+d.Season+" (week "+strconv.Itoa(d.Week)+")") + if d.Observed.Rank != "" { + desc = append(desc, "Rank: "+d.Observed.Rank) + } + desc = append(desc, "Colour: "+d.Colour) + for _, r := range d.Readings { + desc = append(desc, r.Part+": "+r.Citation) + } + add("BEGIN:VEVENT") + add("UID:" + d.Date + "-" + form + "@lectio") // input-free, stable + add("DTSTAMP:" + ds) + add("DTSTART;VALUE=DATE:" + date) + add("DTEND;VALUE=DATE:" + end) + add("SUMMARY:" + icalEscape(d.Observed.Name)) + // join with real \n, then escape the whole string so \n -> \\n and every + // TEXT special is neutralised in one pass. + add("DESCRIPTION:" + icalEscape(strings.Join(desc, "\n"))) + if d.Colour != "" { + add("CATEGORIES:" + icalEscape(strings.ToUpper(d.Colour))) + } + add("END:VEVENT") + } + add("END:VCALENDAR") + return []byte(strings.Join(lines, "\r\n") + "\r\n") +} diff --git a/internal/calfeed/ical_test.go b/internal/calfeed/ical_test.go new file mode 100644 index 0000000..fb7ee9a --- /dev/null +++ b/internal/calfeed/ical_test.go @@ -0,0 +1,52 @@ +package calfeed + +import ( + "strings" + "testing" + "time" +) + +func TestICalEscapeInjection(t *testing.T) { + // A malicious custom-calendar name must not be able to inject lines/props. + got := icalEscape("Evil\r\nBEGIN:VEVENT\nSUMMARY:hijack; a,b\\c") + if strings.ContainsAny(got, "\r\n") { + t.Fatalf("unescaped newline survived: %q", got) + } + for _, sub := range []string{`\n`, `\;`, `\,`, `\\`} { + if !strings.Contains(got, sub) { + t.Fatalf("missing escape %q in %q", sub, got) + } + } +} + +func TestICalStructure(t *testing.T) { + days := []DayView{{ + Date: "2026-01-06", Season: "time-after-epiphany", Week: 1, Colour: "white", + Observed: CelView{Name: "The Epiphany of the Lord", Rank: "class-1"}, + Readings: []ReadingView{{Part: "gospel", Citation: "Matt 2:1-12"}}, + }} + out := string(ICal("old", days, time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC))) + for _, want := range []string{ + "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//lectio//calendar//EN", + "BEGIN:VEVENT", "UID:2026-01-06-old@lectio", "DTSTART;VALUE=DATE:20260106", + "DTEND;VALUE=DATE:20260107", "SUMMARY:The Epiphany of the Lord", + "CATEGORIES:WHITE", "DTSTAMP:20260727T120000Z", "END:VEVENT", "END:VCALENDAR", + } { + if !strings.Contains(out, want) { + t.Fatalf("missing %q in:\n%s", want, out) + } + } + // injection attempt via day count: exactly one VEVENT + if strings.Count(out, "BEGIN:VEVENT") != 1 { + t.Fatalf("expected 1 VEVENT") + } +} + +func TestFoldLine(t *testing.T) { + long := "SUMMARY:" + strings.Repeat("x", 200) + for _, line := range strings.Split(foldLine(long), "\r\n") { + if len(line) > 75 { + t.Fatalf("line exceeds 75 octets: %d", len(line)) + } + } +} |
