Files
Maven/internal/media/image_test.go
T
kami 61ba58388f media: bound an image by pixels, not by compressed bytes
The only cap was 64 MiB of input, and a decode bomb is a small file. A
20000x20000 PNG of flat colour compresses to a few hundred kilobytes,
decodes to 400 million pixels, and flattenAndScale then allocated a
second buffer of the same dimensions before scaling anything. That is
3.2 GB of live heap from one request, on a laptop, in the process that
owns the database and the socket, and max_dim never got a chance to
help. The header is read first now and a source over forty megapixels is
refused. The scaler reads the source through At and allocates only the
destination, so flattening no longer doubles the peak.

Found in review of #72.
2026-08-01 14:21:29 +04:00

261 lines
7.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package media
import (
"bytes"
"encoding/binary"
"errors"
"hash/crc32"
"image"
"image/color"
"image/gif"
"image/jpeg"
"image/png"
"strings"
"testing"
)
// pngBytes builds a w×h test image: left half red, right half a light grey, so
// a downscale that averages produces a predictable mid value and a scaler that
// silently returns the wrong region is visible.
func pngBytes(t *testing.T, w, h int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
if x < w/2 {
img.Set(x, y, color.RGBA{255, 0, 0, 255})
} else {
img.Set(x, y, color.RGBA{200, 200, 200, 255})
}
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func TestSniffImage(t *testing.T) {
cases := []struct {
name string
data []byte
want string
}{
{"png", pngBytes(t, 4, 4), "image/png"},
{"jpeg", jpegBytes(t, 4, 4), "image/jpeg"},
{"gif", gifBytes(t, 4, 4), "image/gif"},
}
for _, c := range cases {
got, err := SniffImage(c.data)
if err != nil {
t.Errorf("%s: %v", c.name, err)
continue
}
if got != c.want {
t.Errorf("%s: got %q want %q", c.name, got, c.want)
}
}
}
// webp is common from Telegram and there is no stdlib decoder, so it must be
// refused by name rather than mis-sniffed or fed to a model as noise.
func TestSniffRefusesWebpByName(t *testing.T) {
webp := append([]byte("RIFF\x00\x00\x00\x00WEBP"), make([]byte, 8)...)
_, err := SniffImage(webp)
if !errors.Is(err, ErrUnsupportedImage) {
t.Fatalf("got %v, want ErrUnsupportedImage", err)
}
if !strings.Contains(err.Error(), "webp") {
t.Errorf("error does not name the format: %v", err)
}
}
func TestSniffRefusesGarbage(t *testing.T) {
for _, data := range [][]byte{nil, []byte("hello"), []byte("\x00\x01\x02\x03")} {
if _, err := SniffImage(data); !errors.Is(err, ErrUnsupportedImage) {
t.Errorf("SniffImage(%q) = %v", data, err)
}
}
}
func TestPrepareImageDownscalesLongestEdge(t *testing.T) {
im, err := PrepareImage(pngBytes(t, 2000, 1000), "web:upload", 500)
if err != nil {
t.Fatalf("prepare: %v", err)
}
if im.Width != 500 || im.Height != 250 {
t.Errorf("got %dx%d, want 500x250", im.Width, im.Height)
}
if _, err := jpeg.Decode(bytes.NewReader(im.JPEG)); err != nil {
t.Errorf("output is not decodable jpeg: %v", err)
}
if im.Source != "web:upload" {
t.Errorf("source lost: %q", im.Source)
}
}
// Tall images scale on the other axis; a scaler that only handles landscape is
// the classic version of this bug.
func TestPrepareImageHandlesPortrait(t *testing.T) {
im, err := PrepareImage(pngBytes(t, 400, 1600), "telegram", 800)
if err != nil {
t.Fatalf("prepare: %v", err)
}
if im.Height != 800 || im.Width != 200 {
t.Errorf("got %dx%d, want 200x800", im.Width, im.Height)
}
}
func TestPrepareImageNeverEnlarges(t *testing.T) {
im, err := PrepareImage(pngBytes(t, 64, 32), "telegram", 896)
if err != nil {
t.Fatalf("prepare: %v", err)
}
if im.Width != 64 || im.Height != 32 {
t.Errorf("got %dx%d, want the original 64x32", im.Width, im.Height)
}
}
// A degenerate strip must not scale to zero on the short axis — jpeg.Encode
// fails on a zero-height image, which would turn a weird screenshot into a
// hard error.
func TestPrepareImageClampsDegenerateAspect(t *testing.T) {
im, err := PrepareImage(pngBytes(t, 2000, 2), "web:upload", 100)
if err != nil {
t.Fatalf("prepare: %v", err)
}
if im.Height < 1 || im.Width != 100 {
t.Errorf("got %dx%d", im.Width, im.Height)
}
}
// Transparent pixels composite onto white, not black: the common case is a
// screenshot or a diagram, and dark-on-black is unreadable to the model.
func TestPrepareImageFlattensAlphaOntoWhite(t *testing.T) {
img := image.NewRGBA(image.Rect(0, 0, 8, 8)) // fully transparent
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatal(err)
}
im, err := PrepareImage(buf.Bytes(), "web:upload", 8)
if err != nil {
t.Fatalf("prepare: %v", err)
}
decoded, err := jpeg.Decode(bytes.NewReader(im.JPEG))
if err != nil {
t.Fatal(err)
}
r, g, b, _ := decoded.At(4, 4).RGBA()
if r>>8 < 240 || g>>8 < 240 || b>>8 < 240 {
t.Errorf("transparent pixel became rgb(%d,%d,%d), want near-white", r>>8, g>>8, b>>8)
}
}
func TestPrepareImageRejectsEmpty(t *testing.T) {
if _, err := PrepareImage(nil, "x", 0); !errors.Is(err, ErrEmpty) {
t.Errorf("got %v, want ErrEmpty", err)
}
}
func TestDataURIIsAJPEGDataURI(t *testing.T) {
im, err := PrepareImage(pngBytes(t, 16, 16), "x", 0)
if err != nil {
t.Fatal(err)
}
uri := im.DataURI()
if !strings.HasPrefix(uri, "data:image/jpeg;base64,") {
t.Fatalf("bad prefix: %.40s", uri)
}
if len(uri) <= len("data:image/jpeg;base64,") {
t.Error("data uri carries no payload")
}
}
func jpegBytes(t *testing.T, w, h int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, nil); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func gifBytes(t *testing.T, w, h int) []byte {
t.Helper()
img := image.NewPaletted(image.Rect(0, 0, w, h), []color.Color{color.Black, color.White})
var buf bytes.Buffer
if err := gif.Encode(&buf, img, nil); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
// A decode bomb is a small file. Nothing bounded pixels before decoding, so a
// 20000x20000 PNG of flat colour — a few hundred kilobytes on the wire, well
// under the byte cap — decoded to 1.6 GB and then allocated another 1.6 GB to
// flatten, in the process that owns the database and the socket.
func TestPrepareImageRefusesADecodeBomb(t *testing.T) {
// The header is what is checked, so the test writes a real header and
// truncated pixel data: reaching the decode at all is the failure.
var buf bytes.Buffer
if err := png.Encode(&buf, image.NewGray(image.Rect(0, 0, 1, 1))); err != nil {
t.Fatal(err)
}
bomb := forgePNGSize(t, buf.Bytes(), 20000, 20000)
_, err := PrepareImage(bomb, "telegram", 0)
if !errors.Is(err, ErrTooManyPixels) {
t.Fatalf("err = %v, want ErrTooManyPixels", err)
}
// A phone photo is not a bomb.
if _, err := PrepareImage(pngBytes(t, 64, 48), "telegram", 0); err != nil {
t.Fatalf("an ordinary image was refused: %v", err)
}
}
// forgePNGSize rewrites the IHDR width and height (and its CRC) of a valid PNG,
// which is how a header claiming 400 megapixels is produced without writing
// 400 megapixels.
func forgePNGSize(t *testing.T, src []byte, w, h uint32) []byte {
t.Helper()
out := append([]byte(nil), src...)
// 8 byte signature, 4 byte length, 4 byte "IHDR", then width and height.
const ihdr = 8 + 4 + 4
binary.BigEndian.PutUint32(out[ihdr:], w)
binary.BigEndian.PutUint32(out[ihdr+4:], h)
crc := crc32.ChecksumIEEE(out[8+4 : ihdr+13])
binary.BigEndian.PutUint32(out[ihdr+13:], crc)
return out
}
// Transparency still composites onto white, which is what makes a screenshot
// readable. The old code did that with a full-size intermediate; the scaler
// walks the source instead and must give the same answer.
func TestPrepareImageFlattensOntoWhite(t *testing.T) {
img := image.NewRGBA(image.Rect(0, 0, 8, 8))
// Fully transparent everywhere: over white, that is white.
data := encodePNG(t, img)
out, err := PrepareImage(data, "test", 4)
if err != nil {
t.Fatal(err)
}
dec, err := jpeg.Decode(bytes.NewReader(out.JPEG))
if err != nil {
t.Fatal(err)
}
r, g, b, _ := dec.At(2, 2).RGBA()
if r>>8 < 240 || g>>8 < 240 || b>>8 < 240 {
t.Fatalf("transparent pixel came out %d,%d,%d, want white", r>>8, g>>8, b>>8)
}
}
func encodePNG(t *testing.T, img image.Image) []byte {
t.Helper()
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}