Files

407 lines
11 KiB
Go

// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package imageutils
import (
"bytes"
"context"
"encoding/binary"
"errors"
"image"
"image/jpeg"
"image/png"
"io"
"net/http"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/image/tiff"
)
func testPNG(t *testing.T, width, height int) []byte {
t.Helper()
var buf bytes.Buffer
require.NoError(t, png.Encode(&buf, image.NewNRGBA(image.Rect(0, 0, width, height))))
return buf.Bytes()
}
// x/image/tiff writes the IFD, and with it the dimensions, after the pixel data.
func testTIFF(t *testing.T, width, height int) []byte {
t.Helper()
var buf bytes.Buffer
require.NoError(t, tiff.Encode(&buf, image.NewNRGBA(image.Rect(0, 0, width, height)), nil))
return buf.Bytes()
}
// Google Camera portraits carry a depth map in extended XMP before the image data.
func testJPEGWithMetadata(t *testing.T, width, height, segments int) []byte {
t.Helper()
var buf bytes.Buffer
require.NoError(t, jpeg.Encode(&buf, image.NewNRGBA(image.Rect(0, 0, width, height)), nil))
encoded := buf.Bytes()
const maxSegmentPayload = 0xffff - 2
segment := binary.BigEndian.AppendUint16([]byte{0xff, 0xe1}, 0xffff)
segment = append(segment, make([]byte, maxSegmentPayload)...)
out := append([]byte{}, encoded[:2]...)
for range segments {
out = append(out, segment...)
}
return append(out, encoded[2:]...)
}
type trackedSource struct {
content []byte
opened atomic.Int32
closed atomic.Int32
}
func (s *trackedSource) open() (io.ReadCloser, error) {
s.opened.Add(1)
return &trackedReader{
Reader: bytes.NewReader(s.content),
source: s,
}, nil
}
type trackedReader struct {
io.Reader
source *trackedSource
}
func (r *trackedReader) Close() error {
r.source.closed.Add(1)
return nil
}
func noUse(image.Image, string) error { return nil }
func assertBudgetFree(t *testing.T, d *decoder) {
t.Helper()
require.True(t, d.slots.TryAcquire(d.budget), "the whole budget must be free")
d.slots.Release(d.budget)
}
func TestDecode(t *testing.T) {
t.Run("hands the decoded image and its format to use", func(t *testing.T) {
var width int
var format string
err := Decode(t.Context(), BytesOpener(testPNG(t, 40, 30)), func(img image.Image, f string) error {
width = img.Bounds().Dx()
format = f
return nil
})
require.NoError(t, err)
assert.Equal(t, 40, width)
assert.Equal(t, "png", format)
})
t.Run("returns the error of use", func(t *testing.T) {
useErr := errors.New("encoding failed")
err := Decode(t.Context(), BytesOpener(testPNG(t, 4, 4)), func(image.Image, string) error { return useErr })
require.ErrorIs(t, err, useErr)
assert.NotErrorIs(t, err, ErrUndecodable)
})
}
func TestDecode_ClosesEverySourceHandle(t *testing.T) {
tests := map[string]struct {
content []byte
opened int32
undecodable bool
tooLarge bool
}{
"image": {
content: testPNG(t, 400, 300),
opened: 2,
},
"image with more than 1 MiB of metadata before the image data": {
content: testJPEGWithMetadata(t, 400, 300, 24),
opened: 2,
},
"undecodable image": {
content: pngWithClaimedDimensions(t, 400, 300),
opened: 2,
undecodable: true,
},
"image over the pixel limit is never opened for decoding": {
content: pngWithClaimedDimensions(t, 10000, 10000),
opened: 1,
tooLarge: true,
},
"tiff is not supported": {
content: testTIFF(t, 16, 16),
opened: 1,
undecodable: true,
},
"image whose header is past the read limit is never opened for decoding": {
content: testTIFF(t, 1024, maxHeaderBytes/(4*1024)+1),
opened: 1,
undecodable: true,
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
source := &trackedSource{content: tc.content}
err := newDecoder(2*MaxPixels, time.Minute).decode(t.Context(), source.open, noUse)
assert.Equal(t, tc.undecodable, errors.Is(err, ErrUndecodable), "err: %v", err)
assert.Equal(t, tc.tooLarge, IsErrImageTooLarge(err), "err: %v", err)
assert.Equal(t, tc.opened, source.opened.Load())
assert.Equal(t, source.opened.Load(), source.closed.Load())
})
}
t.Run("source that cannot be opened is an error as is", func(t *testing.T) {
openErr := errors.New("storage unavailable")
err := newDecoder(2*MaxPixels, time.Minute).decode(t.Context(), func() (io.ReadCloser, error) { return nil, openErr }, noUse)
require.ErrorIs(t, err, openErr)
assert.NotErrorIs(t, err, ErrUndecodable)
})
}
type pixelTracker struct {
gate chan struct{}
mu sync.Mutex
inFlight int64
peak int64
}
func newPixelTracker() *pixelTracker {
return &pixelTracker{gate: make(chan struct{})}
}
func (p *pixelTracker) use(img image.Image, _ string) error {
pixels := int64(img.Bounds().Dx()) * int64(img.Bounds().Dy())
p.mu.Lock()
p.inFlight += pixels
p.peak = max(p.peak, p.inFlight)
p.mu.Unlock()
<-p.gate
p.mu.Lock()
p.inFlight -= pixels
p.mu.Unlock()
return nil
}
func (p *pixelTracker) pixelsInFlight() int64 {
p.mu.Lock()
defer p.mu.Unlock()
return p.inFlight
}
func (p *pixelTracker) peakPixels() int64 {
p.mu.Lock()
defer p.mu.Unlock()
return p.peak
}
func TestDecode_BoundsPixelsInUse(t *testing.T) {
const (
small = 100 * 100
budget = 2 * small
)
t.Run("distinct images in parallel stay within the budget", func(t *testing.T) {
d := newDecoder(budget, time.Minute)
tracker := newPixelTracker()
content := testPNG(t, 100, 100)
const callers = 6
results := make(chan error, callers)
for range callers {
go func() {
results <- d.decode(t.Context(), BytesOpener(content), tracker.use)
}()
}
require.Eventually(t, func() bool { return tracker.pixelsInFlight() >= budget }, 5*time.Second, time.Millisecond)
assert.Never(t, func() bool { return tracker.peakPixels() > budget }, 200*time.Millisecond, 5*time.Millisecond)
close(tracker.gate)
for range callers {
require.NoError(t, <-results)
}
assert.Equal(t, int64(budget), tracker.peakPixels())
assertBudgetFree(t, d)
})
t.Run("a larger image takes more of the budget", func(t *testing.T) {
d := newDecoder(budget, time.Minute)
tracker := newPixelTracker()
results := make(chan error, 2)
large := BytesOpener(testPNG(t, 200, 100))
go func() {
results <- d.decode(t.Context(), large, tracker.use)
}()
require.Eventually(t, func() bool { return tracker.pixelsInFlight() == budget }, 5*time.Second, time.Millisecond)
smallImage := BytesOpener(testPNG(t, 100, 100))
go func() {
results <- d.decode(t.Context(), smallImage, tracker.use)
}()
assert.Never(t, func() bool { return tracker.peakPixels() > budget }, 200*time.Millisecond, 5*time.Millisecond)
close(tracker.gate)
require.NoError(t, <-results)
require.NoError(t, <-results)
assert.Equal(t, int64(budget), tracker.peakPixels())
})
t.Run("an image larger than the whole budget is rejected without queueing", func(t *testing.T) {
d := newDecoder(small-1, time.Minute)
source := &trackedSource{content: testPNG(t, 100, 100)}
result := make(chan error, 1)
go func() {
result <- d.decode(t.Context(), source.open, func(image.Image, string) error {
t.Error("use must not be called")
return nil
})
}()
select {
case err := <-result:
assert.True(t, IsErrImageTooLarge(err), "err: %v", err)
case <-time.After(5 * time.Second):
require.FailNow(t, "the image is queued for a budget it can never get")
}
assert.Equal(t, int32(1), source.opened.Load(), "the source must not be opened for decoding")
assertBudgetFree(t, d)
})
}
func TestDecode_ReleasesTheBudget(t *testing.T) {
const pixels = 100 * 100
t.Run("when decoding fails", func(t *testing.T) {
d := newDecoder(pixels, time.Minute)
err := d.decode(t.Context(), BytesOpener(pngWithClaimedDimensions(t, 100, 100)), noUse)
require.ErrorIs(t, err, ErrUndecodable)
assertBudgetFree(t, d)
})
t.Run("when use fails", func(t *testing.T) {
d := newDecoder(pixels, time.Minute)
useErr := errors.New("encoding failed")
err := d.decode(t.Context(), BytesOpener(testPNG(t, 100, 100)), func(image.Image, string) error { return useErr })
require.ErrorIs(t, err, useErr)
assertBudgetFree(t, d)
})
t.Run("when use panics", func(t *testing.T) {
d := newDecoder(pixels, time.Minute)
source := &trackedSource{content: testPNG(t, 100, 100)}
assert.PanicsWithValue(t, "broken encoder", func() {
_ = d.decode(t.Context(), source.open, func(image.Image, string) error { panic("broken encoder") })
})
assertBudgetFree(t, d)
assert.Equal(t, source.opened.Load(), source.closed.Load())
})
t.Run("when decoding panics", func(t *testing.T) {
d := newDecoder(pixels, time.Minute)
source := &panickingSource{content: testPNG(t, 100, 100)}
assert.PanicsWithValue(t, "broken decoder", func() {
_ = d.decode(t.Context(), source.open, noUse)
})
assertBudgetFree(t, d)
})
}
type panickingSource struct {
content []byte
opened int
}
func (s *panickingSource) open() (io.ReadCloser, error) {
s.opened++
if s.opened == 1 {
return io.NopCloser(bytes.NewReader(s.content)), nil
}
return io.NopCloser(panickingReader{}), nil
}
type panickingReader struct{}
func (panickingReader) Read([]byte) (int, error) { panic("broken decoder") }
func TestDecode_Queue(t *testing.T) {
const pixels = 100 * 100
t.Run("cancelled caller leaves the queue with an error and holds nothing", func(t *testing.T) {
d := newDecoder(pixels, time.Minute)
require.True(t, d.slots.TryAcquire(pixels))
source := &trackedSource{content: testPNG(t, 100, 100)}
ctx, cancel := context.WithCancel(t.Context())
result := make(chan error, 1)
go func() {
result <- d.decode(ctx, source.open, noUse)
}()
require.Eventually(t, func() bool { return source.opened.Load() == 1 }, 5*time.Second, time.Millisecond)
cancel()
select {
case err := <-result:
require.ErrorIs(t, err, context.Canceled)
assert.False(t, IsErrDecodeBusy(err))
case <-time.After(5 * time.Second):
require.FailNow(t, "cancelled caller is still queued")
}
assert.Equal(t, int32(1), source.opened.Load(), "the source must not be opened for decoding")
d.slots.Release(pixels)
assertBudgetFree(t, d)
})
t.Run("gives up with a 503 when no capacity frees up", func(t *testing.T) {
d := newDecoder(pixels, 10*time.Millisecond)
require.True(t, d.slots.TryAcquire(pixels))
source := &trackedSource{content: testPNG(t, 100, 100)}
err := d.decode(t.Context(), source.open, noUse)
var busy ErrDecodeBusy
require.ErrorAs(t, err, &busy)
assert.Equal(t, http.StatusServiceUnavailable, busy.HTTPError().HTTPCode)
assert.Equal(t, ErrCodeDecodeBusy, busy.HTTPError().Code)
assert.Equal(t, int32(1), source.opened.Load(), "the source must not be opened for decoding")
d.slots.Release(pixels)
assertBudgetFree(t, d)
})
}