mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-11-09 00:22:38 -06:00
407 lines
11 KiB
Go
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)
|
|
})
|
|
}
|