From e3e216ffbc90a21a4209240d933e657e9fda38d2 Mon Sep 17 00:00:00 2001 From: say Date: Mon, 11 May 2026 16:20:42 -0700 Subject: [PATCH 1/2] fix(fetcher): avoid nil-deref when ianaindex returns no encoding decodeReaderWithCharset called ianaindex.IANA.Encoding(charset) and on failure fell back to ianaindex.IANA.Encoding("utf-8") while discarding the second error. The library can return (nil, nil) for charset names it recognizes but has no implementation for, so encoding could be nil when we chained .NewDecoder(), panicking. Extract a lookupCharsetEncoding helper that tries the requested charset, falls back to utf-8 via ianaindex, and ultimately falls back to encoding/unicode.UTF8 so it always returns a non-nil encoding. decodeHeader's CharsetReader had the same nil-after-success edge case; guard it explicitly with a clear error message. Regression tests cover an unknown charset and the helper's contract. Signed-off-by: say --- fetcher/fetcher.go | 32 ++++++++++++++++++++++++-------- fetcher/fetcher_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index 74f7cb3..273520d 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -30,7 +30,9 @@ import ( "github.com/emersion/go-pgpmail" "github.com/floatpane/matcha/config" "go.mozilla.org/pkcs7" + "golang.org/x/text/encoding" "golang.org/x/text/encoding/ianaindex" + "golang.org/x/text/encoding/unicode" "golang.org/x/text/transform" ) @@ -228,15 +230,26 @@ func decodePart(reader io.Reader, header mail.PartHeader) (string, error) { } func decodeReaderWithCharset(reader io.Reader, charset string) ([]byte, error) { - encoding, err := ianaindex.IANA.Encoding(charset) - if err != nil || encoding == nil { - encoding, _ = ianaindex.IANA.Encoding("utf-8") - } - - transformReader := transform.NewReader(reader, encoding.NewDecoder()) + enc := lookupCharsetEncoding(charset) + transformReader := transform.NewReader(reader, enc.NewDecoder()) return ioutil.ReadAll(transformReader) } +// lookupCharsetEncoding resolves an IANA charset name to an encoding, +// falling back to UTF-8 when the name is unknown, unsupported, or maps +// to a nil encoding (which ianaindex.IANA.Encoding can return for +// recognized-but-unimplemented names). Always returns a non-nil +// encoding so callers can safely chain .NewDecoder(). +func lookupCharsetEncoding(charset string) encoding.Encoding { + if enc, err := ianaindex.IANA.Encoding(charset); err == nil && enc != nil { + return enc + } + if enc, err := ianaindex.IANA.Encoding("utf-8"); err == nil && enc != nil { + return enc + } + return unicode.UTF8 +} + func bestEffortCharset(contentType string) string { for _, param := range strings.Split(contentType, ";") { key, value, found := strings.Cut(param, "=") @@ -256,11 +269,14 @@ func bestEffortCharset(contentType string) string { func decodeHeader(header string) string { dec := new(mime.WordDecoder) dec.CharsetReader = func(charset string, input io.Reader) (io.Reader, error) { - encoding, err := ianaindex.IANA.Encoding(charset) + enc, err := ianaindex.IANA.Encoding(charset) if err != nil { return nil, err } - return transform.NewReader(input, encoding.NewDecoder()), nil + if enc == nil { + return nil, fmt.Errorf("fetcher: no encoding implementation for charset %q", charset) + } + return transform.NewReader(input, enc.NewDecoder()), nil } decoded, err := dec.DecodeHeader(header) if err != nil { diff --git a/fetcher/fetcher_test.go b/fetcher/fetcher_test.go index 9937ad2..5e186fe 100644 --- a/fetcher/fetcher_test.go +++ b/fetcher/fetcher_test.go @@ -54,6 +54,31 @@ func TestDecodePartFallsBackToUTF8WhenMalformedContentTypeHasNoCharset(t *testin } } +// Regression: ianaindex.IANA.Encoding can return (nil, nil) for charset +// names it recognizes but has no implementation for. The previous +// decodeReaderWithCharset ignored that case and would nil-deref when +// chaining .NewDecoder(). +func TestDecodeReaderWithCharsetSurvivesUnknownCharset(t *testing.T) { + decoded, err := decodeReaderWithCharset(strings.NewReader("hello"), "bogus-charset-name") + if err != nil { + t.Fatalf("decodeReaderWithCharset() returned error: %v", err) + } + if string(decoded) != "hello" { + t.Fatalf("decodeReaderWithCharset() = %q, want %q", string(decoded), "hello") + } +} + +func TestLookupCharsetEncodingAlwaysReturnsNonNil(t *testing.T) { + cases := []string{"", "utf-8", "iso-8859-1", "bogus-charset-name", "this/is/not/real"} + for _, name := range cases { + t.Run(name, func(t *testing.T) { + if enc := lookupCharsetEncoding(name); enc == nil { + t.Fatalf("lookupCharsetEncoding(%q) returned nil", name) + } + }) + } +} + // TestFetchEmails is an integration test that requires a live IMAP server and valid credentials. // NOTE: This test will be skipped if it cannot load a configuration file, // making it safe to run in a CI environment without credentials. From ee69fed4fb5fb115b803356e0dc7d934cc9c61c5 Mon Sep 17 00:00:00 2001 From: SAY-5 Date: Mon, 11 May 2026 17:52:57 -0700 Subject: [PATCH 2/2] trim verbose comments --- fetcher/fetcher.go | 6 +----- fetcher/fetcher_test.go | 4 ---- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index 273520d..006998c 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -235,11 +235,7 @@ func decodeReaderWithCharset(reader io.Reader, charset string) ([]byte, error) { return ioutil.ReadAll(transformReader) } -// lookupCharsetEncoding resolves an IANA charset name to an encoding, -// falling back to UTF-8 when the name is unknown, unsupported, or maps -// to a nil encoding (which ianaindex.IANA.Encoding can return for -// recognized-but-unimplemented names). Always returns a non-nil -// encoding so callers can safely chain .NewDecoder(). +// lookupCharsetEncoding resolves a charset name, falling back to UTF-8. func lookupCharsetEncoding(charset string) encoding.Encoding { if enc, err := ianaindex.IANA.Encoding(charset); err == nil && enc != nil { return enc diff --git a/fetcher/fetcher_test.go b/fetcher/fetcher_test.go index 5e186fe..af4c379 100644 --- a/fetcher/fetcher_test.go +++ b/fetcher/fetcher_test.go @@ -54,10 +54,6 @@ func TestDecodePartFallsBackToUTF8WhenMalformedContentTypeHasNoCharset(t *testin } } -// Regression: ianaindex.IANA.Encoding can return (nil, nil) for charset -// names it recognizes but has no implementation for. The previous -// decodeReaderWithCharset ignored that case and would nil-deref when -// chaining .NewDecoder(). func TestDecodeReaderWithCharsetSurvivesUnknownCharset(t *testing.T) { decoded, err := decodeReaderWithCharset(strings.NewReader("hello"), "bogus-charset-name") if err != nil {