Merge the calendar, email and event sweep (#246)
Four real defects, two of them silent. FactSpan built both instants as midnight.Add(hours). A day is 23 or 25 hours wide on the two DST changeovers, so every span on those days was an hour off and the busy gate read a 14:00 meeting as 13:00 or 15:00. Both readings are time.Date now, and the midnight crossing is AddDate rather than adding 24 hours. parseVEVENT split the block on newlines and trimmed each one, which destroys the leading space that marks a folded continuation. Servers fold at 75 octets and a Russian summary is two bytes a letter, so the tail of an ordinary weekly standup was read as an unknown property and dropped. The event was filed under a truncated name, and through safeKey a truncated fact key. Unfolding runs before the split now. RenderICal escaped TEXT and the parse never unescaped it, so a server-written summary reached the day plan with its backslashes. The MIME walk recursed with no depth cap and the nesting comes off the wire. A boundary line is a few bytes, so one message inside MaxMessageBytes can declare tens of thousands of levels. MaxMIMEDepth is 12 and the headers still come through. Two whole-body copies went with it. Read-only IMAP confirmed rather than assumed: EXAMINE not SELECT, BODY.PEEK not BODY, and no STORE, APPEND, EXPUNGE, COPY or MOVE anywhere in the package or the daemon. No credential is logged, and the dial seam is unexported so no caller can point the reader at a plaintext transport. internal/event needed nothing. (V-581)
This commit is contained in:
@@ -140,6 +140,12 @@ const EventKeyPrefix = "calendar_event_"
|
||||
//
|
||||
// An end at or before the start is read as crossing midnight, so a 23:30-00:15
|
||||
// meeting covers the quarter hour it actually covers.
|
||||
//
|
||||
// Both readings are built with time.Date rather than added to midnight as a
|
||||
// duration. A day is 23 or 25 hours wide on the two DST changeovers, so
|
||||
// midnight plus fourteen hours is 13:00 or 15:00 on those days, and the busy
|
||||
// gate would then read a 14:00 meeting an hour off. The same goes for the
|
||||
// midnight crossing, which is AddDate and not a 24-hour add.
|
||||
func FactSpan(key, value string, loc *time.Location) (start, end time.Time, ok bool) {
|
||||
if !strings.HasPrefix(key, EventKeyPrefix) {
|
||||
return time.Time{}, time.Time{}, false
|
||||
@@ -172,10 +178,11 @@ func FactSpan(key, value string, loc *time.Location) (start, end time.Time, ok b
|
||||
if !ok1 || !ok2 {
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
start = day.Add(time.Duration(sh)*time.Hour + time.Duration(sm)*time.Minute)
|
||||
end = day.Add(time.Duration(eh)*time.Hour + time.Duration(em)*time.Minute)
|
||||
y, mo, d := day.Date()
|
||||
start = time.Date(y, mo, d, sh, sm, 0, 0, loc)
|
||||
end = time.Date(y, mo, d, eh, em, 0, 0, loc)
|
||||
if !end.After(start) {
|
||||
end = end.Add(24 * time.Hour)
|
||||
end = end.AddDate(0, 0, 1)
|
||||
}
|
||||
return start, end, true
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ func ParseICalDay(body []byte, now time.Time) []Event {
|
||||
// Reports false for all-day events and parse failures.
|
||||
func parseVEVENT(block string, loc *time.Location) (Event, bool) {
|
||||
var e Event
|
||||
for _, line := range strings.Split(block, "\n") {
|
||||
for _, line := range strings.Split(unfold(block), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(line, "DTSTART"):
|
||||
@@ -78,9 +78,9 @@ func parseVEVENT(block string, loc *time.Location) (Event, bool) {
|
||||
e.End = t
|
||||
}
|
||||
case strings.HasPrefix(line, "SUMMARY"):
|
||||
e.Summary = afterColon(line)
|
||||
e.Summary = unescapeText(afterColon(line))
|
||||
case strings.HasPrefix(line, "UID"):
|
||||
e.UID = afterColon(line)
|
||||
e.UID = unescapeText(afterColon(line))
|
||||
}
|
||||
}
|
||||
if e.Start.IsZero() || e.End.IsZero() {
|
||||
@@ -89,6 +89,48 @@ func parseVEVENT(block string, loc *time.Location) (Event, bool) {
|
||||
return e, true
|
||||
}
|
||||
|
||||
// unfold undoes RFC 5545 content-line folding, where a long property is split
|
||||
// with a CRLF and the continuation begins with one space or tab.
|
||||
//
|
||||
// It runs before the block is split into lines, because splitting first and
|
||||
// trimming each line destroys the leading space that marks a continuation. A
|
||||
// server folds at 75 octets and a Russian summary is two bytes a letter, so
|
||||
// "Еженедельная планёрка с командой" crosses the limit easily — without this
|
||||
// the tail of the summary was read as an unknown property and dropped, and the
|
||||
// event was filed under a truncated name.
|
||||
func unfold(block string) string {
|
||||
if !strings.Contains(block, "\n ") && !strings.Contains(block, "\n\t") {
|
||||
return block
|
||||
}
|
||||
return strings.NewReplacer("\r\n ", "", "\r\n\t", "", "\n ", "", "\n\t", "").Replace(block)
|
||||
}
|
||||
|
||||
// unescapeText reverses the RFC 5545 TEXT escaping escapeText applies. Without
|
||||
// it a summary a server wrote as "Обед\, потом созвон" reaches the day plan
|
||||
// with the backslash still in it, and FactKey folds that literal into the key.
|
||||
func unescapeText(s string) string {
|
||||
if !strings.Contains(s, `\`) {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] != '\\' || i+1 >= len(s) {
|
||||
b.WriteByte(s[i])
|
||||
continue
|
||||
}
|
||||
i++
|
||||
switch s[i] {
|
||||
case 'n', 'N':
|
||||
b.WriteByte('\n')
|
||||
default:
|
||||
// ";", ",", "\\" and anything else a writer escaped needlessly.
|
||||
b.WriteByte(s[i])
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func afterColon(line string) string {
|
||||
if i := strings.Index(line, ":"); i >= 0 {
|
||||
return strings.TrimSpace(line[i+1:])
|
||||
|
||||
@@ -61,6 +61,44 @@ func TestRenderICalEscapesInjection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A folded SUMMARY is one property, not a property plus a dropped tail. Servers
|
||||
// fold at 75 octets and a Russian summary is two bytes a letter.
|
||||
func TestParseICalUnfoldsAndUnescapes(t *testing.T) {
|
||||
body := []byte("BEGIN:VEVENT\r\n" +
|
||||
"UID:u1\r\n" +
|
||||
"DTSTART:20260703T130000Z\r\n" +
|
||||
"DTEND:20260703T140000Z\r\n" +
|
||||
"SUMMARY:Еженедельная планёрка\\, потом\r\n созвон\r\n" +
|
||||
"END:VEVENT\r\n")
|
||||
from := time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC)
|
||||
events := ParseICal(body, from, from.AddDate(0, 0, 1))
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("got %d events, want 1", len(events))
|
||||
}
|
||||
if want := "Еженедельная планёрка, потом созвон"; events[0].Summary != want {
|
||||
t.Errorf("Summary = %q, want %q", events[0].Summary, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A day is 23 hours wide where DST starts, so a wall clock reading has to be
|
||||
// built with time.Date and never as midnight plus a duration.
|
||||
func TestFactSpanAcrossDSTStart(t *testing.T) {
|
||||
loc, err := time.LoadLocation("Europe/Berlin")
|
||||
if err != nil {
|
||||
t.Skipf("no tzdata for Europe/Berlin: %v", err)
|
||||
}
|
||||
start, end, ok := FactSpan("calendar_event_20260329_Planerka", "Planerka @ 14:00-15:00", loc)
|
||||
if !ok {
|
||||
t.Fatal("FactSpan reported not ok")
|
||||
}
|
||||
if start.Hour() != 14 || start.Minute() != 0 {
|
||||
t.Errorf("start = %s, want a 14:00 wall clock", start)
|
||||
}
|
||||
if end.Hour() != 15 {
|
||||
t.Errorf("end = %s, want a 15:00 wall clock", end)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReminderEventEmptyPayload(t *testing.T) {
|
||||
e := ReminderEvent(3, time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), " ", 0)
|
||||
if e.Summary != "напоминание" {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package email
|
||||
|
||||
import "strings"
|
||||
|
||||
// windows-1251 (and its ASCII-compatible low half) is decoded here rather than
|
||||
// pulled in from x/text.
|
||||
//
|
||||
@@ -35,14 +37,19 @@ var cp1251High = [128]rune{
|
||||
|
||||
// decodeCP1251 maps each byte through the table. Every byte has a defined
|
||||
// meaning in this charset, so decoding cannot fail.
|
||||
//
|
||||
// It writes into a Builder rather than collecting runes: a []rune of the whole
|
||||
// body is four bytes a character and was then copied again into the string, so
|
||||
// a 1 MiB cp1251 mail allocated about 6 MiB to produce roughly 2.
|
||||
func decodeCP1251(b []byte) string {
|
||||
out := make([]rune, 0, len(b))
|
||||
var out strings.Builder
|
||||
out.Grow(len(b))
|
||||
for _, c := range b {
|
||||
if c < 0x80 {
|
||||
out = append(out, rune(c))
|
||||
out.WriteByte(c)
|
||||
continue
|
||||
}
|
||||
out = append(out, cp1251High[c-0x80])
|
||||
out.WriteRune(cp1251High[c-0x80])
|
||||
}
|
||||
return string(out)
|
||||
return out.String()
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -57,7 +58,10 @@ type Message struct {
|
||||
// through, because a subject line alone is often the whole task ("Счёт за
|
||||
// интернет"). Only a message whose headers cannot be read at all is an error.
|
||||
func ParseMessage(uid uint32, raw []byte) (Message, error) {
|
||||
m, err := mail.ReadMessage(strings.NewReader(string(raw)))
|
||||
// bytes.NewReader, not strings.NewReader(string(raw)): the conversion copied
|
||||
// the whole message, and MaxMessageBytes lets that be 2 MiB per mail on a box
|
||||
// already holding the resident model.
|
||||
m, err := mail.ReadMessage(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return Message{}, fmt.Errorf("email: parse message: %w", err)
|
||||
}
|
||||
@@ -82,6 +86,23 @@ func ParseMessage(uid uint32, raw []byte) (Message, error) {
|
||||
// wholesale — an attachment is a file, not a sentence, and reading one would
|
||||
// mean parsing arbitrary formats from the network.
|
||||
func plaintextBody(contentType, encoding string, body io.Reader) (string, error) {
|
||||
return plaintextBodyAt(contentType, encoding, body, 0)
|
||||
}
|
||||
|
||||
// MaxMIMEDepth — how deep the MIME tree is walked.
|
||||
//
|
||||
// The nesting comes off the wire, so the recursion depth is the sender's to
|
||||
// pick: a boundary line is a few bytes, and one message inside MaxMessageBytes
|
||||
// can declare tens of thousands of multipart levels. Real mail is three deep
|
||||
// (mixed, then alternative, then related), so a message past this is malformed
|
||||
// or hostile and truncating the walk costs a body nobody was going to read.
|
||||
const MaxMIMEDepth = 12
|
||||
|
||||
// plaintextBodyAt is plaintextBody carrying the current nesting depth.
|
||||
func plaintextBodyAt(contentType, encoding string, body io.Reader, depth int) (string, error) {
|
||||
if depth > MaxMIMEDepth {
|
||||
return "", nil
|
||||
}
|
||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||
if contentType == "" || err != nil {
|
||||
// No Content-Type at all is legal and means text/plain; a broken one is
|
||||
@@ -94,7 +115,7 @@ func plaintextBody(contentType, encoding string, body io.Reader) (string, error)
|
||||
if boundary == "" {
|
||||
return "", fmt.Errorf("email: multipart without boundary")
|
||||
}
|
||||
plain, html, err := multipartText(multipart.NewReader(body, boundary))
|
||||
plain, html, err := multipartText(multipart.NewReader(body, boundary), depth+1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -124,7 +145,7 @@ func plaintextBody(contentType, encoding string, body io.Reader) (string, error)
|
||||
// contribute either kind. Folding a nested level's answer into one string put
|
||||
// HTML-derived text in the plain bucket, and a real text/plain sibling later in
|
||||
// the message was then thrown away by the "plain is already set" guard.
|
||||
func multipartText(mr *multipart.Reader) (plain, html string, err error) {
|
||||
func multipartText(mr *multipart.Reader, depth int) (plain, html string, err error) {
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err == io.EOF {
|
||||
@@ -143,8 +164,8 @@ func multipartText(mr *multipart.Reader) (plain, html string, err error) {
|
||||
switch {
|
||||
case strings.HasPrefix(mediaType, "multipart/"):
|
||||
var np, nh string
|
||||
if b := params["boundary"]; b != "" {
|
||||
np, nh, _ = multipartText(multipart.NewReader(part, b))
|
||||
if b := params["boundary"]; b != "" && depth <= MaxMIMEDepth {
|
||||
np, nh, _ = multipartText(multipart.NewReader(part, b), depth+1)
|
||||
}
|
||||
part.Close()
|
||||
if plain == "" {
|
||||
@@ -154,7 +175,7 @@ func multipartText(mr *multipart.Reader) (plain, html string, err error) {
|
||||
html = nh
|
||||
}
|
||||
default:
|
||||
text, terr := plaintextBody(ct, part.Header.Get("Content-Transfer-Encoding"), part)
|
||||
text, terr := plaintextBodyAt(ct, part.Header.Get("Content-Transfer-Encoding"), part, depth)
|
||||
part.Close()
|
||||
if terr != nil || strings.TrimSpace(text) == "" {
|
||||
continue
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -143,6 +144,24 @@ func TestParseTruncatesLongBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Nesting depth comes off the wire, so a hostile message must not get to pick
|
||||
// the recursion depth. The walk stops and the headers still come through.
|
||||
func TestParseMessageBoundsMIMEDepth(t *testing.T) {
|
||||
var b strings.Builder
|
||||
b.WriteString("Subject: deep\r\nMIME-Version: 1.0\r\n")
|
||||
for i := 0; i < MaxMIMEDepth+20; i++ {
|
||||
fmt.Fprintf(&b, "Content-Type: multipart/mixed; boundary=\"b%d\"\r\n\r\n--b%d\r\n", i, i)
|
||||
}
|
||||
b.WriteString("Content-Type: text/plain\r\n\r\nглубоко\r\n")
|
||||
msg, err := ParseMessage(7, []byte(b.String()))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMessage: %v", err)
|
||||
}
|
||||
if msg.Subject != "deep" {
|
||||
t.Errorf("Subject = %q, want the headers to survive", msg.Subject)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollapseSqueezesBlankLines(t *testing.T) {
|
||||
got := collapse(" a b \r\n\r\n\r\n\r\n c \r\n")
|
||||
if got != "a b\n\nc" {
|
||||
|
||||
Reference in New Issue
Block a user