From d9dc6fc55f75b83a3385d19461e86e146ecd1aeb Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 14 Aug 2026 19:00:10 -0400 Subject: [PATCH] fix(artwork): reject a nil reader in the resize cache instead of panicking resizedItem.Reader closes what open() hands back, so an open() that reports "no image" as (nil, nil) rather than an error takes the request down with a nil-pointer panic. Every caller returns an error today, and no test covered it: the resolution e2e harness stubs the resize reader out entirely, so no e2e path reaches this code at all. Guard it and cover Reader directly. --- core/artwork/image_cache.go | 4 ++++ core/artwork/image_cache_test.go | 41 ++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 core/artwork/image_cache_test.go diff --git a/core/artwork/image_cache.go b/core/artwork/image_cache.go index 39938a755..b1970d21d 100644 --- a/core/artwork/image_cache.go +++ b/core/artwork/image_cache.go @@ -55,6 +55,10 @@ func (r *resizedItem) Reader(ctx context.Context) (io.ReadCloser, error) { if err != nil { return nil, err } + // An open() that reports "no image" as a nil reader would otherwise panic on the Close below. + if orig == nil { + return nil, ErrUnavailable + } defer orig.Close() data, err := readCapped(orig) if err != nil { diff --git a/core/artwork/image_cache_test.go b/core/artwork/image_cache_test.go new file mode 100644 index 000000000..a74c51088 --- /dev/null +++ b/core/artwork/image_cache_test.go @@ -0,0 +1,41 @@ +package artwork + +import ( + "context" + "errors" + "io" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("resizedItem", func() { + Describe("Reader", func() { + newItem := func(open func() (io.ReadCloser, error)) *resizedItem { + return &resizedItem{hash: "abc123", size: 300, open: open} + } + + It("reports a nil reader as unavailable instead of panicking on it", func() { + // Every caller is expected to report "no image" as an error, but a nil reader reaches + // the deferred Close as a nil interface, which takes the whole request down. + _, err := newItem(func() (io.ReadCloser, error) { return nil, nil }).Reader(context.Background()) + Expect(err).To(MatchError(ErrUnavailable)) + }) + + It("propagates the open error", func() { + boom := errors.New("boom") + _, err := newItem(func() (io.ReadCloser, error) { return nil, boom }).Reader(context.Background()) + Expect(err).To(MatchError(boom)) + }) + + It("serves the original bytes when they cannot be resized", func() { + rc, err := newItem(func() (io.ReadCloser, error) { + return io.NopCloser(strings.NewReader("not an image")), nil + }).Reader(context.Background()) + Expect(err).ToNot(HaveOccurred()) + defer rc.Close() + Expect(io.ReadAll(rc)).To(Equal([]byte("not an image"))) + }) + }) +})