Support SVG sources (closes #68)
check / check (push) Waiting to run

The fetcher accepts image/svg+xml; internal/magic detects SVG. orig of
an SVG at the orig size is the SVG unchanged, cached, with a
Content-Security-Policy allowing only its styles and data: images; at
another size, ErrSVGNotOriginalSize (400). Any other format renders it
at the least size covering the output, at most 8192 on a side.
Rendering refuses an SVG with a side over 8192 times the other
(ErrSVGTooThin, 415), and the orig size of one above 8192 on a side
(ErrSVGTooLargeForOrigSize, 400). Child processes of pixad read an
SVG's size and render it, killed after 20 seconds and limited to 1 GiB;
any failure there is ErrSVGRenderFailed (415). Tests pinning the SVG
refusal now expect SVG.

Model: opus-5-5
This commit is contained in:
clawbot committed 2026-11-12 03:14:49 +00:00
1 parent 20874ba2af
commit 2b19774518
22 files changed
+1525 -131

No files matched your search

+278 -58
View File
@@ -1,4 +1,7 @@
// Package imageprocessor provides image format conversion and resizing using libvips.
// It reads the size of an SVG source and renders it in child processes, each
// the running program started again (see RunAsSVGChildProcess). pixa kills
// them after SVGRenderTimeout, and each may use at most MaxSVGRenderMemory.
package imageprocessor
import (
@@ -7,6 +10,8 @@ import (
"errors"
"fmt"
"io"
"math"
"strconv"
"sync"
"time"
@@ -113,10 +118,43 @@ var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
// ErrUnsupportedInputFormat is returned, before the input is decoded, when
// libvips reads the input's first bytes as another format than its source
// format, or as none. An SVG whose first bytes pass the first-bytes check for
// AVIF is one example. A source format other than the six pixa reads is also
// refused with it.
// AVIF is one example. A source format other than the seven pixa reads is
// also refused with it.
var ErrUnsupportedInputFormat = errors.New("unsupported input format")
// ErrSVGNotOriginalSize is returned when orig is asked of an SVG source at a
// size other than orig. pixa returns an SVG only as it is; a raster format
// gives it a size.
var ErrSVGNotOriginalSize = errors.New(
"an SVG is returned as SVG only at the orig size")
// ErrSVGTooThin is returned, before rendering, when an SVG source has one
// side more than MaxInputDimension times the other. Rendered with its longer
// side at most MaxInputDimension, its shorter side would be under 1 pixel.
var ErrSVGTooThin = errors.New("SVG source too thin to render")
// ErrSVGTooLargeForOrigSize is returned, before rendering, when a format other
// than orig asks for an SVG source at the orig size and the SVG has a side
// above MaxOutputDimension.
var ErrSVGTooLargeForOrigSize = errors.New(
"SVG source too large to render at its own size")
// SVGRenderTimeout is how long the child processes that read the size of one
// SVG source and render it may run, counted from the start of the first,
// before pixa kills them.
const SVGRenderTimeout = 20 * time.Second
// MaxSVGRenderMemory is the most memory, in bytes, that each child process
// reading the size of an SVG source or rendering it may use. It is the
// child's RLIMIT_DATA, which limits its heap and its other private writable
// memory.
const MaxSVGRenderMemory = 1 << 30 // 1 GiB
// ErrSVGRenderFailed is returned when a child process fails to read the size
// of an SVG source or to render it: librsvg cannot read the SVG, or the
// child takes more than SVGRenderTimeout or MaxSVGRenderMemory.
var ErrSVGRenderFailed = errors.New("SVG source could not be rendered")
// ErrTooManyImages is returned when MaxConcurrentProcessing images are being
// processed and none finishes within ProcessingWaitTimeout.
var ErrTooManyImages = errors.New("too many images being processed at once")
@@ -134,6 +172,10 @@ type ImageProcessor struct {
processingSemaphore chan struct{}
// processingWaitTimeout is ProcessingWaitTimeout; tests shorten it.
processingWaitTimeout time.Duration
// svgRenderTimeout is SVGRenderTimeout; tests shorten it.
svgRenderTimeout time.Duration
// maxSVGRenderMemory is MaxSVGRenderMemory; tests lower it.
maxSVGRenderMemory int
}
// Params holds configuration for creating an ImageProcessor. Both fields
@@ -153,6 +195,8 @@ func New(params Params) *ImageProcessor {
maxInputBytes: params.MaxInputBytes,
processingSemaphore: make(chan struct{}, params.MaxConcurrentProcessing),
processingWaitTimeout: ProcessingWaitTimeout,
svgRenderTimeout: SVGRenderTimeout,
maxSVGRenderMemory: MaxSVGRenderMemory,
}
}
@@ -164,7 +208,10 @@ func New(params Params) *ImageProcessor {
// encodes, it returns ctx's error and gives the slot back. govips gives pixa
// no way to stop libvips once an encode has begun, so an encode keeps its
// slot until it finishes. An AVIF output of more than MaxAVIFOutputPixels is
// refused with ErrAVIFOutputTooLarge before anything is decoded.
// refused with ErrAVIFOutputTooLarge before anything is decoded. orig of an
// SVG source is the SVG as it is, since libvips cannot save SVG; any other
// format renders it at the output size. Child processes read an SVG's size
// and render it, as processSVG describes.
func (p *ImageProcessor) Process(
ctx context.Context,
input io.Reader,
@@ -183,35 +230,18 @@ func (p *ImageProcessor) Process(
return nil, err
}
// Input libvips would not load as sourceFormat is refused before it is
// decoded
err = checkInputFormat(data, sourceFormat)
if sourceFormat == imagerequest.FormatSVG {
return p.processSVG(ctx, data, transform)
}
img, err := loadImage(data, sourceFormat)
if err != nil {
return nil, err
}
// Decode image
img, err := vips.NewImageFromBuffer(data)
if err != nil {
return nil, fmt.Errorf("failed to decode image: %w", err)
}
defer img.Close()
// Turn the image upright now: encode strips the EXIF orientation tag,
// and sizes below must be worked out on the upright image.
err = img.AutoRotate()
if err != nil {
return nil, fmt.Errorf("failed to auto-rotate: %w", err)
}
// Get original dimensions
origWidth := img.Width()
origHeight := img.Height()
// Validate input dimensions to prevent DoS via decompression bombs
if origWidth > MaxInputDimension || origHeight > MaxInputDimension {
return nil, ErrInputTooLarge
}
origWidth, origHeight := img.Width(), img.Height()
outputFormat := resolveOutputFormat(transform.Format, sourceFormat)
// Determine target dimensions; too large ones are refused before resizing
targetWidth, targetHeight, err := targetDimensions(transform.Size,
@@ -220,12 +250,6 @@ func (p *ImageProcessor) Process(
return nil, err
}
// orig is the source's own format; encode refuses an empty format
outputFormat := transform.Format
if outputFormat == imagerequest.FormatOriginal {
outputFormat = sourceFormat
}
// Determine output dimensions; too large ones are refused before
// resizing, which with FitCover already decodes the input
outputWidth, outputHeight, err := outputDimensions(origWidth, origHeight,
@@ -242,8 +266,104 @@ func (p *ImageProcessor) Process(
}
}
// Encode to target format
output, err := p.encode(ctx, img, outputFormat, transform.Quality)
return p.encodeResult(ctx, img, outputFormat, transform.Quality,
origWidth, origHeight)
}
// WaitForProcessing waits until no image is being processed, or until ctx
// ends, and returns how many images were still being processed then. It
// waits by taking each slot in processingSemaphore as it frees up until it
// holds them all, or until ctx ends, then gives back the slots it took.
func (p *ImageProcessor) WaitForProcessing(ctx context.Context) int {
taken := 0
defer func() {
for range taken {
<-p.processingSemaphore
}
}()
for taken < cap(p.processingSemaphore) {
select {
case p.processingSemaphore <- struct{}{}:
taken++
case <-ctx.Done():
return len(p.processingSemaphore) - taken
}
}
return 0
}
// processSVG is Process for an SVG source, data. Apart from the first-bytes
// check, pixad hands the SVG to libvips only in child processes (see
// runSVGChildProcess). One reads the SVG's size. For a raster format a second
// renders it as PNG, which is then resized and encoded as any other image.
// pixa kills them once svgRenderTimeout has passed since the first one
// started.
func (p *ImageProcessor) processSVG(
ctx context.Context, data []byte, transform *imagerequest.Transform,
) (*Result, error) {
err := checkInputFormat(data, imagerequest.FormatSVG)
if err != nil {
return nil, err
}
deadline := time.Now().Add(p.svgRenderTimeout)
origWidth, origHeight, err := p.readSVGSize(ctx, deadline, data)
if err != nil {
return nil, err
}
outputFormat := resolveOutputFormat(transform.Format, imagerequest.FormatSVG)
if outputFormat == imagerequest.FormatSVG {
return svgAsItIs(data, transform.Size, origWidth, origHeight)
}
err = checkSVGOrigSize(transform.Size, origWidth, origHeight)
if err != nil {
return nil, err
}
targetWidth, targetHeight, err := targetDimensions(transform.Size,
origWidth, origHeight)
if err != nil {
return nil, err
}
outputWidth, outputHeight, err := outputDimensions(origWidth, origHeight,
targetWidth, targetHeight, transform.FitMode, outputFormat)
if err != nil {
return nil, err
}
// The SVG is rendered at about the output size, to stay sharp
img, err := p.renderSVG(ctx, deadline, data, origWidth, origHeight,
outputWidth, outputHeight)
if err != nil {
return nil, err
}
defer img.Close()
if targetWidth != origWidth || targetHeight != origHeight {
err := p.resize(img, outputWidth, outputHeight, transform.FitMode)
if err != nil {
return nil, fmt.Errorf("failed to resize: %w", err)
}
}
return p.encodeResult(ctx, img, outputFormat, transform.Quality,
origWidth, origHeight)
}
// encodeResult encodes img as format at quality and returns it as the result
// for a source of origWidth x origHeight.
func (p *ImageProcessor) encodeResult(
ctx context.Context, img *vips.ImageRef, format imagerequest.Format,
quality, origWidth, origHeight int,
) (*Result, error) {
output, err := p.encode(ctx, img, format, quality)
if err != nil {
return nil, fmt.Errorf("failed to encode: %w", err)
}
@@ -251,7 +371,7 @@ func (p *ImageProcessor) Process(
return &Result{
Content: io.NopCloser(bytes.NewReader(output)),
ContentLength: int64(len(output)),
ContentType: outputFormat.MIMEType(),
ContentType: format.MIMEType(),
Width: img.Width(),
Height: img.Height(),
InputWidth: origWidth,
@@ -290,29 +410,125 @@ func targetDimensions(
return width, height, nil
}
// WaitForProcessing waits until no image is being processed, or until ctx
// ends, and returns how many images were still being processed then. It
// waits by taking each slot in processingSemaphore as it frees up until it
// holds them all, or until ctx ends, then gives back the slots it took.
func (p *ImageProcessor) WaitForProcessing(ctx context.Context) int {
taken := 0
defer func() {
for range taken {
<-p.processingSemaphore
}
}()
for taken < cap(p.processingSemaphore) {
select {
case p.processingSemaphore <- struct{}{}:
taken++
case <-ctx.Done():
return len(p.processingSemaphore) - taken
}
// resolveOutputFormat returns the format of the output for the requested
// format: sourceFormat for orig, and format itself for any other. encode
// refuses an empty format.
func resolveOutputFormat(
format, sourceFormat imagerequest.Format,
) imagerequest.Format {
if format == imagerequest.FormatOriginal {
return sourceFormat
}
return 0
return format
}
// loadImage refuses data that libvips would not load as sourceFormat, then
// decodes it and turns the image upright: encode strips the EXIF orientation
// tag, and sizes must be worked out on the upright image. An image with a
// side above MaxInputDimension is ErrInputTooLarge, which guards against
// decompression bombs.
func loadImage(
data []byte, sourceFormat imagerequest.Format,
) (*vips.ImageRef, error) {
err := checkInputFormat(data, sourceFormat)
if err != nil {
return nil, err
}
img, err := vips.NewImageFromBuffer(data)
if err != nil {
return nil, fmt.Errorf("failed to decode image: %w", err)
}
err = img.AutoRotate()
if err != nil {
img.Close()
return nil, fmt.Errorf("failed to auto-rotate: %w", err)
}
if img.Width() > MaxInputDimension || img.Height() > MaxInputDimension {
img.Close()
return nil, ErrInputTooLarge
}
return img, nil
}
// svgAsItIs returns the SVG data unchanged, which is orig of an SVG source
// at the orig size. width x height is the SVG's own size. Any other size is
// ErrSVGNotOriginalSize.
func svgAsItIs(
data []byte, size imagerequest.Size, width, height int,
) (*Result, error) {
if !size.OriginalSize() {
return nil, fmt.Errorf("%w: asked for %dx%d", ErrSVGNotOriginalSize,
size.Width, size.Height)
}
return &Result{
Content: io.NopCloser(bytes.NewReader(data)),
ContentLength: int64(len(data)),
ContentType: imagerequest.FormatSVG.MIMEType(),
Width: width,
Height: height,
InputWidth: width,
InputHeight: height,
}, nil
}
// checkSVGOrigSize returns ErrSVGTooLargeForOrigSize when size is the orig
// size and the SVG source, of width x height, has a side above
// MaxOutputDimension. processSVG calls it after returning orig of the SVG, so
// the SVG would be rendered at its own size.
func checkSVGOrigSize(size imagerequest.Size, width, height int) error {
if size.OriginalSize() && max(width, height) > MaxOutputDimension {
return fmt.Errorf("%w: %dx%d is above %d", ErrSVGTooLargeForOrigSize,
width, height, MaxOutputDimension)
}
return nil
}
// svgRenderSize returns the least size in the proportions of width x height,
// an SVG's own size, that covers outputWidth x outputHeight, so that resize
// hardly changes it. Neither side is above MaxInputDimension.
func svgRenderSize(width, height, outputWidth, outputHeight int) (int, int) {
scale := min(
max(float64(outputWidth)/float64(width),
float64(outputHeight)/float64(height)),
float64(MaxInputDimension)/float64(max(width, height)))
return int(math.Round(float64(width) * scale)),
int(math.Round(float64(height) * scale))
}
// renderSVG renders the SVG data, whose own size is width x height, at
// svgRenderSize for outputWidth x outputHeight, in a child process that pixa
// kills at deadline (see runSVGChildProcess). It returns the rendered image,
// read from the child's PNG. An SVG with one side more than
// MaxInputDimension times the other is ErrSVGTooThin.
func (p *ImageProcessor) renderSVG(
ctx context.Context, deadline time.Time, data []byte,
width, height, outputWidth, outputHeight int,
) (*vips.ImageRef, error) {
if max(width, height) > MaxInputDimension*min(width, height) {
return nil, fmt.Errorf("%w: %dx%d", ErrSVGTooThin, width, height)
}
renderWidth, renderHeight := svgRenderSize(width, height,
outputWidth, outputHeight)
png, err := p.runSVGChildProcess(ctx, deadline, data,
renderSVGAsPNGArgument, strconv.Itoa(renderWidth),
strconv.Itoa(renderHeight))
if err != nil {
return nil, err
}
return loadImage(png, imagerequest.FormatPNG)
}
// acquireSlot takes a slot in processingSemaphore, waiting at most
@@ -397,6 +613,8 @@ func vipsImageType(format imagerequest.Format) vips.ImageType {
return vips.ImageTypeAVIF
case imagerequest.FormatJXL:
return vips.ImageTypeJXL
case imagerequest.FormatSVG:
return vips.ImageTypeSVG
case imagerequest.FormatOriginal, imagerequest.FormatAuto:
return vips.ImageTypeUnknown
default:
@@ -575,7 +793,9 @@ func (p *ImageProcessor) encode(
case imagerequest.FormatJXL:
return exportJXL(img, quality)
case imagerequest.FormatOriginal, imagerequest.FormatAuto:
// libvips cannot save SVG; Process returns an SVG source as it is
case imagerequest.FormatSVG, imagerequest.FormatOriginal,
imagerequest.FormatAuto:
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
default:
@@ -19,6 +19,11 @@ import (
)
func TestMain(m *testing.M) {
// The SVG tests start this test binary again as SVG child processes
if IsSVGChildProcess(os.Args[1:]) {
os.Exit(RunAsSVGChildProcess(os.Args[1:]))
}
initVips()
code := m.Run()
@@ -51,7 +51,7 @@ func TestImageProcessor_LoadsEachSourceFormat(t *testing.T) {
}
// TestImageProcessor_NotASourceFormatRefused verifies that a source format
// other than the six, such as orig, auto or none, is refused with
// other than the seven, such as orig, auto or none, is refused with
// ErrUnsupportedInputFormat. The input is text, which libvips reads as no
// format.
func TestImageProcessor_NotASourceFormatRefused(t *testing.T) {
@@ -0,0 +1,255 @@
package imageprocessor
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strconv"
"syscall"
"time"
"github.com/davidbyttow/govips/v2/vips"
)
// An SVG child process is the running program started again with one of
// these arguments first, naming what it does. Its command line, after the
// program's name, is one of:
//
// read-svg-size MEMORY_LIMIT
// render-svg-as-png MEMORY_LIMIT WIDTH HEIGHT
//
// MEMORY_LIMIT is in bytes. The child reads the SVG on standard input. It
// writes the SVG's size as WIDTH HEIGHT, or the SVG rendered at WIDTH x
// HEIGHT as PNG, to standard output.
const (
readSVGSizeArgument = "read-svg-size"
renderSVGAsPNGArgument = "render-svg-as-png"
)
// errSVGChildProcessArguments is returned for a command line that is not
// that of an SVG child process.
var errSVGChildProcessArguments = errors.New(
"not the command line of an SVG child process")
// IsSVGChildProcess reports whether args, a process's command line after the
// program's name, are those of an SVG child process.
func IsSVGChildProcess(args []string) bool {
return len(args) > 0 &&
(args[0] == readSVGSizeArgument || args[0] == renderSVGAsPNGArgument)
}
// RunAsSVGChildProcess runs this process as the SVG child process that args,
// its command line after the program's name, ask for. It returns the status
// to exit with: 0 once it has written its result, and 1 after writing an
// error to standard error, which the parent discards.
func RunAsSVGChildProcess(args []string) int {
err := runAsSVGChildProcess(args, os.Stdin, os.Stdout)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, err)
return 1
}
return 0
}
// runAsSVGChildProcess does the work of RunAsSVGChildProcess, reading the SVG
// from stdin and writing the result to stdout. It sets the process's limits
// before it reads the SVG.
func runAsSVGChildProcess(
args []string, stdin io.Reader, stdout io.Writer,
) error {
numbers, err := parseSVGChildProcessArguments(args)
if err != nil {
return err
}
err = setSVGChildProcessLimits(numbers[0])
if err != nil {
return err
}
initVips()
svg, err := io.ReadAll(stdin)
if err != nil {
return fmt.Errorf("failed to read the SVG: %w", err)
}
if args[0] == readSVGSizeArgument {
return writeSVGSize(svg, stdout)
}
return writeSVGAsPNG(svg, numbers[1], numbers[2], stdout)
}
// parseSVGChildProcessArguments returns the numbers on the command line of an
// SVG child process, args: the memory limit, then the width and height for
// render-svg-as-png.
func parseSVGChildProcessArguments(args []string) ([]int, error) {
wantArgs := 2
if len(args) > 0 && args[0] == renderSVGAsPNGArgument {
wantArgs = 4
}
if !IsSVGChildProcess(args) || len(args) != wantArgs {
return nil, fmt.Errorf("%w: %q", errSVGChildProcessArguments, args)
}
numbers := make([]int, 0, len(args)-1)
for _, arg := range args[1:] {
number, err := strconv.Atoi(arg)
if err != nil {
return nil, fmt.Errorf("%w: %q: %w", errSVGChildProcessArguments,
args, err)
}
numbers = append(numbers, number)
}
return numbers, nil
}
// setSVGChildProcessLimits sets this process's RLIMIT_DATA to memoryLimit
// bytes, so that allocating heap or other private writable memory past it
// fails. RLIMIT_AS would also count the address space the Go runtime reserves
// but does not use. It also sets RLIMIT_CORE to 0: librsvg crashes when an
// allocation fails, and the crash would write a core file of up to
// memoryLimit bytes into pixad's working directory.
func setSVGChildProcessLimits(memoryLimit int) error {
if memoryLimit < 1 {
return fmt.Errorf("%w: memory limit %d", errSVGChildProcessArguments,
memoryLimit)
}
err := syscall.Setrlimit(syscall.RLIMIT_DATA, &syscall.Rlimit{
Cur: uint64(memoryLimit),
Max: uint64(memoryLimit),
})
if err != nil {
return fmt.Errorf("failed to limit memory to %d bytes: %w",
memoryLimit, err)
}
err = syscall.Setrlimit(syscall.RLIMIT_CORE, &syscall.Rlimit{})
if err != nil {
return fmt.Errorf("failed to turn off core files: %w", err)
}
return nil
}
// writeSVGSize writes the size of svg to stdout as WIDTH HEIGHT. librsvg
// reads the whole SVG for it.
func writeSVGSize(svg []byte, stdout io.Writer) error {
img, err := vips.NewImageFromBuffer(svg)
if err != nil {
return fmt.Errorf("failed to read the SVG: %w", err)
}
defer img.Close()
_, err = fmt.Fprintf(stdout, "%d %d\n", img.Width(), img.Height())
return err
}
// writeSVGAsPNG renders svg at width x height and writes it to stdout as PNG.
// libvips' thumbnail has librsvg render the SVG at that size directly, at
// any scale: govips takes a density only in whole dots per inch, which cannot
// render an SVG below 1/72 of its own size. Given the SVG as bytes with no
// file name, librsvg loads no file or URL the SVG refers to.
func writeSVGAsPNG(svg []byte, width, height int, stdout io.Writer) error {
img, err := vips.NewThumbnailWithSizeFromBuffer(svg, width, height,
vips.InterestingNone, vips.SizeBoth)
if err != nil {
return fmt.Errorf("failed to render the SVG: %w", err)
}
defer img.Close()
png, err := exportPNG(img)
if err != nil {
return fmt.Errorf("failed to save the SVG as PNG: %w", err)
}
_, err = stdout.Write(png)
return err
}
// readSVGSize reads the size of the SVG data in a child process that pixa
// kills at deadline (see runSVGChildProcess).
func (p *ImageProcessor) readSVGSize(
ctx context.Context, deadline time.Time, data []byte,
) (int, int, error) {
output, err := p.runSVGChildProcess(ctx, deadline, data,
readSVGSizeArgument)
if err != nil {
return 0, 0, err
}
var width, height int
_, err = fmt.Sscan(string(output), &width, &height)
if err != nil {
return 0, 0, fmt.Errorf("failed to read the size %q an SVG child "+
"process wrote: %w", output, err)
}
return width, height, nil
}
// runSVGChildProcess starts the running program again as an SVG child
// process, with argument, p.maxSVGRenderMemory and extraArgs as its command
// line, and waits for it. It gives the child data on standard input,
// discards its standard error, and returns its standard output. pixa kills
// the child at deadline. A child that fails or is killed is
// ErrSVGRenderFailed, unless ctx has ended: runSVGChildProcess then returns
// ctx's error. Failing to start the child is another error.
func (p *ImageProcessor) runSVGChildProcess(
ctx context.Context, deadline time.Time, data []byte,
argument string, extraArgs ...string,
) ([]byte, error) {
executable, err := os.Executable()
if err != nil {
return nil, fmt.Errorf("failed to find the running program: %w", err)
}
childCtx, cancel := context.WithDeadline(ctx, deadline)
defer cancel()
args := append([]string{argument, strconv.Itoa(p.maxSVGRenderMemory)},
extraArgs...)
//nolint:gosec // the running program, with arguments pixa makes
cmd := exec.CommandContext(childCtx, executable, args...)
cmd.Stdin = bytes.NewReader(data)
var stdout bytes.Buffer
cmd.Stdout = &stdout
err = cmd.Run()
var exitError *exec.ExitError
switch {
case err == nil:
return stdout.Bytes(), nil
case ctx.Err() != nil:
return nil, ctx.Err()
case childCtx.Err() != nil:
return nil, fmt.Errorf("%w: it took more than %v", ErrSVGRenderFailed,
p.svgRenderTimeout)
case errors.As(err, &exitError):
return nil, fmt.Errorf("%w: %w", ErrSVGRenderFailed, err)
default:
return nil, fmt.Errorf("failed to run an SVG child process: %w", err)
}
}
@@ -0,0 +1,503 @@
package imageprocessor
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/davidbyttow/govips/v2/vips"
"sneak.berlin/go/pixa/internal/imagerequest"
)
// testSVG is a 16x12 SVG, black on its left half and white on its right.
const testSVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="12">` +
`<rect width="16" height="12" fill="white"/>` +
`<rect width="8" height="12" fill="black"/></svg>`
// newSVGTestProcessor returns a processor for the SVG tests.
func newSVGTestProcessor() *ImageProcessor {
return New(Params{MaxInputBytes: 1 << 20, MaxConcurrentProcessing: 1})
}
// processSVG processes svg, as an SVG source, with transform.
func processSVG(
t *testing.T, svg string, transform imagerequest.Transform,
) (*Result, []byte, error) {
t.Helper()
result, err := newSVGTestProcessor().Process(t.Context(),
bytes.NewReader([]byte(svg)), imagerequest.FormatSVG, &transform)
if err != nil {
return nil, nil, err
}
output, err := io.ReadAll(result.Content)
if err != nil {
t.Fatalf("reading the output: %v", err)
}
return result, output, nil
}
// TestImageProcessor_SVGOrigIsTheSVG checks that orig of an SVG at the orig
// size is the SVG itself, never another format, and that orig at another
// size is ErrSVGNotOriginalSize.
func TestImageProcessor_SVGOrigIsTheSVG(t *testing.T) {
t.Parallel()
result, output, err := processSVG(t, testSVG,
imagerequest.Transform{Format: imagerequest.FormatOriginal})
if err != nil {
t.Fatalf("orig of the SVG: %v", err)
}
t.Logf("orig of the SVG: %s, %dx%d", result.ContentType, result.Width,
result.Height)
if result.ContentType != "image/svg+xml" || string(output) != testSVG {
t.Errorf("orig of the SVG = %s %q, want image/svg+xml %q",
result.ContentType, output, testSVG)
}
_, _, err = processSVG(t, testSVG, imagerequest.Transform{
Size: imagerequest.Size{Width: 32, Height: 24},
Format: imagerequest.FormatOriginal,
})
if !errors.Is(err, ErrSVGNotOriginalSize) {
t.Errorf("orig of the SVG at 32x24: error = %v, want %v", err,
ErrSVGNotOriginalSize)
}
}
// TestImageProcessor_RendersSVGToEachRasterFormat checks that an SVG is
// rendered to each raster format at the requested size.
func TestImageProcessor_RendersSVGToEachRasterFormat(t *testing.T) {
t.Parallel()
for _, format := range []imagerequest.Format{
imagerequest.FormatJPEG, imagerequest.FormatPNG, imagerequest.FormatGIF,
imagerequest.FormatWebP, imagerequest.FormatAVIF, imagerequest.FormatJXL,
} {
result, output, err := processSVG(t, testSVG, imagerequest.Transform{
Size: imagerequest.Size{Width: 40, Height: 30},
Format: format,
})
if err != nil {
t.Errorf("SVG as %s: %v", format, err)
continue
}
t.Logf("SVG as %s: %s, %dx%d, %d bytes", format, result.ContentType,
result.Width, result.Height, len(output))
if result.ContentType != format.MIMEType() ||
vips.DetermineImageType(output) != vipsImageType(format) ||
result.Width != 40 || result.Height != 30 {
t.Errorf("SVG as %s = %s, %dx%d; want %s, 40x30", format,
result.ContentType, result.Width, result.Height, format.MIMEType())
}
}
}
// TestImageProcessor_RendersSVGSharpWhenEnlarged checks that a 16x12 SVG
// asked for at 1600x1200 is rendered at that size, not rendered at 16x12 and
// enlarged: 10 pixels either side of the edge between its black and white
// halves are black and white, where an enlarged image is grey there.
func TestImageProcessor_RendersSVGSharpWhenEnlarged(t *testing.T) {
t.Parallel()
_, output, err := processSVG(t, testSVG, imagerequest.Transform{
Size: imagerequest.Size{Width: 1600, Height: 1200},
Format: imagerequest.FormatPNG,
})
if err != nil {
t.Fatalf("SVG as PNG at 1600x1200: %v", err)
}
img, err := vips.NewImageFromBuffer(output)
if err != nil {
t.Fatalf("decoding the PNG: %v", err)
}
t.Cleanup(img.Close)
for x, want := range map[int]float64{790: 0, 810: 255} {
point, err := img.GetPoint(x, 600)
if err != nil {
t.Fatalf("GetPoint(%d, 600): %v", x, err)
}
t.Logf("pixel at %d,600: %v", x, point)
if point[0] != want {
t.Errorf("pixel at %d,600 = %v, want %v", x, point[0], want)
}
}
}
// TestImageProcessor_SVGFetchesNothing checks that rendering an SVG draws no
// image it refers to: one at a URL on a local server, by href or xlink:href,
// or a local file that exists. Each SVG renders without error, white where
// the image would be. An SVG that uses an external entity at that server is
// a separate case. No SVG makes a request to the server.
func TestImageProcessor_SVGFetchesNothing(t *testing.T) {
t.Parallel()
var requests atomic.Int64
// Blue is 128 in every pixel of this PNG.
png := createTestPNG(t, 4, 4)
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
t.Logf("request for %s", r.URL)
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(png)
}))
t.Cleanup(server.Close)
pngPath := filepath.Join(t.TempDir(), "image.png")
err := os.WriteFile(pngPath, png, 0o600)
if err != nil {
t.Fatalf("writing the PNG file: %v", err)
}
for _, reference := range []string{
`href="` + server.URL + `/image.png"`,
`xlink:href="` + server.URL + `/xlink.png"`,
`href="file://` + pngPath + `"`,
} {
svg := `<svg xmlns="http://www.w3.org/2000/svg" ` +
`xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="12">` +
`<rect width="16" height="12" fill="white"/>` +
`<image ` + reference + ` width="16" height="12"/></svg>`
_, output, err := processSVG(t, svg, imagerequest.Transform{
Format: imagerequest.FormatPNG,
})
if err != nil {
t.Errorf("SVG with image %s as PNG: %v", reference, err)
continue
}
img, err := vips.NewImageFromBuffer(output)
if err != nil {
t.Fatalf("decoding the PNG: %v", err)
}
point, err := img.GetPoint(8, 6)
img.Close()
if err != nil {
t.Fatalf("GetPoint(8, 6): %v", err)
}
t.Logf("SVG with image %s: pixel at 8,6 = %v", reference, point)
if point[2] != 255 {
t.Errorf("SVG with image %s: blue at 8,6 = %v, want 255: the "+
"image is drawn", reference, point[2])
}
}
svg := `<?xml version="1.0"?>` +
`<!DOCTYPE svg [<!ENTITY outside SYSTEM "` + server.URL + `/entity">]>` +
`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="12">` +
`<text y="10">&outside;</text></svg>`
// librsvg loads no external entity, so it refuses this SVG: outside is
// not defined.
_, _, err = processSVG(t, svg, imagerequest.Transform{
Format: imagerequest.FormatPNG,
})
t.Logf("SVG with an external entity as PNG: error = %v", err)
if requests.Load() != 0 {
t.Errorf("rendering the SVGs made %d requests, want none",
requests.Load())
}
}
// TestImageProcessor_RendersSVGLargerThanMaxInputDimension checks that an
// SVG whose own size is above MaxInputDimension is limited only by the
// output checks: it is rendered at a raster size, orig of it is the SVG
// itself, and a raster format at its own size is ErrSVGTooLargeForOrigSize.
// The 1000000x1000000 one is rendered at a ten-thousandth of its own size.
func TestImageProcessor_RendersSVGLargerThanMaxInputDimension(t *testing.T) {
t.Parallel()
for _, svg := range []string{
`<svg xmlns="http://www.w3.org/2000/svg" width="10000" height="10">` +
`<rect width="10000" height="10"/></svg>`,
`<svg xmlns="http://www.w3.org/2000/svg" width="1000000" ` +
`height="1000000"><rect width="1000000" height="1000000"/></svg>`,
} {
result, _, err := processSVG(t, svg, imagerequest.Transform{
Size: imagerequest.Size{Width: 100, Height: 100},
Format: imagerequest.FormatPNG,
})
if err != nil {
t.Errorf("%s as PNG at 100x100: %v", svg, err)
} else {
t.Logf("%s as PNG at 100x100: %dx%d", svg, result.Width, result.Height)
if result.Width != 100 || result.Height != 100 {
t.Errorf("%s as PNG at 100x100 = %dx%d, want 100x100", svg,
result.Width, result.Height)
}
}
result, output, err := processSVG(t, svg,
imagerequest.Transform{Format: imagerequest.FormatOriginal})
if err != nil || result.ContentType != imagerequest.FormatSVG.MIMEType() ||
string(output) != svg {
t.Errorf("orig of %s: error = %v, want the SVG itself", svg, err)
}
_, _, err = processSVG(t, svg,
imagerequest.Transform{Format: imagerequest.FormatPNG})
if !errors.Is(err, ErrSVGTooLargeForOrigSize) {
t.Errorf("%s as PNG at its own size: error = %v, want %v", svg,
err, ErrSVGTooLargeForOrigSize)
}
}
}
// TestImageProcessor_RefusesSVGTooThin checks that an SVG with one side more
// than MaxInputDimension times the other is ErrSVGTooThin at a raster size,
// that one at exactly that proportion is rendered, and that orig of either
// is the SVG itself.
func TestImageProcessor_RefusesSVGTooThin(t *testing.T) {
t.Parallel()
tooThin := `<svg xmlns="http://www.w3.org/2000/svg" width="81930" ` +
`height="10"><rect width="81930" height="10"/></svg>`
atTheLimit := `<svg xmlns="http://www.w3.org/2000/svg" width="81920" ` +
`height="10"><rect width="81920" height="10"/></svg>`
size := imagerequest.Size{Width: 100, Height: 100}
_, _, err := processSVG(t, tooThin,
imagerequest.Transform{Size: size, Format: imagerequest.FormatPNG})
if !errors.Is(err, ErrSVGTooThin) {
t.Errorf("81930x10 SVG as PNG at 100x100: error = %v, want %v", err,
ErrSVGTooThin)
}
result, _, err := processSVG(t, atTheLimit,
imagerequest.Transform{Size: size, Format: imagerequest.FormatPNG})
if err != nil {
t.Errorf("81920x10 SVG as PNG at 100x100: %v", err)
} else if result.Width != 100 || result.Height != 100 {
t.Errorf("81920x10 SVG as PNG at 100x100 = %dx%d, want 100x100",
result.Width, result.Height)
}
for _, svg := range []string{tooThin, atTheLimit} {
result, output, err := processSVG(t, svg,
imagerequest.Transform{Format: imagerequest.FormatOriginal})
if err != nil || result.ContentType != imagerequest.FormatSVG.MIMEType() ||
string(output) != svg {
t.Errorf("orig of %s: error = %v, want the SVG itself", svg, err)
}
}
}
// testSVGRenderTimeout is the time limit of the SVG time limit tests, short
// so that they finish in a few seconds. Under the race detector a child
// process takes about a second to start and read an SVG's size.
const testSVGRenderTimeout = 3 * time.Second
// processSVGTimed has processor process svg, as an SVG source, with transform,
// and returns the error and how long it took.
func processSVGTimed(
t *testing.T, processor *ImageProcessor, svg string,
transform imagerequest.Transform,
) (time.Duration, error) {
t.Helper()
start := time.Now()
_, err := processor.Process(t.Context(), strings.NewReader(svg),
imagerequest.FormatSVG, &transform)
return time.Since(start), err
}
// checkProcessesAnotherImage checks that processor, after refusing an SVG,
// processes a PNG.
func checkProcessesAnotherImage(t *testing.T, processor *ImageProcessor) {
t.Helper()
_, err := processor.Process(t.Context(),
bytes.NewReader(createTestPNG(t, 64, 48)), imagerequest.FormatPNG,
&imagerequest.Transform{
Size: imagerequest.Size{Width: 32, Height: 24},
Format: imagerequest.FormatJPEG,
})
if err != nil {
t.Errorf("a PNG after the SVG: %v", err)
}
}
// TestImageProcessor_StopsSlowSVGRender checks that an SVG that takes about
// 12 seconds to render, 600 shapes each drawn with an feTurbulence filter
// effect, is ErrSVGRenderFailed soon after a time limit of
// testSVGRenderTimeout, not when the render would end. The processor then
// processes another image.
func TestImageProcessor_StopsSlowSVGRender(t *testing.T) {
t.Parallel()
svg := `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256">` +
`<filter id="noise">` +
`<feTurbulence baseFrequency="0.05" numOctaves="4"/></filter>` +
strings.Repeat(`<rect width="256" height="256" filter="url(#noise)"/>`,
600) +
`</svg>`
processor := newSVGTestProcessor()
processor.svgRenderTimeout = testSVGRenderTimeout
elapsed, err := processSVGTimed(t, processor, svg, imagerequest.Transform{
Size: imagerequest.Size{Width: 256, Height: 256},
Format: imagerequest.FormatPNG,
})
t.Logf("slow SVG as PNG: error %v after %v", err, elapsed)
if !errors.Is(err, ErrSVGRenderFailed) {
t.Errorf("slow SVG as PNG: error = %v, want %v", err,
ErrSVGRenderFailed)
}
if elapsed > testSVGRenderTimeout+2*time.Second {
t.Errorf("slow SVG as PNG: refused after %v, want soon after %v",
elapsed, testSVGRenderTimeout)
}
checkProcessesAnotherImage(t, processor)
}
// TestImageProcessor_StopsSlowSVGSizeRead checks that orig of an SVG whose
// size takes about 10 seconds to read is ErrSVGRenderFailed soon after a time
// limit of testSVGRenderTimeout. To read the size, librsvg matches each of the
// SVG's 2500 CSS rules against each of its 2500 shapes, walking up the 75
// groups around it. The processor then processes another image.
func TestImageProcessor_StopsSlowSVGSizeRead(t *testing.T) {
t.Parallel()
var rules strings.Builder
for i := range 2500 {
fmt.Fprintf(&rules, ".group%d rect { fill: red }", i)
}
svg := `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="12">` +
`<style>` + rules.String() + `</style>` + strings.Repeat(`<g>`, 75) +
strings.Repeat(`<rect width="1" height="1"/>`, 2500) +
strings.Repeat(`</g>`, 75) + `</svg>`
processor := newSVGTestProcessor()
processor.svgRenderTimeout = testSVGRenderTimeout
elapsed, err := processSVGTimed(t, processor, svg,
imagerequest.Transform{Format: imagerequest.FormatOriginal})
t.Logf("orig of the SVG: error %v after %v", err, elapsed)
if !errors.Is(err, ErrSVGRenderFailed) {
t.Errorf("orig of the SVG: error = %v, want %v", err,
ErrSVGRenderFailed)
}
if elapsed > testSVGRenderTimeout+2*time.Second {
t.Errorf("orig of the SVG: refused after %v, want soon after %v",
elapsed, testSVGRenderTimeout)
}
checkProcessesAnotherImage(t, processor)
}
// TestImageProcessor_StopsSVGRenderOverMemoryLimit checks that an SVG that
// needs about 500 MiB more than a child process's own memory to render, a
// chain of 2000 blurs that each keep their result, is ErrSVGRenderFailed with
// a memory limit of 512 MiB, and that a plain SVG renders under the same
// limit. A child process needs about 130 MiB of its own, and about 350 MiB
// under the race detector, so the chain also renders under MaxSVGRenderMemory.
func TestImageProcessor_StopsSVGRenderOverMemoryLimit(t *testing.T) {
t.Parallel()
var blurs strings.Builder
blurs.WriteString(`<feGaussianBlur stdDeviation="3" result="blur0"/>`)
for i := 1; i < 2000; i++ {
fmt.Fprintf(&blurs,
`<feGaussianBlur in="blur%d" stdDeviation="3" result="blur%d"/>`,
i-1, i)
}
svg := `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256">` +
`<filter id="blurs" color-interpolation-filters="sRGB">` +
blurs.String() + `</filter>` +
`<rect width="256" height="256" fill="red" filter="url(#blurs)"/></svg>`
size := imagerequest.Size{Width: 256, Height: 256}
processor := newSVGTestProcessor()
processor.maxSVGRenderMemory = 512 << 20
elapsed, err := processSVGTimed(t, processor, svg,
imagerequest.Transform{Size: size, Format: imagerequest.FormatPNG})
t.Logf("SVG of 2000 blurs as PNG: error %v after %v", err, elapsed)
if !errors.Is(err, ErrSVGRenderFailed) {
t.Errorf("SVG of 2000 blurs as PNG: error = %v, want %v", err,
ErrSVGRenderFailed)
}
_, err = processSVGTimed(t, processor, testSVG,
imagerequest.Transform{Size: size, Format: imagerequest.FormatPNG})
if err != nil {
t.Errorf("plain SVG as PNG after it: %v", err)
}
}
// TestSVGRenderSize checks the size at which an SVG is rendered to cover an
// output size, and that it is held to MaxInputDimension.
func TestSVGRenderSize(t *testing.T) {
t.Parallel()
tests := []struct {
name string
width, height int
outputWidth, outputHeight int
wantWidth, wantHeight int
}{
{"own size", 16, 12, 16, 12, 16, 12},
{"100 times larger", 16, 12, 1600, 1200, 1600, 1200},
{"wider than the SVG's proportions", 16, 12, 160, 12, 160, 120},
{"10 times smaller", 100, 100, 10, 10, 10, 10},
{"held to MaxInputDimension", 8192, 1, 8192, 8192, 8192, 1},
{"held to MaxInputDimension when enlarged", 1024, 1, 8192, 8192, 8192, 8},
{"far above MaxInputDimension", 1000000, 1000000, 100, 100, 100, 100},
}
for _, tt := range tests {
gotWidth, gotHeight := svgRenderSize(tt.width, tt.height,
tt.outputWidth, tt.outputHeight)
if gotWidth != tt.wantWidth || gotHeight != tt.wantHeight {
t.Errorf("%s: svgRenderSize(%d, %d, %d, %d) = %dx%d, want %dx%d",
tt.name, tt.width, tt.height, tt.outputWidth, tt.outputHeight,
gotWidth, gotHeight, tt.wantWidth, tt.wantHeight)
}
}
}