Something went wrong. Try again.
A community based topic aggregation platform built on atproto
Something went wrong. Try again.
21 kB · 584 lines
Go
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585package imageproxy
import ( "bytes" "errors" "image" "image/color" "image/jpeg" "image/png" "runtime" "strings" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/require")
// createTestJPEG creates a test JPEG image with the specified dimensions.func createTestJPEG(t *testing.T, width, height int) []byte { t.Helper() img := image.NewRGBA(image.Rect(0, 0, width, height)) // Fill with a solid color for y := 0; y < height; y++ { for x := 0; x < width; x++ { img.Set(x, y, color.RGBA{R: 255, G: 128, B: 64, A: 255}) } } var buf bytes.Buffer err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}) require.NoError(t, err) return buf.Bytes()}
// createTestPNG creates a test PNG image with the specified dimensions.func createTestPNG(t *testing.T, width, height int) []byte { t.Helper() img := image.NewRGBA(image.Rect(0, 0, width, height)) // Fill with a solid color for y := 0; y < height; y++ { for x := 0; x < width; x++ { img.Set(x, y, color.RGBA{R: 64, G: 128, B: 255, A: 255}) } } var buf bytes.Buffer err := png.Encode(&buf, img) require.NoError(t, err) return buf.Bytes()}
// newTestProcessor builds a processor with the production default pixel budget.func newTestProcessor(t *testing.T) Processor { t.Helper() proc, err := NewProcessor(DefaultMaxSourceMegapixels) require.NoError(t, err) return proc}
func TestProcessor_Process_CoverFit(t *testing.T) { proc := newTestProcessor(t)
tests := []struct { name string srcWidth int srcHeight int preset Preset wantWidth int wantHeight int description string }{ { name: "landscape image to square avatar", srcWidth: 800, srcHeight: 600, preset: Preset{Name: "avatar", Width: 1000, Height: 1000, Fit: FitCover, Quality: 85}, wantWidth: 1000, wantHeight: 1000, description: "landscape cropped to square", }, { name: "portrait image to square avatar", srcWidth: 600, srcHeight: 800, preset: Preset{Name: "avatar", Width: 1000, Height: 1000, Fit: FitCover, Quality: 85}, wantWidth: 1000, wantHeight: 1000, description: "portrait cropped to square", }, { name: "square image to smaller square", srcWidth: 500, srcHeight: 500, preset: Preset{Name: "avatar_small", Width: 360, Height: 360, Fit: FitCover, Quality: 80}, wantWidth: 360, wantHeight: 360, description: "square scaled down", }, { name: "landscape to banner dimensions", srcWidth: 1920, srcHeight: 1080, preset: Preset{Name: "banner", Width: 640, Height: 300, Fit: FitCover, Quality: 85}, wantWidth: 640, wantHeight: 300, description: "banner crop", }, { name: "embed thumbnail dimensions", srcWidth: 1600, srcHeight: 900, preset: Preset{Name: "embed_thumbnail", Width: 720, Height: 360, Fit: FitCover, Quality: 80}, wantWidth: 720, wantHeight: 360, description: "embed thumbnail crop", }, }
for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { srcData := createTestJPEG(t, tt.srcWidth, tt.srcHeight)
result, err := proc.Process(srcData, tt.preset) require.NoError(t, err) require.NotNil(t, result)
// Decode the result to verify dimensions img, _, err := image.Decode(bytes.NewReader(result)) require.NoError(t, err)
bounds := img.Bounds() assert.Equal(t, tt.wantWidth, bounds.Dx(), "width mismatch for %s", tt.description) assert.Equal(t, tt.wantHeight, bounds.Dy(), "height mismatch for %s", tt.description) }) }}
func TestProcessor_Process_ContainFit(t *testing.T) { proc := newTestProcessor(t)
tests := []struct { name string srcWidth int srcHeight int preset Preset wantMaxWidth int wantMaxHeight int description string }{ { name: "landscape image scaled to content_preview width", srcWidth: 1600, srcHeight: 900, preset: Preset{Name: "content_preview", Width: 800, Height: 0, Fit: FitContain, Quality: 80}, wantMaxWidth: 800, wantMaxHeight: 450, // 800 * (900/1600) = 450 (aspect ratio preserved) description: "landscape scaled proportionally", }, { name: "portrait image scaled to content_preview width", srcWidth: 900, srcHeight: 1600, preset: Preset{Name: "content_preview", Width: 800, Height: 0, Fit: FitContain, Quality: 80}, wantMaxWidth: 800, wantMaxHeight: 1422, // 800 * (1600/900) ~= 1422 description: "portrait scaled proportionally", }, { name: "wide panorama to content_full", srcWidth: 3200, srcHeight: 800, preset: Preset{Name: "content_full", Width: 1600, Height: 0, Fit: FitContain, Quality: 90}, wantMaxWidth: 1600, wantMaxHeight: 400, // 1600 * (800/3200) = 400 description: "panorama scaled proportionally", }, { name: "image smaller than target width stays same size", srcWidth: 400, srcHeight: 300, preset: Preset{Name: "content_preview", Width: 800, Height: 0, Fit: FitContain, Quality: 80}, wantMaxWidth: 400, // Don't upscale wantMaxHeight: 300, description: "small image not upscaled", }, }
for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { srcData := createTestJPEG(t, tt.srcWidth, tt.srcHeight)
result, err := proc.Process(srcData, tt.preset) require.NoError(t, err) require.NotNil(t, result)
// Decode the result to verify dimensions img, _, err := image.Decode(bytes.NewReader(result)) require.NoError(t, err)
bounds := img.Bounds() // For contain fit, verify width doesn't exceed max and aspect ratio is preserved assert.LessOrEqual(t, bounds.Dx(), tt.wantMaxWidth, "width should not exceed max for %s", tt.description) assert.Equal(t, tt.wantMaxWidth, bounds.Dx(), "width mismatch for %s", tt.description) assert.Equal(t, tt.wantMaxHeight, bounds.Dy(), "height mismatch for %s", tt.description) }) }}
func TestProcessor_Process_InvalidImageData(t *testing.T) { proc := newTestProcessor(t)
tests := []struct { name string data []byte wantErr error }{ { name: "empty data", data: []byte{}, wantErr: ErrUnsupportedFormat, }, { name: "nil data", data: nil, wantErr: ErrUnsupportedFormat, }, { name: "random garbage data", data: []byte("not an image at all"), wantErr: ErrUnsupportedFormat, }, { // A file that ends inside its own header is malformed input, not // a failure of ours; see TestProcessor_Process_MalformedHeaderIsClientError. name: "truncated JPEG header", data: []byte{0xFF, 0xD8, 0xFF, 0xE0}, // Partial JPEG magic wantErr: ErrUnsupportedFormat, }, }
preset, _ := GetPreset("avatar")
for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := proc.Process(tt.data, preset) require.Error(t, err) assert.ErrorIs(t, err, tt.wantErr) assert.Nil(t, result) }) }}
func TestProcessor_Process_SupportsJPEG(t *testing.T) { proc := newTestProcessor(t) srcData := createTestJPEG(t, 500, 500) preset, _ := GetPreset("avatar")
result, err := proc.Process(srcData, preset) require.NoError(t, err) require.NotNil(t, result)
// Verify output is valid JPEG img, format, err := image.Decode(bytes.NewReader(result)) require.NoError(t, err) assert.Equal(t, "jpeg", format) assert.Equal(t, 1000, img.Bounds().Dx()) assert.Equal(t, 1000, img.Bounds().Dy())}
func TestProcessor_Process_SupportsPNG(t *testing.T) { proc := newTestProcessor(t) srcData := createTestPNG(t, 500, 500) preset, _ := GetPreset("avatar")
result, err := proc.Process(srcData, preset) require.NoError(t, err) require.NotNil(t, result)
// Verify PNG input produces PNG output. img, format, err := image.Decode(bytes.NewReader(result)) require.NoError(t, err) assert.Equal(t, "png", format) assert.Equal(t, 1000, img.Bounds().Dx()) assert.Equal(t, 1000, img.Bounds().Dy())}
func TestProcessor_Interface(t *testing.T) { // Compile-time check that ImageProcessor implements Processor var _ Processor = (*ImageProcessor)(nil)}
func TestNewProcessor(t *testing.T) { proc, err := NewProcessor(DefaultMaxSourceMegapixels) require.NoError(t, err) require.NotNil(t, proc)
// Verify it's an *ImageProcessor _, ok := proc.(*ImageProcessor) assert.True(t, ok, "NewProcessor should return *ImageProcessor")}
func TestNewProcessor_RejectsNonPositiveMegapixelBudget(t *testing.T) { for _, budget := range []int{0, -1} { proc, err := NewProcessor(budget) require.Error(t, err, "NewProcessor(%d) must refuse a budget that would admit nothing or everything", budget) assert.True(t, errors.Is(err, ErrInvalidMaxSourceMegapixels), "NewProcessor(%d) error must wrap ErrInvalidMaxSourceMegapixels, got %v", budget, err) assert.Nil(t, proc, "NewProcessor(%d) must not hand back a processor alongside an error", budget) }}
func TestNewProcessor_AcceptsSmallestPositiveBudget(t *testing.T) { proc, err := NewProcessor(1) require.NoError(t, err) require.NotNil(t, proc)}
func TestDefaultMaxSourceMegapixels(t *testing.T) { // The audit asked for "a few tens of megapixels"; 50 is the value chosen // here. The per-request worst case is documented in Process (processor.go); // the concurrency cap in Config is what keeps it survivable. assert.Equal(t, 50, DefaultMaxSourceMegapixels)}
// newProcessorWithBudget builds a processor with an explicit megapixel budget.// Boundary tests use tiny budgets so the "just over" case is a real image of// about a megapixel rather than the 50 MP default nobody can afford to encode.func newProcessorWithBudget(t *testing.T, megapixels int) Processor { t.Helper() proc, err := NewProcessor(megapixels) require.NoError(t, err) return proc}
// measureProcessAlloc runs Process once and reports the bytes the whole// process allocated during the call. TotalAlloc is cumulative and monotonic,// so the delta counts every allocation made while Process ran regardless of// GC; the runtime.GC() beforehand only keeps a collection from being triggered// mid-call and skewing timing, it does not isolate the number. What isolates it// is scheduling: Go runs sequential top-level tests to completion before// releasing parallel ones, so a caller that is not t.Parallel (and not a// subtest of a parallel parent) is the only goroutine allocating.func measureProcessAlloc(proc Processor, data []byte, preset Preset) ([]byte, uint64, error) { var before, after runtime.MemStats runtime.GC() runtime.ReadMemStats(&before) result, err := proc.Process(data, preset) runtime.ReadMemStats(&after) return result, after.TotalAlloc - before.TotalAlloc, err}
// budgetAllocBound is the most a refused image may cost. The smallest bomb in// these tests would allocate 36 MB (3000×3000×4) if decoded, so 8 MiB leaves// room for the decoder's own bookkeeping while still catching a decode.const budgetAllocBound = 8 << 20
// TestProcessor_Process_RefusesOverBudgetWithoutDecoding is the unit-level// statement of the decode budget (docs/MEDIA.md, Decode budget): a header that declares more// pixels than the budget must be refused from the header alone. The sentinel// matters because the handler maps ErrImageTooManyPixels to a 400 and// ErrProcessingFailed to a 500, and because ErrImageTooLarge already means the// fetcher's byte cap, which is a different resource. The error value carries// the declared dimensions and the budget so the handler's log line can name// them and an operator can tell a bomb from a corrupt file.func TestProcessor_Process_RefusesOverBudgetWithoutDecoding(t *testing.T) { proc := newProcessorWithBudget(t, 1) preset, err := GetPreset("avatar") require.NoError(t, err)
bomb := pngHeader(t, 3000, 3000)
result, allocated, err := measureProcessAlloc(proc, bomb, preset)
require.Error(t, err) assert.Nil(t, result) assert.ErrorIs(t, err, ErrImageTooManyPixels) assert.NotErrorIs(t, err, ErrImageTooLarge, "the pixel budget must not borrow the fetcher's byte-cap sentinel; a log reader could not tell which limit tripped") assert.NotErrorIs(t, err, ErrProcessingFailed, "an over-budget image is a refusal, not a processing failure; the handler turns the latter into a 500") assert.Truef(t, strings.Contains(err.Error(), "3000x3000"), "error should name the declared dimensions, got %q", err.Error()) assert.Truef(t, strings.Contains(err.Error(), "1-megapixel budget"), "error should state the budget in megapixels, e.g. \"exceeds the 1-megapixel budget\", got %q", err.Error()) assert.Lessf(t, allocated, uint64(budgetAllocBound), "Process allocated %d bytes (%.1f MiB) for a %d-byte input; the declared 3000x3000 frame was decoded", allocated, float64(allocated)/(1<<20), len(bomb))}
// TestProcessor_Process_BudgetBoundary pins the budget to the configured value// with real encoded images on both sides of it. A hardcoded cap, or an// off-by-one that rejects exactly-at-budget, fails here while the bomb test// alone would still pass.func TestProcessor_Process_BudgetBoundary(t *testing.T) { preset, err := GetPreset("avatar") require.NoError(t, err)
tests := []struct { name string budgetMegapixel int width, height int wantErr error }{ {name: "exactly at a 1 MP budget succeeds", budgetMegapixel: 1, width: 1000, height: 1000}, {name: "one pixel column over a 1 MP budget is refused", budgetMegapixel: 1, width: 1001, height: 1000, wantErr: ErrImageTooManyPixels}, {name: "the same image under a 2 MP budget succeeds", budgetMegapixel: 2, width: 1001, height: 1000}, }
for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { proc := newProcessorWithBudget(t, tt.budgetMegapixel) src := createTestJPEG(t, tt.width, tt.height)
result, err := proc.Process(src, preset)
if tt.wantErr != nil { require.Error(t, err) assert.ErrorIs(t, err, tt.wantErr) assert.NotErrorIs(t, err, ErrImageTooLarge) assert.Nil(t, result) return } require.NoError(t, err) _, format, decodeErr := image.DecodeConfig(bytes.NewReader(result)) require.NoError(t, decodeErr) assert.Equal(t, "jpeg", format) }) }}
// TestProcessor_Process_RefusesOverBudgetEveryFormat runs the budget against// the two decoders the PNG bomb test above does not cover. The decoders fail// differently when handed a header-only file: PNG and WebP allocate the whole// frame before noticing there is no data, JPEG hits EOF first and allocates// nothing. Only a check that reads the header BEFORE decoding produces the// same sentinel and the same near-zero allocation for every format.func TestProcessor_Process_RefusesOverBudgetEveryFormat(t *testing.T) { proc := newTestProcessor(t) preset, err := GetPreset("avatar") require.NoError(t, err)
tests := []struct { name string data []byte }{ {name: "jpeg", data: jpegHeader(t, 12000, 12000)}, {name: "webp", data: webpHeader(t, 12000, 12000)}, }
for _, tt := range tests { // Not t.Parallel: the allocation measurement is process-wide. t.Run(tt.name, func(t *testing.T) { result, allocated, err := measureProcessAlloc(proc, tt.data, preset)
require.Error(t, err) assert.Nil(t, result) assert.ErrorIs(t, err, ErrImageTooManyPixels) assert.NotErrorIs(t, err, ErrImageTooLarge) assert.NotErrorIs(t, err, ErrProcessingFailed) assert.Truef(t, strings.Contains(err.Error(), "12000x12000"), "error should name the declared dimensions, got %q", err.Error()) assert.Truef(t, strings.Contains(err.Error(), "50-megapixel budget"), "error should state the default budget in megapixels, got %q", err.Error()) assert.Lessf(t, allocated, uint64(budgetAllocBound), "Process allocated %d bytes (%.1f MiB) for a %d-byte %s input; the declared frame was decoded", allocated, float64(allocated)/(1<<20), len(tt.data), tt.name) }) }}
// TestProcessor_Process_RejectsZeroDimensionAsUnsupported covers the headers// image/jpeg and x/image/webp accept with a zero side and a nil error. Those// decoders leave it to the caller, so the processor's own dimension check must// refuse them, and as a malformed input (400) rather than an internal failure// (500). PNG is deliberately absent: its decoder rejects a zero dimension// itself and that pre-existing mapping is left alone.func TestProcessor_Process_RejectsZeroDimensionAsUnsupported(t *testing.T) { proc := newTestProcessor(t) preset, err := GetPreset("avatar") require.NoError(t, err)
tests := []struct { name string data []byte }{ {name: "jpeg with zero height", data: jpegHeader(t, 100, 0)}, {name: "webp with zero width", data: webpHeader(t, 0, 100)}, }
for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := proc.Process(tt.data, preset)
require.Error(t, err) assert.Nil(t, result) assert.ErrorIs(t, err, ErrUnsupportedFormat) assert.NotErrorIs(t, err, ErrProcessingFailed, "a zero-dimension header is a malformed input, not a processing failure") }) }}
// TestProcessor_Process_MalformedHeaderIsClientError pins every way a header// can be broken to ErrUnsupportedFormat. The handler serves that as a 400 and// ErrProcessingFailed as a 500, and a 500 is wrong twice over for these: it// tells the client the server is at fault when the bytes are, and it lands in// error-rate alerts where a stranger uploading garbage can page someone. The// decoder's own error strings differ per case, so the processor has to// classify by error TYPE (png.FormatError, io.ErrUnexpectedEOF, ...) rather// than by matching text, which is also why the strings are not asserted here.func TestProcessor_Process_MalformedHeaderIsClientError(t *testing.T) { proc := newTestProcessor(t) preset, err := GetPreset("avatar") require.NoError(t, err)
tests := []struct { name string data []byte }{ // DecodeConfig fails: unexpected EOF inside the JPEG segment table. {name: "jpeg truncated after SOI and APP0 marker", data: []byte{0xFF, 0xD8, 0xFF, 0xE0}}, // DecodeConfig fails: unexpected EOF, 12 of 13 IHDR bytes present. {name: "png truncated inside IHDR", data: pngTruncatedIHDR(t, 12)}, // DecodeConfig fails: png.FormatError "invalid checksum". {name: "png IHDR with wrong CRC", data: pngBadCRC(t, 100, 100)}, // DecodeConfig fails: png.FormatError "non-positive dimension". {name: "png IHDR declaring width 0", data: pngHeader(t, 0, 100)}, // DecodeConfig fails: png.UnsupportedError "dimension overflow". The // decoder refuses before sizing anything, so this stays cheap; the // allocation bound below is what proves that. {name: "png IHDR declaring 0x7fffffff x 0x7fffffff", data: pngHeader(t, 0x7fffffff, 0x7fffffff)}, // DecodeConfig fails: io.EOF inside the VP8 chunk, before the start code. {name: "webp truncated inside the VP8 chunk", data: webpTruncated(t)}, // DecodeConfig succeeds and the budget passes; Decode then fails with // png.FormatError "not enough pixel data". Corruption past the header // is still the client's bytes, not our fault. {name: "png under budget with undecodable IDAT", data: pngHeader(t, 100, 100)}, }
for _, tt := range tests { // Not t.Parallel: the allocation measurement is process-wide. t.Run(tt.name, func(t *testing.T) { result, allocated, err := measureProcessAlloc(proc, tt.data, preset)
require.Error(t, err) assert.Nil(t, result) assert.ErrorIs(t, err, ErrUnsupportedFormat) assert.NotErrorIs(t, err, ErrProcessingFailed, "a malformed header is the client's bytes, not a failure of ours") assert.Lessf(t, allocated, uint64(budgetAllocBound), "Process allocated %d bytes (%.1f MiB) for a %d-byte malformed input", allocated, float64(allocated)/(1<<20), len(tt.data)) }) }}
// TestProcessor_Process_UnknownFitModeIsProcessingFailure keeps// ErrProcessingFailed meaningful now that malformed input no longer produces// it: a preset the code cannot handle is OUR bug, and a 500 is the honest// answer for that.func TestProcessor_Process_UnknownFitModeIsProcessingFailure(t *testing.T) { proc := newTestProcessor(t) bogus := Preset{Name: "bogus", Width: 100, Height: 100, Fit: FitMode("bogus"), Quality: 85}
result, err := proc.Process(createTestJPEG(t, 200, 200), bogus)
require.Error(t, err) assert.Nil(t, result) assert.ErrorIs(t, err, ErrProcessingFailed) assert.NotErrorIs(t, err, ErrUnsupportedFormat)}
// TestNewProcessor_BudgetCeiling: an operator can raise the budget, but not// past the point where the concurrency cap stops meaning anything. At the// ceiling the 12 B/px worst case is 12 GB per request.func TestNewProcessor_BudgetCeiling(t *testing.T) { assert.Equal(t, 1000, MaxSourceMegapixelsCeiling)
proc, err := NewProcessor(MaxSourceMegapixelsCeiling) require.NoError(t, err, "the ceiling itself is a legal budget") require.NotNil(t, proc)
proc, err = NewProcessor(MaxSourceMegapixelsCeiling + 1) require.Error(t, err, "one over the ceiling must be refused") assert.ErrorIs(t, err, ErrInvalidMaxSourceMegapixels) assert.Nil(t, proc)}