chore: update golangci-lint to v2.12.2 with canonical config
All checks were successful
check / check (push) Successful in 2m3s

Replace .golangci.yml with the canonical v2-schema config
(default: all minus six disabled linters, lll 88, tests included)
and bump every golangci-lint pin to v2.12.2:

- Dockerfile: golangci/golangci-lint:v2.12.2-alpine (hash-pinned)
- script/bootstrap: GOLANGCI_LINT_VERSION 2.12.2 with new
  linux-amd64/arm64 release-archive sha256 pins

Fix all 747 findings the stricter config surfaces, with no behavior
changes: t.Parallel() throughout the test suite, static sentinel
errors and errors.Is comparisons, checked error returns, context
propagation (contextcheck/noctx), 88-column wrapping, extracted
constants and helpers for goconst/dupl/funlen/cyclop, exhaustive
switch cases replicating existing defaults, and white-box test files
renamed to *_internal_test.go for testpackage. Three
nolint:tagliatelle directives preserve the existing snake_case JSON
wire and on-disk metadata formats.
This commit is contained in:
2026-08-07 17:10:27 +00:00
parent 5d0b5f864e
commit 23506df609
55 changed files with 2584 additions and 1863 deletions

View File

@@ -13,7 +13,9 @@ import (
)
// vipsOnce ensures vips is initialized exactly once.
var vipsOnce sync.Once //nolint:gochecknoglobals // package-level sync.Once for one-time vips init
//
//nolint:gochecknoglobals // package-level sync.Once for one-time vips init
var vipsOnce sync.Once
// initVips initializes libvips with quiet logging.
func initVips() {
@@ -96,10 +98,12 @@ const DefaultMaxInputBytes = 50 << 20
// ErrInputTooLarge is returned when input image dimensions exceed MaxInputDimension.
var ErrInputTooLarge = errors.New("input image dimensions exceed maximum")
// ErrInputDataTooLarge is returned when the raw input data exceeds the configured byte limit.
// ErrInputDataTooLarge is returned when the raw input data exceeds the
// configured byte limit.
var ErrInputDataTooLarge = errors.New("input data exceeds maximum allowed size")
// ErrUnsupportedOutputFormat is returned when the requested output format is not supported.
// ErrUnsupportedOutputFormat is returned when the requested output format is
// not supported.
var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
// ImageProcessor implements image transformation using libvips via govips.
@@ -170,25 +174,12 @@ func (p *ImageProcessor) Process(
}
// Determine target dimensions
targetWidth := req.Size.Width
targetHeight := req.Size.Height
// Handle dimension calculation
if targetWidth == 0 && targetHeight == 0 {
// Both are 0: keep original size
targetWidth = origWidth
targetHeight = origHeight
} else if targetWidth == 0 {
// Only height specified: calculate width proportionally
targetWidth = origWidth * targetHeight / origHeight
} else if targetHeight == 0 {
// Only width specified: calculate height proportionally
targetHeight = origHeight * targetWidth / origWidth
}
targetWidth, targetHeight := targetDimensions(req.Size, origWidth, origHeight)
// Resize if needed
if targetWidth != origWidth || targetHeight != origHeight {
if err := p.resize(img, targetWidth, targetHeight, req.FitMode); err != nil {
err := p.resize(img, targetWidth, targetHeight, req.FitMode)
if err != nil {
return nil, fmt.Errorf("failed to resize: %w", err)
}
}
@@ -217,14 +208,42 @@ func (p *ImageProcessor) Process(
}, nil
}
// targetDimensions calculates the output dimensions for a requested size,
// scaling proportionally when only one dimension is given and keeping the
// original dimensions when both are zero.
func targetDimensions(size Size, origWidth, origHeight int) (int, int) {
switch {
case size.Width == 0 && size.Height == 0:
// Both are 0: keep original size
return origWidth, origHeight
case size.Width == 0:
// Only height specified: calculate width proportionally
return origWidth * size.Height / origHeight, size.Height
case size.Height == 0:
// Only width specified: calculate height proportionally
return size.Width, origHeight * size.Width / origWidth
default:
return size.Width, size.Height
}
}
// MIME types for the supported image formats.
const (
mimeJPEG = "image/jpeg"
mimePNG = "image/png"
mimeGIF = "image/gif"
mimeWebP = "image/webp"
mimeAVIF = "image/avif"
)
// SupportedInputFormats returns MIME types this processor can read.
func (p *ImageProcessor) SupportedInputFormats() []string {
return []string{
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/avif",
mimeJPEG,
mimePNG,
mimeGIF,
mimeWebP,
mimeAVIF,
}
}
@@ -243,15 +262,17 @@ func (p *ImageProcessor) SupportedOutputFormats() []Format {
func FormatToMIME(format Format) string {
switch format {
case FormatJPEG:
return "image/jpeg"
return mimeJPEG
case FormatPNG:
return "image/png"
return mimePNG
case FormatWebP:
return "image/webp"
return mimeWebP
case FormatGIF:
return "image/gif"
return mimeGIF
case FormatAVIF:
return "image/avif"
return mimeAVIF
case FormatOriginal:
return "application/octet-stream"
default:
return "application/octet-stream"
}
@@ -270,14 +291,20 @@ func (p *ImageProcessor) detectFormat(img *vips.ImageRef) string {
case vips.ImageTypeWEBP:
return "webp"
case vips.ImageTypeAVIF, vips.ImageTypeHEIF:
return "avif"
return string(FormatAVIF)
case vips.ImageTypeUnknown, vips.ImageTypeMagick, vips.ImageTypePDF,
vips.ImageTypeSVG, vips.ImageTypeTIFF, vips.ImageTypeBMP,
vips.ImageTypeJP2K, vips.ImageTypeJXL:
return "unknown"
default:
return "unknown"
}
}
// resize resizes the image according to the fit mode.
func (p *ImageProcessor) resize(img *vips.ImageRef, width, height int, fit FitMode) error {
func (p *ImageProcessor) resize(
img *vips.ImageRef, width, height int, fit FitMode,
) error {
switch fit {
case FitCover, "":
// Resize and crop to fill exact dimensions (default)
@@ -303,6 +330,7 @@ func (p *ImageProcessor) resize(img *vips.ImageRef, width, height int, fit FitMo
if img.Width() <= width && img.Height() <= height {
return nil // Already fits
}
imgW, imgH := img.Width(), img.Height()
scaleW := float64(width) / float64(imgW)
scaleH := float64(height) / float64(imgH)
@@ -331,7 +359,9 @@ func (p *ImageProcessor) resize(img *vips.ImageRef, width, height int, fit FitMo
const defaultQuality = 85
// encode encodes an image to the specified format.
func (p *ImageProcessor) encode(img *vips.ImageRef, format Format, quality int) ([]byte, error) {
func (p *ImageProcessor) encode(
img *vips.ImageRef, format Format, quality int,
) ([]byte, error) {
if quality <= 0 {
quality = defaultQuality
}
@@ -367,8 +397,11 @@ func (p *ImageProcessor) encode(img *vips.ImageRef, format Format, quality int)
Quality: quality,
}
case FormatOriginal:
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
default:
return nil, fmt.Errorf("unsupported output format: %s", format)
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
}
output, _, err := img.Export(&params)
@@ -390,7 +423,7 @@ func (p *ImageProcessor) formatFromString(format string) Format {
return FormatGIF
case "webp":
return FormatWebP
case "avif":
case string(FormatAVIF):
return FormatAVIF
default:
return FormatJPEG

View File

@@ -3,6 +3,7 @@ package imageprocessor
import (
"bytes"
"context"
"errors"
"image"
"image/color"
"image/jpeg"
@@ -16,7 +17,9 @@ import (
func TestMain(m *testing.M) {
initVips()
code := m.Run()
vips.Shutdown()
os.Exit(code)
}
@@ -27,11 +30,11 @@ func createTestJPEG(t *testing.T, width, height int) []byte {
img := image.NewRGBA(image.Rect(0, 0, width, height))
// Fill with a gradient
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
for y := range height {
for x := range width {
img.Set(x, y, color.RGBA{
R: uint8(x * 255 / width),
G: uint8(y * 255 / height),
R: uint8((x * 255 / width) & 0xff),
G: uint8((y * 255 / height) & 0xff),
B: 128,
A: 255,
})
@@ -39,7 +42,9 @@ func createTestJPEG(t *testing.T, width, height int) []byte {
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil {
err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90})
if err != nil {
t.Fatalf("failed to encode test JPEG: %v", err)
}
@@ -51,11 +56,11 @@ func createTestPNG(t *testing.T, width, height int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
for y := range height {
for x := range width {
img.Set(x, y, color.RGBA{
R: uint8(x * 255 / width),
G: uint8(y * 255 / height),
R: uint8((x * 255 / width) & 0xff),
G: uint8((y * 255 / height) & 0xff),
B: 128,
A: 255,
})
@@ -63,37 +68,54 @@ func createTestPNG(t *testing.T, width, height int) []byte {
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
err := png.Encode(&buf, img)
if err != nil {
t.Fatalf("failed to encode test PNG: %v", err)
}
return buf.Bytes()
}
// isAVIF reports whether data starts with an AVIF ftyp box.
func isAVIF(data []byte) bool {
if len(data) < 12 || string(data[4:8]) != "ftyp" {
return false
}
brand := string(data[8:12])
return brand == string(FormatAVIF) || brand == "avis"
}
// detectMIME is a minimal magic-byte detector for test assertions.
func detectMIME(data []byte) string {
if len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
return "image/jpeg"
return mimeJPEG
}
if len(data) >= 8 && string(data[:8]) == "\x89PNG\r\n\x1a\n" {
return "image/png"
return mimePNG
}
if len(data) >= 4 && string(data[:4]) == "GIF8" {
return "image/gif"
return mimeGIF
}
if len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP" {
return "image/webp"
return mimeWebP
}
if len(data) >= 12 && string(data[4:8]) == "ftyp" {
brand := string(data[8:12])
if brand == "avif" || brand == "avis" {
return "image/avif"
}
if isAVIF(data) {
return mimeAVIF
}
return ""
}
func TestImageProcessor_ResizeJPEG(t *testing.T) {
t.Parallel()
proc := New(Params{})
ctx := context.Background()
@@ -110,7 +132,8 @@ func TestImageProcessor_ResizeJPEG(t *testing.T) {
if err != nil {
t.Fatalf("Process() error = %v", err)
}
defer result.Content.Close()
defer func() { _ = result.Content.Close() }()
if result.Width != 400 {
t.Errorf("Process() width = %d, want 400", result.Width)
@@ -131,12 +154,14 @@ func TestImageProcessor_ResizeJPEG(t *testing.T) {
}
mime := detectMIME(data)
if mime != "image/jpeg" {
if mime != mimeJPEG {
t.Errorf("Output format = %v, want image/jpeg", mime)
}
}
func TestImageProcessor_ConvertToPNG(t *testing.T) {
t.Parallel()
proc := New(Params{})
ctx := context.Background()
@@ -152,7 +177,8 @@ func TestImageProcessor_ConvertToPNG(t *testing.T) {
if err != nil {
t.Fatalf("Process() error = %v", err)
}
defer result.Content.Close()
defer func() { _ = result.Content.Close() }()
data, err := io.ReadAll(result.Content)
if err != nil {
@@ -160,19 +186,25 @@ func TestImageProcessor_ConvertToPNG(t *testing.T) {
}
mime := detectMIME(data)
if mime != "image/png" {
if mime != mimePNG {
t.Errorf("Output format = %v, want image/png", mime)
}
}
func TestImageProcessor_OriginalSize(t *testing.T) {
// processAndCheckSize processes a test JPEG of the given input dimensions
// with the requested size and asserts the resulting dimensions.
func processAndCheckSize(
t *testing.T, inputW, inputH int, size Size, wantW, wantH int,
) {
t.Helper()
proc := New(Params{})
ctx := context.Background()
input := createTestJPEG(t, 640, 480)
input := createTestJPEG(t, inputW, inputH)
req := &Request{
Size: Size{Width: 0, Height: 0}, // Original size
Size: size,
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
@@ -182,18 +214,28 @@ func TestImageProcessor_OriginalSize(t *testing.T) {
if err != nil {
t.Fatalf("Process() error = %v", err)
}
defer result.Content.Close()
if result.Width != 640 {
t.Errorf("Process() width = %d, want 640", result.Width)
defer func() { _ = result.Content.Close() }()
if result.Width != wantW {
t.Errorf("Process() width = %d, want %d", result.Width, wantW)
}
if result.Height != 480 {
t.Errorf("Process() height = %d, want 480", result.Height)
if result.Height != wantH {
t.Errorf("Process() height = %d, want %d", result.Height, wantH)
}
}
func TestImageProcessor_OriginalSize(t *testing.T) {
t.Parallel()
// Width and height 0: keep original size
processAndCheckSize(t, 640, 480, Size{Width: 0, Height: 0}, 640, 480)
}
func TestImageProcessor_FitContain(t *testing.T) {
t.Parallel()
proc := New(Params{})
ctx := context.Background()
@@ -212,7 +254,8 @@ func TestImageProcessor_FitContain(t *testing.T) {
if err != nil {
t.Fatalf("Process() error = %v", err)
}
defer result.Content.Close()
defer func() { _ = result.Content.Close() }()
// With contain, the image should fit within the box
if result.Width > 400 || result.Height > 400 {
@@ -221,66 +264,24 @@ func TestImageProcessor_FitContain(t *testing.T) {
}
func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) {
proc := New(Params{})
ctx := context.Background()
t.Parallel()
// 800x600 image, request width=400 height=0
// Should scale proportionally to 400x300
input := createTestJPEG(t, 800, 600)
req := &Request{
Size: Size{Width: 400, Height: 0},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
result, err := proc.Process(ctx, bytes.NewReader(input), req)
if err != nil {
t.Fatalf("Process() error = %v", err)
}
defer result.Content.Close()
if result.Width != 400 {
t.Errorf("Process() width = %d, want 400", result.Width)
}
if result.Height != 300 {
t.Errorf("Process() height = %d, want 300", result.Height)
}
processAndCheckSize(t, 800, 600, Size{Width: 400, Height: 0}, 400, 300)
}
func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
proc := New(Params{})
ctx := context.Background()
t.Parallel()
// 800x600 image, request width=0 height=300
// Should scale proportionally to 400x300
input := createTestJPEG(t, 800, 600)
req := &Request{
Size: Size{Width: 0, Height: 300},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
result, err := proc.Process(ctx, bytes.NewReader(input), req)
if err != nil {
t.Fatalf("Process() error = %v", err)
}
defer result.Content.Close()
if result.Width != 400 {
t.Errorf("Process() width = %d, want 400", result.Width)
}
if result.Height != 300 {
t.Errorf("Process() height = %d, want 300", result.Height)
}
processAndCheckSize(t, 800, 600, Size{Width: 0, Height: 300}, 400, 300)
}
func TestImageProcessor_ProcessPNG(t *testing.T) {
t.Parallel()
proc := New(Params{})
ctx := context.Background()
@@ -296,7 +297,8 @@ func TestImageProcessor_ProcessPNG(t *testing.T) {
if err != nil {
t.Fatalf("Process() error = %v", err)
}
defer result.Content.Close()
defer func() { _ = result.Content.Close() }()
if result.Width != 200 {
t.Errorf("Process() width = %d, want 200", result.Width)
@@ -308,6 +310,8 @@ func TestImageProcessor_ProcessPNG(t *testing.T) {
}
func TestImageProcessor_SupportedFormats(t *testing.T) {
t.Parallel()
proc := New(Params{})
inputFormats := proc.SupportedInputFormats()
@@ -322,55 +326,49 @@ func TestImageProcessor_SupportedFormats(t *testing.T) {
}
func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
proc := New(Params{})
ctx := context.Background()
t.Parallel()
// Create an image that exceeds MaxInputDimension (e.g., 10000x100)
// This should be rejected before processing to prevent DoS
input := createTestJPEG(t, 10000, 100)
req := &Request{
Size: Size{Width: 100, Height: 100},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
// Images exceeding MaxInputDimension in either dimension must be
// rejected before processing to prevent DoS.
tests := []struct {
name string
width int
height int
}{
{name: "oversized width", width: 10000, height: 100},
{name: "oversized height", width: 100, height: 10000},
}
_, err := proc.Process(ctx, bytes.NewReader(input), req)
if err == nil {
t.Error("Process() should reject oversized input images")
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if err != ErrInputTooLarge {
t.Errorf("Process() error = %v, want ErrInputTooLarge", err)
}
}
proc := New(Params{})
ctx := context.Background()
input := createTestJPEG(t, tt.width, tt.height)
func TestImageProcessor_RejectsOversizedInputHeight(t *testing.T) {
proc := New(Params{})
ctx := context.Background()
req := &Request{
Size: Size{Width: 100, Height: 100},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
// Create an image with oversized height
input := createTestJPEG(t, 100, 10000)
_, err := proc.Process(ctx, bytes.NewReader(input), req)
if err == nil {
t.Error("Process() should reject oversized input images")
}
req := &Request{
Size: Size{Width: 100, Height: 100},
Format: FormatJPEG,
Quality: 85,
FitMode: FitCover,
}
_, err := proc.Process(ctx, bytes.NewReader(input), req)
if err == nil {
t.Error("Process() should reject oversized input images")
}
if err != ErrInputTooLarge {
t.Errorf("Process() error = %v, want ErrInputTooLarge", err)
if !errors.Is(err, ErrInputTooLarge) {
t.Errorf("Process() error = %v, want ErrInputTooLarge", err)
}
})
}
}
func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
t.Parallel()
proc := New(Params{})
ctx := context.Background()
@@ -386,12 +384,20 @@ func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
result, err := proc.Process(ctx, bytes.NewReader(input), req)
if err != nil {
t.Fatalf("Process() should accept images at MaxInputDimension, got error: %v", err)
t.Fatalf(
"Process() should accept images at MaxInputDimension, got error: %v",
err,
)
}
defer result.Content.Close()
defer func() { _ = result.Content.Close() }()
}
func TestImageProcessor_EncodeWebP(t *testing.T) {
// encodeAndCheck processes a 200x150 test JPEG into a 100x75 output of the
// given format and asserts the output MIME type and dimensions.
func encodeAndCheck(t *testing.T, format Format, quality int, wantMIME string) {
t.Helper()
proc := New(Params{})
ctx := context.Background()
@@ -399,8 +405,8 @@ func TestImageProcessor_EncodeWebP(t *testing.T) {
req := &Request{
Size: Size{Width: 100, Height: 75},
Format: FormatWebP,
Quality: 80,
Format: format,
Quality: quality,
FitMode: FitCover,
}
@@ -408,29 +414,39 @@ func TestImageProcessor_EncodeWebP(t *testing.T) {
if err != nil {
t.Fatalf("Process() error = %v, want nil", err)
}
defer result.Content.Close()
// Verify output is valid WebP
defer func() { _ = result.Content.Close() }()
// Verify output format
data, err := io.ReadAll(result.Content)
if err != nil {
t.Fatalf("failed to read result: %v", err)
}
mime := detectMIME(data)
if mime != "image/webp" {
t.Errorf("Output format = %v, want image/webp", mime)
if mime != wantMIME {
t.Errorf("Output format = %v, want %v", mime, wantMIME)
}
// Verify dimensions
if result.Width != 100 {
t.Errorf("Width = %d, want 100", result.Width)
}
if result.Height != 75 {
t.Errorf("Height = %d, want 75", result.Height)
}
}
func TestImageProcessor_EncodeWebP(t *testing.T) {
t.Parallel()
encodeAndCheck(t, FormatWebP, 80, mimeWebP)
}
func TestImageProcessor_DecodeAVIF(t *testing.T) {
t.Parallel()
proc := New(Params{})
ctx := context.Background()
@@ -452,7 +468,8 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) {
if err != nil {
t.Fatalf("Process() error = %v, want nil (AVIF decoding should work)", err)
}
defer result.Content.Close()
defer func() { _ = result.Content.Close() }()
// Verify output is valid JPEG
data, err := io.ReadAll(result.Content)
@@ -461,14 +478,17 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) {
}
mime := detectMIME(data)
if mime != "image/jpeg" {
if mime != mimeJPEG {
t.Errorf("Output format = %v, want image/jpeg", mime)
}
}
func TestImageProcessor_RejectsOversizedInputData(t *testing.T) {
t.Parallel()
// Create a processor with a very small byte limit
const limit = 1024
proc := New(Params{MaxInputBytes: limit})
ctx := context.Background()
@@ -490,12 +510,14 @@ func TestImageProcessor_RejectsOversizedInputData(t *testing.T) {
t.Fatal("Process() should reject input exceeding maxInputBytes")
}
if err != ErrInputDataTooLarge {
if !errors.Is(err, ErrInputDataTooLarge) {
t.Errorf("Process() error = %v, want ErrInputDataTooLarge", err)
}
}
func TestImageProcessor_AcceptsInputWithinLimit(t *testing.T) {
t.Parallel()
// Create a small image and set limit well above its size
input := createTestJPEG(t, 10, 10)
limit := int64(len(input)) * 10 // 10× headroom
@@ -514,10 +536,13 @@ func TestImageProcessor_AcceptsInputWithinLimit(t *testing.T) {
if err != nil {
t.Fatalf("Process() error = %v, want nil", err)
}
defer result.Content.Close()
defer func() { _ = result.Content.Close() }()
}
func TestImageProcessor_DefaultMaxInputBytes(t *testing.T) {
t.Parallel()
// Passing 0 should use the default
proc := New(Params{})
if proc.maxInputBytes != DefaultMaxInputBytes {
@@ -532,40 +557,7 @@ func TestImageProcessor_DefaultMaxInputBytes(t *testing.T) {
}
func TestImageProcessor_EncodeAVIF(t *testing.T) {
proc := New(Params{})
ctx := context.Background()
t.Parallel()
input := createTestJPEG(t, 200, 150)
req := &Request{
Size: Size{Width: 100, Height: 75},
Format: FormatAVIF,
Quality: 85,
FitMode: FitCover,
}
result, err := proc.Process(ctx, bytes.NewReader(input), req)
if err != nil {
t.Fatalf("Process() error = %v, want nil (AVIF encoding should work)", err)
}
defer result.Content.Close()
// Verify output is valid AVIF
data, err := io.ReadAll(result.Content)
if err != nil {
t.Fatalf("failed to read result: %v", err)
}
mime := detectMIME(data)
if mime != "image/avif" {
t.Errorf("Output format = %v, want image/avif", mime)
}
// Verify dimensions
if result.Width != 100 {
t.Errorf("Width = %d, want 100", result.Width)
}
if result.Height != 75 {
t.Errorf("Height = %d, want 75", result.Height)
}
encodeAndCheck(t, FormatAVIF, 85, mimeAVIF)
}