summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-27 21:27:10 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-27 21:27:10 +0200
commit2901c73fde7ff19e8cdea00f933dc628d11c6ec6 (patch)
tree5ea265b5f27c0dc05f888cb84ae345e1751307e7 /internal
parent45e0b7652552867f7f99ffe22c968641510d5143 (diff)
downloadlectio-2901c73fde7ff19e8cdea00f933dc628d11c6ec6.tar.gz
lectio-2901c73fde7ff19e8cdea00f933dc628d11c6ec6.zip
feat(calfeed): RFC-5545 iCal renderer with injection-safe escaping
Diffstat (limited to 'internal')
-rw-r--r--internal/calfeed/ical.go92
-rw-r--r--internal/calfeed/ical_test.go52
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))
+ }
+ }
+}