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:
1 parent
20874ba2af
commit
2b19774518
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user