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 was merged in pull request #351.
This commit is contained in:
clawbot committed 2026-11-12 04:41:25 +01:00
1 parent 20874ba2af
commit c2e32d987b
22 files changed
+1525 -131

No files matched your search

@@ -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)
}
}