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
256 lines
7.3 KiB
Go
256 lines
7.3 KiB
Go
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)
|
|
}
|
|
}
|