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