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: