package mimeparse import ( "errors" "io" "strings" "testing" ) const defaultMaxSize = 10 * 1024 * 1024 // 10 MiB // TestParse_NestedMultipartFullyDecomposed ist Akzeptanzkriterium 1: // Multipart-Nachrichten mit verschachtelten Teilen werden vollständig // zerlegt (multipart/mixed enthält multipart/alternative UND einen // Anhang). func TestParse_NestedMultipartFullyDecomposed(t *testing.T) { raw := "From: a@example.com\r\n" + "To: b@example.com\r\n" + "Subject: Test\r\n" + "MIME-Version: 1.0\r\n" + "Content-Type: multipart/mixed; boundary=\"outer\"\r\n\r\n" + "--outer\r\n" + "Content-Type: multipart/alternative; boundary=\"inner\"\r\n\r\n" + "--inner\r\n" + "Content-Type: text/plain; charset=utf-8\r\n\r\n" + "Hallo als Text\r\n" + "--inner\r\n" + "Content-Type: text/html; charset=utf-8\r\n\r\n" + "
Hallo als HTML
\r\n" + "--inner--\r\n" + "--outer\r\n" + "Content-Type: application/pdf\r\n" + "Content-Disposition: attachment; filename=\"rechnung.pdf\"\r\n" + "Content-Transfer-Encoding: base64\r\n\r\n" + "JVBERi0xLjQK\r\n" + "--outer--\r\n" msg, err := Parse(strings.NewReader(raw), defaultMaxSize) if err != nil { t.Fatalf("parse: %v", err) } if len(msg.Parts) != 3 { t.Fatalf("erwartet 3 zerlegte teile (text, html, anhang), habe %d: %+v", len(msg.Parts), msg.Parts) } var sawText, sawHTML, sawAttachment bool for _, p := range msg.Parts { switch { case p.ContentType == "text/plain": sawText = true if string(p.Content) != "Hallo als Text" { t.Fatalf("unerwarteter text-inhalt: %q", p.Content) } case p.ContentType == "text/html": sawHTML = true case p.IsAttachment: sawAttachment = true } } if !sawText || !sawHTML || !sawAttachment { t.Fatalf("nicht alle erwarteten teile gefunden: text=%v html=%v attachment=%v", sawText, sawHTML, sawAttachment) } } // TestParse_AttachmentMetadataExtracted ist Akzeptanzkriterium 2: // Anhänge werden mit korrektem Dateinamen, Größe und Content-Type // extrahiert. func TestParse_AttachmentMetadataExtracted(t *testing.T) { raw := "From: a@example.com\r\n" + "Content-Type: multipart/mixed; boundary=\"b\"\r\n\r\n" + "--b\r\n" + "Content-Type: text/plain\r\n\r\n" + "Text\r\n" + "--b\r\n" + "Content-Type: image/png\r\n" + "Content-Disposition: attachment; filename=\"bild.png\"\r\n" + "Content-Transfer-Encoding: base64\r\n\r\n" + "iVBORw0KGgo=\r\n" + "--b--\r\n" msg, err := Parse(strings.NewReader(raw), defaultMaxSize) if err != nil { t.Fatalf("parse: %v", err) } var attachment *Part for i := range msg.Parts { if msg.Parts[i].IsAttachment { attachment = &msg.Parts[i] } } if attachment == nil { t.Fatal("kein anhang gefunden") } if attachment.Filename != "bild.png" { t.Fatalf("falscher dateiname: %q", attachment.Filename) } if attachment.ContentType != "image/png" { t.Fatalf("falscher content-type: %q", attachment.ContentType) } if attachment.Size != int64(len(attachment.Content)) || attachment.Size == 0 { t.Fatalf("unplausible größe: %d (content-len %d)", attachment.Size, len(attachment.Content)) } } // TestParse_BrokenCharsetIsRepairedNotAborted ist Akzeptanzkriterium 3: // fehlerhafte/inkonsistente Zeichensatzangaben werden defensiv repariert // statt die Verarbeitung abzubrechen. func TestParse_BrokenCharsetIsRepairedNotAborted(t *testing.T) { // "unbekannt-xyz" ist KEIN gültiger IANA-Zeichensatzname. raw := "From: a@example.com\r\n" + "Content-Type: text/plain; charset=\"unbekannt-xyz\"\r\n\r\n" + "Rohtext trotz kaputtem Charset\r\n" msg, err := Parse(strings.NewReader(raw), defaultMaxSize) if err != nil { t.Fatalf("erwartet KEINEN abbruch bei kaputtem charset, habe: %v", err) } if len(msg.Parts) != 1 { t.Fatalf("erwartet 1 teil, habe %d", len(msg.Parts)) } if !strings.Contains(string(msg.Parts[0].Content), "Rohtext") { t.Fatalf("inhalt fehlt/verstümmelt: %q", msg.Parts[0].Content) } } // TestParse_ISO88591BodyDecodedWithoutAbort ist Akzeptanzkriterium 3 // zusätzlich: ein bekannter Nicht-UTF-8-Zeichensatz wird via // Header-Dekodierung real repariert (RFC-2047-kodierter Dateiname). func TestParse_ISO88591FilenameDecoded(t *testing.T) { // "=?ISO-8859-1?Q?Rechnung_=DC?=" kodiert "Rechnung Ü" (0xDC = 'Ü' in Latin-1). raw := "From: a@example.com\r\n" + "Content-Type: multipart/mixed; boundary=\"b\"\r\n\r\n" + "--b\r\n" + "Content-Type: application/octet-stream\r\n" + "Content-Disposition: attachment; filename=\"=?ISO-8859-1?Q?Rechnung_=DC?=\"\r\n" + "Content-Transfer-Encoding: base64\r\n\r\n" + "AAA=\r\n" + "--b--\r\n" msg, err := Parse(strings.NewReader(raw), defaultMaxSize) if err != nil { t.Fatalf("parse: %v", err) } if len(msg.Parts) != 1 { t.Fatalf("erwartet 1 teil, habe %d", len(msg.Parts)) } if msg.Parts[0].Filename != "Rechnung Ü" { t.Fatalf("erwartet dekodierten dateinamen 'Rechnung Ü', habe %q", msg.Parts[0].Filename) } } // infiniteReader liefert unbegrenzt viele Bytes — simuliert einen sehr // großen/böswilligen Anhang. Ohne io.LimitReader (siehe archivmail // known-issues #3) würde ein io.ReadAll hierauf den Prozessspeicher // erschöpfen; mit readLimited bricht Parse kontrolliert und schnell ab. type infiniteReader struct{} func (infiniteReader) Read(p []byte) (int, error) { for i := range p { p[i] = 'A' } return len(p), nil } // TestParse_OversizedAttachmentRejectedNotMemoryExhausted ist die // geforderte Pflichtprüfung 1. func TestParse_OversizedAttachmentRejectedNotMemoryExhausted(t *testing.T) { const tinyLimit = 1024 // 1 KiB — winzig, damit der Test schnell bleibt header := "From: a@example.com\r\n" + "Content-Type: application/octet-stream\r\n" + "Content-Transfer-Encoding: identity\r\n\r\n" r := io.MultiReader(strings.NewReader(header), infiniteReader{}) _, err := Parse(r, tinyLimit) if !errors.Is(err, ErrAttachmentTooLarge) { t.Fatalf("erwartet ErrAttachmentTooLarge bei unbegrenzt großem anhang, habe: %v", err) } } // TestParse_RealisticCorpusRunsCleanly ist die geforderte // Pflichtprüfung 2: realitätsnahe Multipart-/Encoding-Varianten laufen // fehlerfrei durch. func TestParse_RealisticCorpusRunsCleanly(t *testing.T) { corpus := []string{ // Einfache Textnachricht ohne Multipart. "From: a@example.com\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nEinfacher Text\r\n", // Quoted-Printable. "From: a@example.com\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nUmlaut: =C3=9C\r\n", // Multipart/alternative ohne Anhang. "From: a@example.com\r\nContent-Type: multipart/alternative; boundary=\"x\"\r\n\r\n--x\r\nContent-Type: text/plain\r\n\r\nText\r\n--x\r\nContent-Type: text/html\r\n\r\nText
\r\n--x--\r\n", // Leere Multipart-Nachricht (kein Teil, nur Präambel/Epilog). "From: a@example.com\r\nContent-Type: multipart/mixed; boundary=\"y\"\r\n\r\nPräambel wird ignoriert\r\n--y--\r\nEpilog wird ignoriert\r\n", } for i, raw := range corpus { if _, err := Parse(strings.NewReader(raw), defaultMaxSize); err != nil { t.Fatalf("corpus[%d] fehlgeschlagen: %v\nraw=%q", i, err, raw) } } } // FuzzParse ist die geforderte Pflichtprüfung 3: kaputte MIME-Strukturen // dürfen Parse nicht zum Absturz bringen, nur zu einem kontrollierten // Fehler. func FuzzParse(f *testing.F) { f.Add([]byte("From: a@example.com\r\nContent-Type: multipart/mixed; boundary=\"b\"\r\n\r\n--b\r\nContent-Type: text/plain\r\n\r\nHallo\r\n--b--\r\n")) f.Add([]byte("Content-Type: multipart/mixed; boundary=\r\n\r\nkaputt")) f.Add([]byte("")) f.Add([]byte("From: a@example.com\r\n\r\n")) f.Fuzz(func(t *testing.T, data []byte) { defer func() { if r := recover(); r != nil { t.Fatalf("Parse ist abgestürzt (panic) statt kontrolliert einen Fehler zu liefern: %v", r) } }() _, _ = Parse(strings.NewReader(string(data)), defaultMaxSize) }) }