Repository: jhillyerd/enmime
Pull request #212 (2021-08-15 19:13:50)

Move RFC2047 decoding into internal/coding package
- Move rfc2047 decoding into internal/coding package
- Tidy formatting/comments
- Rename existing decode tests
- Add rfc2047 specific test



Patch:
diff --git a/header.go b/header.go
index 579267e..4103a3c 100644
--- a/header.go
+++ b/header.go
@@ -288,7 +288,7 @@ func fixMangledMediaType(mtype string, sep rune) string {
 			}
 
 			// RFC-2047 encoded attribute name.
-			p = rfc2047decode(p)
+			p = coding.RFC2047Decode(p)
 
 			pair := strings.SplitAfter(p, "=")
 			if strings.Contains(mtype, strings.TrimSpace(pair[0])) {
diff --git a/inspect.go b/inspect.go
index fc5da53..ea5b557 100644
--- a/inspect.go
+++ b/inspect.go
@@ -3,10 +3,8 @@ package enmime
 import (
 	"bufio"
 	"bytes"
-	"fmt"
 	"io"
 	"net/textproto"
-	"strings"
 
 	"github.com/jhillyerd/enmime/internal/coding"
 	"github.com/pkg/errors"
@@ -45,7 +43,7 @@ func DecodeHeaders(b []byte, addtlHeaders ...string) (textproto.MIMEHeader, erro
 		h := textproto.CanonicalMIMEHeaderKey(header)
 		res[h] = make([]string, 0, len(headers[h]))
 		for _, value := range headers[h] {
-			res[h] = append(res[h], rfc2047decode(value))
+			res[h] = append(res[h], coding.RFC2047Decode(value))
 		}
 	}
 
@@ -74,97 +72,3 @@ func ensureHeaderBoundary(b []byte) []byte {
 
 	return dest
 }
-
-// rfc2047decode returns a decoded string if the input uses RFC2047 encoding, otherwise it will return the input.
-// RFC2047 Example:
-//     `=?UTF-8?B?bmFtZT0iw7DCn8KUwoo=?=`
-func rfc2047decode(s string) string {
-	s = strings.Map(func(r rune) rune {
-		if r == '\n' || r == '\r' {
-			return ' '
-		}
-		return r
-	}, s)
-	var err error
-	decoded := false
-	for {
-		s, err = rfc2047recurse(s)
-		switch err {
-		case nil:
-			decoded = true
-			continue
-		default:
-			if decoded {
-				keyValuePair := strings.SplitAfter(s, "=")
-				if len(keyValuePair) < 2 {
-					return s
-				}
-				// Add quotes as needed
-				if !strings.HasPrefix(keyValuePair[1], "\"") {
-					keyValuePair[1] = fmt.Sprintf("\"%s", keyValuePair[1])
-				}
-				if !strings.HasSuffix(keyValuePair[1], "\"") {
-					keyValuePair[1] = fmt.Sprintf("%s\"", keyValuePair[1])
-				}
-				return strings.Join(keyValuePair, "")
-			}
-			return s
-		}
-	}
-}
-
-// rfc2047recurse is called for if the value contains content encoded in RFC2047 format and decodes it.
-// RFC2047 Example:
-//     `=?UTF-8?B?bmFtZT0iw7DCn8KUwoo=?=`
-func rfc2047recurse(s string) (string, error) {
-	us := strings.ToUpper(s)
-	if !strings.Contains(us, "?Q?") && !strings.Contains(us, "?B?") {
-		return s, io.EOF
-	}
-
-	var val string
-	if val = coding.DecodeExtHeader(s); val == s {
-		if val = coding.DecodeExtHeader(fixRFC2047String(val)); val == s {
-			return val, io.EOF
-		}
-	}
-
-	return val, nil
-}
-
-// fixRFC2047String removes the following characters from charset and encoding segments of an RFC2047 string:
-//  '\n', '\r' and ' '
-func fixRFC2047String(s string) string {
-	inString := false
-	isWithinTerminatingEqualSigns := false
-	questionMarkCount := 0
-	sb := &strings.Builder{}
-	for _, v := range s {
-		switch v {
-		case '=':
-			if questionMarkCount == 3 {
-				inString = false
-			} else {
-				isWithinTerminatingEqualSigns = true
-			}
-			sb.WriteRune(v)
-		case '?':
-			if isWithinTerminatingEqualSigns {
-				inString = true
-			} else {
-				questionMarkCount++
-			}
-			isWithinTerminatingEqualSigns = false
-			sb.WriteRune(v)
-		case '\n', '\r', ' ':
-			if !inString {
-				sb.WriteRune(v)
-			}
-			isWithinTerminatingEqualSigns = false
-		default:
-			isWithinTerminatingEqualSigns = false
-			sb.WriteRune(v)
-		}
-	}
-	return sb.String()
-}
diff --git a/internal/coding/headerext.go b/internal/coding/headerext.go
index def8ee4..10b3fe2 100644
--- a/internal/coding/headerext.go
+++ b/internal/coding/headerext.go
@@ -1,12 +1,14 @@
 package coding
 
 import (
+	"fmt"
+	"io"
 	"mime"
 	"strings"
 )
 
 // DecodeExtHeader decodes a single line (per RFC 2047, aka Message Header Extensions) using Golang's
-// mime.WordDecoder
+// mime.WordDecoder.
 func DecodeExtHeader(input string) string {
 	if !strings.Contains(input, "=?") {
 		// Don't scan if there is nothing to do here
@@ -19,5 +21,110 @@ func DecodeExtHeader(input string) string {
 	if err != nil {
 		return input
 	}
+
 	return header
 }
+
+// RFC2047Decode returns a decoded string if the input uses RFC2047 encoding, otherwise it will
+// return the input.
+//
+// RFC2047 Example: `=?UTF-8?B?bmFtZT0iw7DCn8KUwoo=?=`
+func RFC2047Decode(s string) string {
+	// Convert CR/LF to spaces.
+	s = strings.Map(func(r rune) rune {
+		if r == '\n' || r == '\r' {
+			return ' '
+		}
+		return r
+	}, s)
+
+	var err error
+	decoded := false
+	for {
+		s, err = rfc2047Recurse(s)
+		switch err {
+		case nil:
+			decoded = true
+			continue
+
+		default:
+			if decoded {
+				keyValuePair := strings.SplitAfter(s, "=")
+				if len(keyValuePair) < 2 {
+					return s
+				}
+
+				// Add quotes as needed.
+				if !strings.HasPrefix(keyValuePair[1], "\"") {
+					keyValuePair[1] = fmt.Sprintf("\"%s", keyValuePair[1])
+				}
+				if !strings.HasSuffix(keyValuePair[1], "\"") {
+					keyValuePair[1] = fmt.Sprintf("%s\"", keyValuePair[1])
+				}
+
+				return strings.Join(keyValuePair, "")
+			}
+
+			return s
+		}
+	}
+}
+
+// rfc2047Recurse is called for if the value contains content encoded in RFC2047 format and decodes
+// it.
+func rfc2047Recurse(s string) (string, error) {
+	us := strings.ToUpper(s)
+	if !strings.Contains(us, "?Q?") && !strings.Contains(us, "?B?") {
+		return s, io.EOF
+	}
+
+	var val string
+	if val = DecodeExtHeader(s); val == s {
+		if val = DecodeExtHeader(fixRFC2047String(val)); val == s {
+			return val, io.EOF
+		}
+	}
+
+	return val, nil
+}
+
+// fixRFC2047String removes the following characters from charset and encoding segments of an
+// RFC2047 string: '\n', '\r' and ' '
+func fixRFC2047String(s string) string {
+	inString := false
+	isWithinTerminatingEqualSigns := false
+	questionMarkCount := 0
+	sb := &strings.Builder{}
+	for _, v := range s {
+		switch v {
+		case '=':
+			if questionMarkCount == 3 {
+				inString = false
+			} else {
+				isWithinTerminatingEqualSigns = true
+			}
+			sb.WriteRune(v)
+
+		case '?':
+			if isWithinTerminatingEqualSigns {
+				inString = true
+			} else {
+				questionMarkCount++
+			}
+			isWithinTerminatingEqualSigns = false
+			sb.WriteRune(v)
+
+		case '\n', '\r', ' ':
+			if !inString {
+				sb.WriteRune(v)
+			}
+			isWithinTerminatingEqualSigns = false
+
+		default:
+			isWithinTerminatingEqualSigns = false
+			sb.WriteRune(v)
+		}
+	}
+
+	return sb.String()
+}


Test patch:
diff --git a/internal/coding/headerext_test.go b/internal/coding/headerext_test.go
index 46725bb..05d35ff 100644
--- a/internal/coding/headerext_test.go
+++ b/internal/coding/headerext_test.go
@@ -4,7 +4,7 @@ import (
 	"testing"
 )
 
-func TestPlainPassthrough(t *testing.T) {
+func TestDecodePlainPassthrough(t *testing.T) {
 	var ttable = []string{
 		"Test",
 		"Testing One two 3 4",
@@ -14,13 +14,13 @@ func TestPlainPassthrough(t *testing.T) {
 		t.Run(in, func(t *testing.T) {
 			got := DecodeExtHeader(in)
 			if got != in {
-				t.Errorf("DecodeHeader(%q) == %q, want: %q", in, got, in)
+				t.Errorf("DecodeExtHeader(%q) == %q, want: %q", in, got, in)
 			}
 		})
 	}
 }
 
-func TestFailurePassthrough(t *testing.T) {
+func TestDecodeFailurePassthrough(t *testing.T) {
 	var ttable = []struct {
 		label, in string
 	}{
@@ -42,13 +42,13 @@ func TestFailurePassthrough(t *testing.T) {
 		t.Run(tt.in, func(t *testing.T) {
 			got := DecodeExtHeader(tt.in)
 			if got != tt.in {
-				t.Errorf("DecodeHeader(%q) == %q, want: %q", tt.in, got, tt.in)
+				t.Errorf("DecodeExtHeader(%q) == %q, want: %q", tt.in, got, tt.in)
 			}
 		})
 	}
 }
 
-func TestAsciiB64(t *testing.T) {
+func TestDecodeAsciiB64(t *testing.T) {
 	var ttable = []struct {
 		in, want string
 	}{
@@ -64,13 +64,13 @@ func TestAsciiB64(t *testing.T) {
 		t.Run(tt.in, func(t *testing.T) {
 			got := DecodeExtHeader(tt.in)
 			if got != tt.want {
-				t.Errorf("DecodeHeader(%q) == %q, want: %q", tt.in, got, tt.want)
+				t.Errorf("DecodeExtHeader(%q) == %q, want: %q", tt.in, got, tt.want)
 			}
 		})
 	}
 }
 
-func TestAsciiQ(t *testing.T) {
+func TestDecodeAsciiQ(t *testing.T) {
 	var ttable = []struct {
 		in, want string
 	}{
@@ -86,14 +86,14 @@ func TestAsciiQ(t *testing.T) {
 		t.Run(tt.in, func(t *testing.T) {
 			got := DecodeExtHeader(tt.in)
 			if got != tt.want {
-				t.Errorf("DecodeHeader(%q) == %q, want: %q", tt.in, got, tt.want)
+				t.Errorf("DecodeExtHeader(%q) == %q, want: %q", tt.in, got, tt.want)
 			}
 		})
 	}
 }
 
 // Spacing rules from RFC 2047
-func TestSpacing(t *testing.T) {
+func TestDecodeSpacing(t *testing.T) {
 	var ttable = []struct {
 		in, want string
 	}{
@@ -110,14 +110,14 @@ func TestSpacing(t *testing.T) {
 		t.Run(tt.in, func(t *testing.T) {
 			got := DecodeExtHeader(tt.in)
 			if got != tt.want {
-				t.Errorf("DecodeHeader(%q) == %q, want: %q", tt.in, got, tt.want)
+				t.Errorf("DecodeExtHeader(%q) == %q, want: %q", tt.in, got, tt.want)
 			}
 		})
 	}
 }
 
 // Test some different character sets
-func TestCharsets(t *testing.T) {
+func TestDecodeCharsets(t *testing.T) {
 	var ttable = []struct {
 		in, want string
 	}{
@@ -130,7 +130,27 @@ func TestCharsets(t *testing.T) {
 		t.Run(tt.in, func(t *testing.T) {
 			got := DecodeExtHeader(tt.in)
 			if got != tt.want {
-				t.Errorf("DecodeHeader(%q) == %q, want: %q", tt.in, got, tt.want)
+				t.Errorf("DecodeExtHeader(%q) == %q, want: %q", tt.in, got, tt.want)
+			}
+		})
+	}
+}
+
+func TestRfc2047Decode(t *testing.T) {
+	var ttable = []struct {
+		label, in, want string
+	}{
+		{"pass through", "plain text", "plain text"},
+		{"quoted", "=?US-ASCII?q?Hello=20World?=", "Hello World"},
+		{"base64", "=?US-ASCII?b?SGVsbG8gV29ybGQ=?=", "Hello World"},
+		{"nested qp+b64", "=?utf-8?b?PT9VUy1BU0NJST9xP0hlbGxvPTIwV29ybGQ/PQ==?=", "Hello World"},
+	}
+
+	for _, tt := range ttable {
+		t.Run(tt.label, func(t *testing.T) {
+			got := RFC2047Decode(tt.in)
+			if got != tt.want {
+				t.Errorf("RFC2047Decode(%q) == %q, want: %q", tt.in, got, tt.want)
 			}
 		})
 	}