From 74f6b9a8766076b70b3ca760f1fdddd190a9e748 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Tue, 21 Jul 2026 23:03:40 -0400 Subject: [PATCH 1/2] =?UTF-8?q?Image=20normalization=20package:=20decode?= =?UTF-8?q?=20HEIC/webp/png/jpeg=20=E2=86=92=20JPEG=20(#80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prerequisite for seed-packet capture (#81). majordomo's media pipeline is stdlib-based and cannot decode HEIC or webp — and HEIC is the iPhone camera default, so the capture feature's very first input would fail on the device that motivates it. Normalizing at the upload boundary means everything downstream only ever sees JPEG. internal/imagenorm.Normalize(reader, opts) decodes any of jpeg/png/heic/webp, downscales to fit MaxDim (2048, ollama-cloud's limit) on the longest edge with Catmull-Rom (sharp on the text a packet is mostly made of), and re-encodes JPEG. CGO stays off: github.com/gen2brain/heic runs libheif as WASM via wazero (pure Go), golang.org/x/image/webp is pure Go. Both register with image.Decode. The ~5 MB these add only links into the binary when #81 imports this package — it's not pulled into cmd/pansy yet, so main is unchanged in size for now. Guards against hostile uploads: input capped at MaxBytes (ErrTooLarge) before a full read, and the decoded pixel count checked from DecodeConfig BEFORE the bitmap is allocated, so a small file claiming a huge canvas (a decompression bomb) is refused up front. TestNormalizeAllFormats round-trips all four formats to a valid JPEG — its real job is catching a dropped blank import, where the intuition-breaking failure is that the COMMON format (png) breaks while the exotic one (heic) still works. heic/webp fixtures live in testdata (Go has no encoder for them); the webp is a known-good 16x16 from Python's stdlib test corpus, verified to decode. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ --- go.mod | 8 +- go.sum | 28 +++-- internal/imagenorm/imagenorm.go | 157 ++++++++++++++++++++++++ internal/imagenorm/imagenorm_test.go | 141 +++++++++++++++++++++ internal/imagenorm/testdata/sample.heic | Bin 0 -> 1110 bytes internal/imagenorm/testdata/sample.webp | Bin 0 -> 432 bytes 6 files changed, 322 insertions(+), 12 deletions(-) create mode 100644 internal/imagenorm/imagenorm.go create mode 100644 internal/imagenorm/imagenorm_test.go create mode 100644 internal/imagenorm/testdata/sample.heic create mode 100644 internal/imagenorm/testdata/sample.webp diff --git a/go.mod b/go.mod index 3e46e30..b002b7f 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,11 @@ go 1.26.2 require ( gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260718232210-a941f5ff4a3f github.com/coreos/go-oidc/v3 v3.20.0 + github.com/gen2brain/heic v0.7.1 github.com/gin-gonic/gin v1.10.1 github.com/samber/slog-gin v1.15.0 golang.org/x/crypto v0.36.0 + golang.org/x/image v0.44.0 golang.org/x/oauth2 v0.36.0 modernc.org/sqlite v1.34.4 ) @@ -33,6 +35,7 @@ require ( github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/ebitengine/purego v0.10.1 // indirect github.com/gabriel-vasile/mimetype v1.4.4 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect @@ -51,14 +54,15 @@ require ( github.com/ncruces/go-strftime v0.1.9 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/tetratelabs/wazero v1.12.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect go.opentelemetry.io/otel v1.29.0 // indirect go.opentelemetry.io/otel/trace v1.29.0 // indirect golang.org/x/arch v0.8.0 // indirect golang.org/x/net v0.38.0 // indirect - golang.org/x/sys v0.31.0 // indirect - golang.org/x/text v0.23.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.40.0 // indirect google.golang.org/protobuf v1.34.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect diff --git a/go.sum b/go.sum index 191e499..05bbeff 100644 --- a/go.sum +++ b/go.sum @@ -26,12 +26,16 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I= github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s= +github.com/gen2brain/heic v0.7.1 h1:Aha1sZdKEeZeWl5o0xkSg7NBRhhkrlokGVCRri+2Qcc= +github.com/gen2brain/heic v0.7.1/go.mod h1:ja42wMJc4fpnKsfdUJxeZa2YqqRnes1wS0xqs5+8o5w= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= @@ -126,6 +130,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= +github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= @@ -144,11 +150,13 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I= +golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -163,27 +171,27 @@ golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= diff --git a/internal/imagenorm/imagenorm.go b/internal/imagenorm/imagenorm.go new file mode 100644 index 0000000..d8170f2 --- /dev/null +++ b/internal/imagenorm/imagenorm.go @@ -0,0 +1,157 @@ +// Package imagenorm normalizes an uploaded image to a JPEG the rest of pansy +// (and majordomo's vision path) can rely on, decoding the formats a phone +// actually produces. +// +// # Why this exists +// +// majordomo's own media pipeline is stdlib-based, so it cannot decode HEIC or +// WebP — and HEIC is the iPhone camera default. The seed-packet feature's very +// first input is "a photo from my phone", so without this the feature fails on +// the exact device that motivates it. Normalizing at the upload boundary means +// everything downstream only ever sees JPEG. +// +// # The CGO constraint +// +// pansy is CGO_ENABLED=0 (a single static binary — the reason modernc/sqlite was +// chosen over the C one), so a libheif *binding* is out. github.com/gen2brain/heic +// runs libheif as WebAssembly via wazero: pure Go, no cgo, and it registers with +// image.Decode like any other format. golang.org/x/image/webp is pure Go too. +// +// # Import-driven registry +// +// Go's image decoders register via blank imports, and the failure mode is +// backwards from intuition: forget "image/png" and PNG uploads fail with +// "unknown format" while the exotic HEIC still works. So the blank imports below +// are load-bearing, and TestNormalize exercises all four formats to keep them so. +package imagenorm + +import ( + "bytes" + "fmt" + "image" + "image/jpeg" + "io" + + "golang.org/x/image/draw" + + // Decoders, registered with image.Decode by side effect. All four matter: + // jpeg/png are the common cases, heic is the iPhone default, webp is common + // on the web. Dropping any one silently breaks that format's uploads. + _ "image/jpeg" + _ "image/png" + + _ "github.com/gen2brain/heic" + _ "golang.org/x/image/webp" +) + +// Defaults chosen for the seed-packet path against ollama-cloud's limits (8 +// images, 20 MiB, 2048px, jpeg+png). We re-encode to JPEG well under all of them. +const ( + // DefaultMaxDim is the longest-edge ceiling. 2048 matches ollama-cloud's + // MaxDim; anything larger is downscaled. A packet photo has plenty of detail + // left at 2048. + DefaultMaxDim = 2048 + // DefaultMaxBytes caps the *input* we will read. A phone photo is 3–8 MB; 25 + // MiB leaves headroom for a large HEIC without inviting a decompression bomb + // as an unbounded read. The re-encoded output is far smaller. + DefaultMaxBytes = 25 << 20 + // maxDecodePixels bounds the decoded bitmap regardless of input byte size — a + // small file can claim enormous dimensions (a decompression bomb). 100 MP is + // ~400 MB at 4 bytes/px decoded, already generous for any real photo. + maxDecodePixels = 100_000_000 + // jpegQuality for the normalized output. 85 is visually clean and keeps the + // file small; the model reads text off it, not fine gradients. + jpegQuality = 85 +) + +// Options tunes Normalize. The zero value uses the Default* constants. +type Options struct { + MaxDim int // longest edge; 0 → DefaultMaxDim + MaxBytes int // input read cap; 0 → DefaultMaxBytes +} + +func (o Options) maxDim() int { + if o.MaxDim > 0 { + return o.MaxDim + } + return DefaultMaxDim +} + +func (o Options) maxBytes() int { + if o.MaxBytes > 0 { + return o.MaxBytes + } + return DefaultMaxBytes +} + +// ErrTooLarge means the input exceeded the byte cap or decoded to an absurd +// pixel count. ErrUnsupported means the bytes weren't a decodable image format. +var ( + ErrTooLarge = fmt.Errorf("imagenorm: image too large") + ErrUnsupported = fmt.Errorf("imagenorm: unsupported or corrupt image") +) + +// Normalize reads an image of any supported format (JPEG, PNG, HEIC, WebP), +// downscales it to fit opts.MaxDim on its longest edge, and returns it re-encoded +// as JPEG. It also reports the decoded format name (e.g. "heic"), which is handy +// for logging what a phone actually sent. +// +// It reads at most opts.MaxBytes and refuses a bitmap over maxDecodePixels, so a +// hostile upload can neither exhaust memory by byte count nor by claimed +// dimensions. A read/format error maps to ErrUnsupported; an over-limit input to +// ErrTooLarge. +func Normalize(r io.Reader, opts Options) (out []byte, format string, err error) { + // Cap the read at MaxBytes+1 so we can tell "exactly at the cap" from "over". + limited := io.LimitReader(r, int64(opts.maxBytes())+1) + raw, err := io.ReadAll(limited) + if err != nil { + return nil, "", fmt.Errorf("imagenorm: read: %w", err) + } + if len(raw) > opts.maxBytes() { + return nil, "", ErrTooLarge + } + + // Check dimensions BEFORE a full decode, so a decompression bomb is refused + // before it allocates its bitmap. + cfg, format, err := image.DecodeConfig(bytes.NewReader(raw)) + if err != nil { + return nil, "", ErrUnsupported + } + if int64(cfg.Width)*int64(cfg.Height) > maxDecodePixels { + return nil, format, ErrTooLarge + } + + img, _, err := image.Decode(bytes.NewReader(raw)) + if err != nil { + return nil, format, ErrUnsupported + } + + img = downscale(img, opts.maxDim()) + + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: jpegQuality}); err != nil { + return nil, format, fmt.Errorf("imagenorm: encode jpeg: %w", err) + } + return buf.Bytes(), format, nil +} + +// downscale returns img shrunk so its longest edge is at most maxDim, preserving +// aspect ratio. An image already within bounds is returned unchanged (no +// re-sampling, no quality loss beyond the JPEG round-trip). Uses Catmull-Rom for +// a sharp result on text, which is what a packet photo is mostly made of. +func downscale(img image.Image, maxDim int) image.Image { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + longest := w + if h > longest { + longest = h + } + if longest <= maxDim || longest == 0 { + return img + } + scale := float64(maxDim) / float64(longest) + nw, nh := max(int(float64(w)*scale), 1), max(int(float64(h)*scale), 1) + dst := image.NewRGBA(image.Rect(0, 0, nw, nh)) + draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil) + return dst +} diff --git a/internal/imagenorm/imagenorm_test.go b/internal/imagenorm/imagenorm_test.go new file mode 100644 index 0000000..8d8a6dd --- /dev/null +++ b/internal/imagenorm/imagenorm_test.go @@ -0,0 +1,141 @@ +package imagenorm + +import ( + "bytes" + "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) { + // 4000x1000 → longest 4000, MaxDim 2048 → scaled to 2048x512. + big := pngBytes(t, 4000, 1000) + 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 != 2048 { + t.Errorf("width = %d, want 2048 (longest edge clamped)", cfg.Width) + } + if cfg.Height != 512 { + t.Errorf("height = %d, want 512 (aspect preserved)", cfg.Height) + } + + // 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, _, _ = image.DecodeConfig(bytes.NewReader(out)) + 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) + } +} + +// TestNormalizeRejectsBombDimensions: a small file claiming a huge canvas is +// refused before the bitmap is allocated. A tiny MaxDim doesn't matter — the +// guard is on the DECODED pixel count, checked from DecodeConfig. +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) + } +} diff --git a/internal/imagenorm/testdata/sample.heic b/internal/imagenorm/testdata/sample.heic new file mode 100644 index 0000000000000000000000000000000000000000..6a3b7cf1205d31e11d80d3813482f8ae023c7c6d GIT binary patch literal 1110 zcmZQzU{FabsVvAy%}izhg51nBL*LARqTI~HG~Y}QCx|#>OKxgOB1j$x6f;tCiXcn| zhJwuG5*QmstE6P+r2#oIDMhIu8Uz@Dn5VQTM*+&_E66O#1+pPB%9#)y5S}QIlgb2@ z%P31W1Tmjv78Df#>BE@?$@xH9I5|J3C@(puf`JJL*&2XCJjppZnGB~H9Dr<*%;JJn zAPoW^fOr8Ab7mIhgB>eSkXexl6kuTH;NSpK`9MpY8JR)GZvY81*nD94^Y_OO1_o~C z1wf~ZJ23Jv{{IhBzzkBs3_>;qDhq*ficXARok$W3wsdjG#J-Uab(-G8`uo~MW~Pb@ zB>Oa6c<-omZW;gMXU~6E*l@m&R8}dNSG%?P_4~Y)8eLOE?auRs1Qj4zz}D)-Fv-3oL5efmEo zT6>vqyvVO_Ch?2Zs$UyD-W+A0!&YYYB5Bc~4KvGJ{iIdR+t@aAT7SCWy~exvJDY%t z?y1-J*G3#(G}S0jVQSS0^&PntE1$Zae?Rpjle1{k8>8kp=>`UAD@Ko4@rBzJXT(=N zP}uW_wM@Kf#{HD=RlQ7!;#RKqLa+APu4p>9O-;ppVM(;x+#JL2CwFBxhB*H@#Avd- zYya#CiwiX7`rYA?Ye+x6{rR%#ulH3fZd%)zbE}T`!p~`WJ2kAGdwr*WVBLF8SYbw6 z2!s8DP`1N0%yW|_&kJj~G`%l#>wIU2xE+63R=+Ts_C)X7xv7%*;j^#3{QHiV^72lx=P4-?xswuaruD?>gz^=3=Wcad%kCyLJt1z@KIe@mqym`db6GCkUR%8-V&j?| z*$0h+7i|n<3h#T(&AmIfdv4dJn~xM3!dos&ORQi#aAozuV(y*dCly;v{~Qi4_f~r| z?WeTpwAVNNeRwk2W`8=_6<+1^{8J?JEz33MuUXExw(9#v#aGE6IJ>79#5gzWRmnz0 zzCE(Zb;i&83bJcTC3$+6ef+ktxyZEddef<%^e4}Lg{42pej`xyV+mu8mAP-W&IHkk zmDvn&KM(IW5OEdjWxCdBc}{-Wk(#sbxZa6K{EYNp(K91tO3%%&3%c3<^mBH`vRu!x lcy~i`bK~m25r5d{7#iALSyra#)@Jp#ifg^f`2=BZeE<@GlCl5* literal 0 HcmV?d00001 diff --git a/internal/imagenorm/testdata/sample.webp b/internal/imagenorm/testdata/sample.webp new file mode 100644 index 0000000000000000000000000000000000000000..e824ec7fb1c7fa85716d27454e9d1715a896d4b4 GIT binary patch literal 432 zcmV;h0Z;x?Nk&Gf0RRA3MM6+kP&il$0000G0000F000jF06|PpNW%aC00AeWsF5V) zdgmBl=ie%sh?xI$agE?6Fyi5hcQVMq5nO{SFzB#&6^?jOHNglB0#A~lYOqxq9M-mN zMlVjrc09K2cl)m?t|6lTNzwg}koUSoNhBLOC6P4q6m1U2)8!T*^a~Wekl};38Zw?X zf`1(;eol4>`-q;ik?)yvM(8qv+xW~#viC(WeIWfd7mOh?RxPF0Ya_f}@)trH( z*>09~iWo5uimEJ7H2N@t-@0p1QYp19&*@YY3IPBB09H^qAie+q0FVLzodGHk01yBJ z05m1A0(3A>2!#xM104t~{pbMx{dA!H0-GO-_d|S~H}=vDuhaiEXyWdty~%GI^s2>U zuDYL>gx^7;JK9_1@A%8{y?^1a`ak6f?DV^4U;nBp*?-Aw=Z5C2m$~G|^phiuU)_&6 zRUm(rPy9Lf|8LT31L&W;gP3M*vDFj*b{FQc{#}aXpGUv5`oI1i9rF^8{#?(&`uP~c az0c1dkvX6N{h9$wF)ZG^*?X$20000=+R1SM literal 0 HcmV?d00001 From cb594f34d840acda5d076661b31396f69e1479f5 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Tue, 21 Jul 2026 23:27:00 -0400 Subject: [PATCH 2/2] Address Gadfly findings on #80 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ --- internal/imagenorm/imagenorm.go | 106 +++++++++++++++++++------- internal/imagenorm/imagenorm_test.go | 81 +++++++++++++++++--- internal/imagenorm/testdata/README.md | 14 ++++ 3 files changed, 163 insertions(+), 38 deletions(-) create mode 100644 internal/imagenorm/testdata/README.md diff --git a/internal/imagenorm/imagenorm.go b/internal/imagenorm/imagenorm.go index d8170f2..4461be4 100644 --- a/internal/imagenorm/imagenorm.go +++ b/internal/imagenorm/imagenorm.go @@ -22,11 +22,13 @@ // Go's image decoders register via blank imports, and the failure mode is // backwards from intuition: forget "image/png" and PNG uploads fail with // "unknown format" while the exotic HEIC still works. So the blank imports below -// are load-bearing, and TestNormalize exercises all four formats to keep them so. +// are load-bearing, and TestNormalizeAllFormats exercises all four formats to +// keep them so. package imagenorm import ( "bytes" + "errors" "fmt" "image" "image/jpeg" @@ -55,10 +57,19 @@ const ( // MiB leaves headroom for a large HEIC without inviting a decompression bomb // as an unbounded read. The re-encoded output is far smaller. DefaultMaxBytes = 25 << 20 - // maxDecodePixels bounds the decoded bitmap regardless of input byte size — a - // small file can claim enormous dimensions (a decompression bomb). 100 MP is - // ~400 MB at 4 bytes/px decoded, already generous for any real photo. - maxDecodePixels = 100_000_000 + // maxDecodePixels bounds the DECODED bitmap regardless of input byte size, so + // a small file claiming enormous dimensions (a decompression bomb) is refused + // before its ~4-bytes/px bitmap is allocated. 50 MP ≈ 200 MB peak — above any + // current phone camera (a 48 MP sensor is 48 MP) while capping the + // amplification a hostile header can force. maxDecodePixels and maxDimension + // are internal safety floors, not knobs — unlike MaxDim/MaxBytes there's no + // reason for a caller to raise them. + maxDecodePixels = 50_000_000 + // maxDimension caps EACH side independently. It exists to make the pixel-count + // check overflow-safe: without it, a header claiming ~2^32 on a side could + // wrap int64(w)*int64(h) negative and slip past maxDecodePixels. No real image + // is 50k px on a side. + maxDimension = 50_000 // jpegQuality for the normalized output. 85 is visually clean and keeps the // file small; the model reads text off it, not fine gradients. jpegQuality = 85 @@ -85,56 +96,98 @@ func (o Options) maxBytes() int { } // ErrTooLarge means the input exceeded the byte cap or decoded to an absurd -// pixel count. ErrUnsupported means the bytes weren't a decodable image format. +// pixel count. ErrUnsupported means the bytes weren't a decodable image format — +// or a decoder panicked on them (see the recover in Normalize). var ( - ErrTooLarge = fmt.Errorf("imagenorm: image too large") - ErrUnsupported = fmt.Errorf("imagenorm: unsupported or corrupt image") + ErrTooLarge = errors.New("imagenorm: image too large") + ErrUnsupported = errors.New("imagenorm: unsupported or corrupt image") ) // Normalize reads an image of any supported format (JPEG, PNG, HEIC, WebP), // downscales it to fit opts.MaxDim on its longest edge, and returns it re-encoded -// as JPEG. It also reports the decoded format name (e.g. "heic"), which is handy -// for logging what a phone actually sent. +// as JPEG, plus the decoded format name (e.g. "heic") — handy for logging what a +// phone actually sent. On any error the returned bytes are nil and format is "". // -// It reads at most opts.MaxBytes and refuses a bitmap over maxDecodePixels, so a -// hostile upload can neither exhaust memory by byte count nor by claimed -// dimensions. A read/format error maps to ErrUnsupported; an over-limit input to -// ErrTooLarge. +// Errors, by cause: +// - input over opts.MaxBytes, or a decoded canvas over maxDecodePixels / +// maxDimension → ErrTooLarge, refused before the bitmap is allocated; +// - bytes that aren't a decodable image, or a decoder that panics on them → +// ErrUnsupported; +// - a genuine read or JPEG-encode I/O failure → a wrapped error (not a +// sentinel), since those are the caller's stream/environment, not the image. +// +// It bounds work against a hostile upload three ways: the byte cap, the +// pre-decode pixel/dimension check, and a recover around the third-party decoders +// (a malformed HEIC/WebP shouldn't take the process down). +// +// Two known gaps, both deferred to the upload handler that wires this in (#81): +// - EXIF ORIENTATION is not applied, so a portrait phone photo tagged +// "rotate 90°" comes out sideways. That's best fixed and tested with a real +// oriented photo end-to-end, which the library has no consumer for yet. +// - There is no context: image.Decode is CPU-bound and not cancellable +// mid-decode, so a caller that needs a hard deadline should run Normalize +// under its own timeout. The size guards keep the work finite regardless. func Normalize(r io.Reader, opts Options) (out []byte, format string, err error) { // Cap the read at MaxBytes+1 so we can tell "exactly at the cap" from "over". - limited := io.LimitReader(r, int64(opts.maxBytes())+1) - raw, err := io.ReadAll(limited) + // maxBytes() is always a sane positive (default 25 MiB); guard the +1 anyway. + byteCap := opts.maxBytes() + limit := int64(byteCap) + 1 + if limit < 1 { + limit = int64(DefaultMaxBytes) + 1 + } + raw, err := io.ReadAll(io.LimitReader(r, limit)) if err != nil { return nil, "", fmt.Errorf("imagenorm: read: %w", err) } - if len(raw) > opts.maxBytes() { + if len(raw) > byteCap { return nil, "", ErrTooLarge } // Check dimensions BEFORE a full decode, so a decompression bomb is refused - // before it allocates its bitmap. - cfg, format, err := image.DecodeConfig(bytes.NewReader(raw)) + // before it allocates its bitmap. The per-side maxDimension check runs first + // so the pixel-count multiply below can't overflow. + cfg, _, err := image.DecodeConfig(bytes.NewReader(raw)) if err != nil { return nil, "", ErrUnsupported } - if int64(cfg.Width)*int64(cfg.Height) > maxDecodePixels { - return nil, format, ErrTooLarge + if cfg.Width <= 0 || cfg.Height <= 0 || + cfg.Width > maxDimension || cfg.Height > maxDimension || + int64(cfg.Width)*int64(cfg.Height) > maxDecodePixels { + return nil, "", ErrTooLarge } - img, _, err := image.Decode(bytes.NewReader(raw)) + img, format, err := decodeSafely(raw) if err != nil { - return nil, format, ErrUnsupported + return nil, "", err } img = downscale(img, opts.maxDim()) var buf bytes.Buffer if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: jpegQuality}); err != nil { - return nil, format, fmt.Errorf("imagenorm: encode jpeg: %w", err) + return nil, "", fmt.Errorf("imagenorm: encode jpeg: %w", err) } return buf.Bytes(), format, nil } +// decodeSafely decodes raw, converting both a decode error and a decoder PANIC +// into ErrUnsupported. The recover matters because the image comes from an +// untrusted upload and the HEIC/WebP decoders are third-party (libheif via WASM, +// x/image/webp): a malformed file that panics one of them must fail this one +// request, not crash the process. +func decodeSafely(raw []byte) (img image.Image, format string, err error) { + defer func() { + if r := recover(); r != nil { + img, format, err = nil, "", ErrUnsupported + } + }() + img, format, err = image.Decode(bytes.NewReader(raw)) + if err != nil { + return nil, "", ErrUnsupported + } + return img, format, nil +} + // downscale returns img shrunk so its longest edge is at most maxDim, preserving // aspect ratio. An image already within bounds is returned unchanged (no // re-sampling, no quality loss beyond the JPEG round-trip). Uses Catmull-Rom for @@ -142,10 +195,7 @@ func Normalize(r io.Reader, opts Options) (out []byte, format string, err error) func downscale(img image.Image, maxDim int) image.Image { b := img.Bounds() w, h := b.Dx(), b.Dy() - longest := w - if h > longest { - longest = h - } + longest := max(w, h) if longest <= maxDim || longest == 0 { return img } diff --git a/internal/imagenorm/imagenorm_test.go b/internal/imagenorm/imagenorm_test.go index 8d8a6dd..4a67309 100644 --- a/internal/imagenorm/imagenorm_test.go +++ b/internal/imagenorm/imagenorm_test.go @@ -2,6 +2,8 @@ package imagenorm import ( "bytes" + "encoding/binary" + "hash/crc32" "image" "image/jpeg" "image/png" @@ -85,8 +87,11 @@ func TestNormalizeAllFormats(t *testing.T) { // 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) { - // 4000x1000 → longest 4000, MaxDim 2048 → scaled to 2048x512. - big := pngBytes(t, 4000, 1000) + // 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) @@ -95,11 +100,11 @@ func TestNormalizeDownscales(t *testing.T) { if err != nil { t.Fatalf("decode out: %v", err) } - if cfg.Width != 2048 { - t.Errorf("width = %d, want 2048 (longest edge clamped)", cfg.Width) + if cfg.Width != DefaultMaxDim { + t.Errorf("width = %d, want %d (longest edge clamped)", cfg.Width, DefaultMaxDim) } - if cfg.Height != 512 { - t.Errorf("height = %d, want 512 (aspect preserved)", cfg.Height) + 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. @@ -108,7 +113,10 @@ func TestNormalizeDownscales(t *testing.T) { if err != nil { t.Fatalf("Normalize small: %v", err) } - cfg, _, _ = image.DecodeConfig(bytes.NewReader(out)) + 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) } @@ -124,9 +132,8 @@ func TestNormalizeRejectsOversizeInput(t *testing.T) { } } -// TestNormalizeRejectsBombDimensions: a small file claiming a huge canvas is -// refused before the bitmap is allocated. A tiny MaxDim doesn't matter — the -// guard is on the DECODED pixel count, checked from DecodeConfig. +// 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 { @@ -139,3 +146,57 @@ func TestNormalizeRejectsGarbage(t *testing.T) { 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) + } + }) + } +} diff --git a/internal/imagenorm/testdata/README.md b/internal/imagenorm/testdata/README.md new file mode 100644 index 0000000..dc036de --- /dev/null +++ b/internal/imagenorm/testdata/README.md @@ -0,0 +1,14 @@ +# imagenorm test fixtures + +Go has no encoder for HEIC or WebP, so these small samples are committed rather +than generated at test time. They are used by `TestNormalizeAllFormats` to prove +every accepted format round-trips to JPEG (and, mainly, to catch a dropped blank +import — see the package doc). + +- `sample.heic` — a 240×160 gradient, created from a Go-generated PNG with macOS + `sips -s format heic`. HEVC still-image profile. +- `sample.webp` — a 16×16 image copied from CPython's stdlib test corpus + (`Lib/test/test_email/data/python.webp`), verified to decode with + `golang.org/x/image/webp` before committing. Used only as a decode fixture. + +Neither contains anything meaningful; they exist purely to be decoded.