Build image / build-and-push (push) Successful in 6s
Security / correctness (multi-model): - Wrap the decoders in a recover (decodeSafely): a malformed HEIC/WebP that panics libheif-via-WASM or x/image/webp now fails this one request as ErrUnsupported instead of taking the process down. - Make the pixel-bomb guard overflow-safe: check each side against maxDimension BEFORE multiplying, so a header claiming ~2^32 on a side can't wrap int64(w)*int64(h) negative and slip past the pixel-count cap. - Lower maxDecodePixels 100 MP → 50 MP (~200 MB peak), still above any current phone sensor, capping the amplification a small hostile header can force. - Sentinel errors use errors.New, not fmt.Errorf without a verb. The finding 5 models agreed on: the decompression-bomb guard was UNTESTED and a stale comment implied otherwise. Added TestNormalizeRejectsPixelBomb, which crafts a ~40-byte PNG (valid IHDR + CRC) claiming a huge canvas and asserts ErrTooLarge from DecodeConfig alone, before any bitmap is allocated — covering both the per-side and the area trip. Fixed the stale comment. Error-handling / docs: - format is now "" on ALL error paths (was populated on some, empty on others). - Doc rewritten to state the actual error contract (read/encode I/O → wrapped, not a sentinel) and to stop referencing a non-existent TestNormalize / TODO. - Guard io.LimitReader's +1 against an int64 overflow at an absurd MaxBytes. Tests / provenance: - Downscale test derives its numbers from DefaultMaxDim instead of hard-coding. - Check the previously-ignored DecodeConfig error in the small-image case. - testdata/README documents where sample.heic/webp came from. Deferred to the #81 upload handler, documented in the Normalize doc: EXIF orientation (best fixed and tested with a real oriented photo end-to-end) and context cancellation (image.Decode isn't cancellable mid-decode; the caller runs it under a timeout, and the size guards keep the work finite regardless). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
203 lines
6.8 KiB
Go
203 lines
6.8 KiB
Go
package imagenorm
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"hash/crc32"
|
|
"image"
|
|
"image/jpeg"
|
|
"image/png"
|
|
"os"
|
|
"testing"
|
|
)
|
|
|
|
// pngBytes and jpegBytes generate in-memory fixtures for the two formats Go can
|
|
// encode; heic/webp come from testdata (Go has no encoder for them).
|
|
func pngBytes(t *testing.T, w, h int) []byte {
|
|
t.Helper()
|
|
m := image.NewRGBA(image.Rect(0, 0, w, h))
|
|
for y := range h {
|
|
for x := range w {
|
|
m.Pix[m.PixOffset(x, y)+0] = uint8(x)
|
|
m.Pix[m.PixOffset(x, y)+3] = 255
|
|
}
|
|
}
|
|
var b bytes.Buffer
|
|
if err := png.Encode(&b, m); err != nil {
|
|
t.Fatalf("encode png: %v", err)
|
|
}
|
|
return b.Bytes()
|
|
}
|
|
|
|
func jpegBytes(t *testing.T, w, h int) []byte {
|
|
t.Helper()
|
|
m := image.NewRGBA(image.Rect(0, 0, w, h))
|
|
var b bytes.Buffer
|
|
if err := jpeg.Encode(&b, m, nil); err != nil {
|
|
t.Fatalf("encode jpeg: %v", err)
|
|
}
|
|
return b.Bytes()
|
|
}
|
|
|
|
func readTestdata(t *testing.T, name string) []byte {
|
|
t.Helper()
|
|
b, err := os.ReadFile("testdata/" + name)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", name, err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// TestNormalizeAllFormats is the load-bearing test: every format pansy claims to
|
|
// accept must round-trip to a valid JPEG. It exists specifically to catch a
|
|
// dropped blank import — the failure mode where the common format (PNG) breaks
|
|
// while the exotic one (HEIC) works, because someone deleted `_ "image/png"`.
|
|
func TestNormalizeAllFormats(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
input []byte
|
|
wantFormat string
|
|
}{
|
|
{"png", pngBytes(t, 120, 90), "png"},
|
|
{"jpeg", jpegBytes(t, 120, 90), "jpeg"},
|
|
{"heic", readTestdata(t, "sample.heic"), "heic"},
|
|
{"webp", readTestdata(t, "sample.webp"), "webp"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
out, format, err := Normalize(bytes.NewReader(tc.input), Options{})
|
|
if err != nil {
|
|
t.Fatalf("Normalize(%s): %v", tc.name, err)
|
|
}
|
|
if format != tc.wantFormat {
|
|
t.Errorf("format = %q, want %q", format, tc.wantFormat)
|
|
}
|
|
// The output must itself be a decodable JPEG.
|
|
_, outFormat, err := image.Decode(bytes.NewReader(out))
|
|
if err != nil {
|
|
t.Fatalf("output isn't a valid image: %v", err)
|
|
}
|
|
if outFormat != "jpeg" {
|
|
t.Errorf("output format = %q, want jpeg", outFormat)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestNormalizeDownscales checks a large image is shrunk to fit MaxDim on its
|
|
// longest edge with aspect ratio preserved, and a small one is left alone.
|
|
func TestNormalizeDownscales(t *testing.T) {
|
|
// A 2:1 image twice as wide as DefaultMaxDim → clamped to DefaultMaxDim on the
|
|
// long edge with aspect preserved. Derived from the constant, not hard-coded,
|
|
// so the test tracks the default rather than silently asserting a magic number.
|
|
longEdge := DefaultMaxDim * 2
|
|
big := pngBytes(t, longEdge, longEdge/2)
|
|
out, _, err := Normalize(bytes.NewReader(big), Options{})
|
|
if err != nil {
|
|
t.Fatalf("Normalize: %v", err)
|
|
}
|
|
cfg, _, err := image.DecodeConfig(bytes.NewReader(out))
|
|
if err != nil {
|
|
t.Fatalf("decode out: %v", err)
|
|
}
|
|
if cfg.Width != DefaultMaxDim {
|
|
t.Errorf("width = %d, want %d (longest edge clamped)", cfg.Width, DefaultMaxDim)
|
|
}
|
|
if cfg.Height != DefaultMaxDim/2 {
|
|
t.Errorf("height = %d, want %d (aspect preserved)", cfg.Height, DefaultMaxDim/2)
|
|
}
|
|
|
|
// A small image within bounds keeps its dimensions.
|
|
small := pngBytes(t, 100, 80)
|
|
out, _, err = Normalize(bytes.NewReader(small), Options{})
|
|
if err != nil {
|
|
t.Fatalf("Normalize small: %v", err)
|
|
}
|
|
cfg, _, err = image.DecodeConfig(bytes.NewReader(out))
|
|
if err != nil {
|
|
t.Fatalf("decode small out: %v", err)
|
|
}
|
|
if cfg.Width != 100 || cfg.Height != 80 {
|
|
t.Errorf("small image resized to %dx%d, want 100x80", cfg.Width, cfg.Height)
|
|
}
|
|
}
|
|
|
|
// TestNormalizeRejectsOversizeInput: an input past the byte cap is ErrTooLarge,
|
|
// refused without a full decode.
|
|
func TestNormalizeRejectsOversizeInput(t *testing.T) {
|
|
big := pngBytes(t, 500, 500)
|
|
_, _, err := Normalize(bytes.NewReader(big), Options{MaxBytes: 100})
|
|
if err != ErrTooLarge {
|
|
t.Errorf("over-cap input err = %v, want ErrTooLarge", err)
|
|
}
|
|
}
|
|
|
|
// TestNormalizeRejectsGarbage: unreadable-as-image bytes and a truncated image
|
|
// both fail cleanly with ErrUnsupported, not a panic.
|
|
func TestNormalizeRejectsGarbage(t *testing.T) {
|
|
_, _, err := Normalize(bytes.NewReader([]byte("not an image at all")), Options{})
|
|
if err != ErrUnsupported {
|
|
t.Errorf("garbage err = %v, want ErrUnsupported", err)
|
|
}
|
|
// A truncated image (valid header, cut body) also fails cleanly, not a panic.
|
|
png := pngBytes(t, 100, 100)
|
|
_, _, err = Normalize(bytes.NewReader(png[:len(png)/2]), Options{})
|
|
if err != ErrUnsupported {
|
|
t.Errorf("truncated image err = %v, want ErrUnsupported", err)
|
|
}
|
|
}
|
|
|
|
// pngHeader builds a valid PNG signature + IHDR chunk (with a correct CRC, which
|
|
// DecodeConfig verifies) for the given dimensions, and nothing else. It's enough
|
|
// for image.DecodeConfig to report width/height without a real bitmap — exactly
|
|
// what's needed to exercise the pre-decode size guard with a tiny input.
|
|
func pngHeader(w, h uint32) []byte {
|
|
var buf bytes.Buffer
|
|
buf.Write([]byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a})
|
|
ihdr := make([]byte, 13)
|
|
binary.BigEndian.PutUint32(ihdr[0:], w)
|
|
binary.BigEndian.PutUint32(ihdr[4:], h)
|
|
ihdr[8] = 8 // bit depth
|
|
ihdr[9] = 6 // colour type: RGBA
|
|
// compression/filter/interlace already 0.
|
|
binary.Write(&buf, binary.BigEndian, uint32(len(ihdr)))
|
|
chunk := append([]byte("IHDR"), ihdr...)
|
|
buf.Write(chunk)
|
|
binary.Write(&buf, binary.BigEndian, crc32.ChecksumIEEE(chunk))
|
|
return buf.Bytes()
|
|
}
|
|
|
|
// TestNormalizeRejectsPixelBomb is the guard the review found untested: a small
|
|
// input (a bare ~40-byte PNG header) claiming an enormous canvas is refused with
|
|
// ErrTooLarge from DecodeConfig alone, before image.Decode allocates anything.
|
|
// Covers both the per-side maxDimension trip and the pixel-count trip — and, via
|
|
// the near-2^16-per-side case, that the count math doesn't overflow.
|
|
func TestNormalizeRejectsPixelBomb(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
w, h uint32
|
|
}{
|
|
{"huge single side", 60000, 10}, // > maxDimension on width
|
|
{"huge area within side cap", 40000, 40000}, // sides < cap, area 1.6 GP > maxDecodePixels
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
hdr := pngHeader(tc.w, tc.h)
|
|
if len(hdr) > 100 {
|
|
t.Fatalf("header unexpectedly large (%d bytes) — not a bomb test", len(hdr))
|
|
}
|
|
// Sanity: the header really does decode to those dimensions.
|
|
cfg, _, err := image.DecodeConfig(bytes.NewReader(hdr))
|
|
if err != nil {
|
|
t.Fatalf("crafted PNG header didn't parse: %v", err)
|
|
}
|
|
if uint32(cfg.Width) != tc.w || uint32(cfg.Height) != tc.h {
|
|
t.Fatalf("header reports %dx%d, want %dx%d", cfg.Width, cfg.Height, tc.w, tc.h)
|
|
}
|
|
if _, _, err := Normalize(bytes.NewReader(hdr), Options{}); err != ErrTooLarge {
|
|
t.Errorf("pixel bomb %dx%d err = %v, want ErrTooLarge", tc.w, tc.h, err)
|
|
}
|
|
})
|
|
}
|
|
}
|