package imageprocessor import ( "bytes" "errors" "fmt" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync/atomic" "testing" "time" "github.com/davidbyttow/govips/v2/vips" "sneak.berlin/go/pixa/internal/imagerequest" ) // testSVG is a 16x12 SVG, black on its left half and white on its right. const testSVG = `` + `` + `` // newSVGTestProcessor returns a processor for the SVG tests. func newSVGTestProcessor() *ImageProcessor { return New(Params{MaxInputBytes: 1 << 20, MaxConcurrentProcessing: 1}) } // processSVG processes svg, as an SVG source, with transform. func processSVG( t *testing.T, svg string, transform imagerequest.Transform, ) (*Result, []byte, error) { t.Helper() result, err := newSVGTestProcessor().Process(t.Context(), bytes.NewReader([]byte(svg)), imagerequest.FormatSVG, &transform) if err != nil { return nil, nil, err } output, err := io.ReadAll(result.Content) if err != nil { t.Fatalf("reading the output: %v", err) } return result, output, nil } // TestImageProcessor_SVGOrigIsTheSVG checks that orig of an SVG at the orig // size is the SVG itself, never another format, and that orig at another // size is ErrSVGNotOriginalSize. func TestImageProcessor_SVGOrigIsTheSVG(t *testing.T) { t.Parallel() result, output, err := processSVG(t, testSVG, imagerequest.Transform{Format: imagerequest.FormatOriginal}) if err != nil { t.Fatalf("orig of the SVG: %v", err) } t.Logf("orig of the SVG: %s, %dx%d", result.ContentType, result.Width, result.Height) if result.ContentType != "image/svg+xml" || string(output) != testSVG { t.Errorf("orig of the SVG = %s %q, want image/svg+xml %q", result.ContentType, output, testSVG) } _, _, err = processSVG(t, testSVG, imagerequest.Transform{ Size: imagerequest.Size{Width: 32, Height: 24}, Format: imagerequest.FormatOriginal, }) if !errors.Is(err, ErrSVGNotOriginalSize) { t.Errorf("orig of the SVG at 32x24: error = %v, want %v", err, ErrSVGNotOriginalSize) } } // TestImageProcessor_RendersSVGToEachRasterFormat checks that an SVG is // rendered to each raster format at the requested size. func TestImageProcessor_RendersSVGToEachRasterFormat(t *testing.T) { t.Parallel() for _, format := range []imagerequest.Format{ imagerequest.FormatJPEG, imagerequest.FormatPNG, imagerequest.FormatGIF, imagerequest.FormatWebP, imagerequest.FormatAVIF, imagerequest.FormatJXL, } { result, output, err := processSVG(t, testSVG, imagerequest.Transform{ Size: imagerequest.Size{Width: 40, Height: 30}, Format: format, }) if err != nil { t.Errorf("SVG as %s: %v", format, err) continue } t.Logf("SVG as %s: %s, %dx%d, %d bytes", format, result.ContentType, result.Width, result.Height, len(output)) if result.ContentType != format.MIMEType() || vips.DetermineImageType(output) != vipsImageType(format) || result.Width != 40 || result.Height != 30 { t.Errorf("SVG as %s = %s, %dx%d; want %s, 40x30", format, result.ContentType, result.Width, result.Height, format.MIMEType()) } } } // TestImageProcessor_RendersSVGSharpWhenEnlarged checks that a 16x12 SVG // asked for at 1600x1200 is rendered at that size, not rendered at 16x12 and // enlarged: 10 pixels either side of the edge between its black and white // halves are black and white, where an enlarged image is grey there. func TestImageProcessor_RendersSVGSharpWhenEnlarged(t *testing.T) { t.Parallel() _, output, err := processSVG(t, testSVG, imagerequest.Transform{ Size: imagerequest.Size{Width: 1600, Height: 1200}, Format: imagerequest.FormatPNG, }) if err != nil { t.Fatalf("SVG as PNG at 1600x1200: %v", err) } img, err := vips.NewImageFromBuffer(output) if err != nil { t.Fatalf("decoding the PNG: %v", err) } t.Cleanup(img.Close) for x, want := range map[int]float64{790: 0, 810: 255} { point, err := img.GetPoint(x, 600) if err != nil { t.Fatalf("GetPoint(%d, 600): %v", x, err) } t.Logf("pixel at %d,600: %v", x, point) if point[0] != want { t.Errorf("pixel at %d,600 = %v, want %v", x, point[0], want) } } } // TestImageProcessor_SVGFetchesNothing checks that rendering an SVG draws no // image it refers to: one at a URL on a local server, by href or xlink:href, // or a local file that exists. Each SVG renders without error, white where // the image would be. An SVG that uses an external entity at that server is // a separate case. No SVG makes a request to the server. func TestImageProcessor_SVGFetchesNothing(t *testing.T) { t.Parallel() var requests atomic.Int64 // Blue is 128 in every pixel of this PNG. png := createTestPNG(t, 4, 4) server := httptest.NewServer(http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { requests.Add(1) t.Logf("request for %s", r.URL) w.Header().Set("Content-Type", "image/png") _, _ = w.Write(png) })) t.Cleanup(server.Close) pngPath := filepath.Join(t.TempDir(), "image.png") err := os.WriteFile(pngPath, png, 0o600) if err != nil { t.Fatalf("writing the PNG file: %v", err) } for _, reference := range []string{ `href="` + server.URL + `/image.png"`, `xlink:href="` + server.URL + `/xlink.png"`, `href="file://` + pngPath + `"`, } { svg := `` + `` + `` _, output, err := processSVG(t, svg, imagerequest.Transform{ Format: imagerequest.FormatPNG, }) if err != nil { t.Errorf("SVG with image %s as PNG: %v", reference, err) continue } img, err := vips.NewImageFromBuffer(output) if err != nil { t.Fatalf("decoding the PNG: %v", err) } point, err := img.GetPoint(8, 6) img.Close() if err != nil { t.Fatalf("GetPoint(8, 6): %v", err) } t.Logf("SVG with image %s: pixel at 8,6 = %v", reference, point) if point[2] != 255 { t.Errorf("SVG with image %s: blue at 8,6 = %v, want 255: the "+ "image is drawn", reference, point[2]) } } svg := `` + `]>` + `` + `&outside;` // librsvg loads no external entity, so it refuses this SVG: outside is // not defined. _, _, err = processSVG(t, svg, imagerequest.Transform{ Format: imagerequest.FormatPNG, }) t.Logf("SVG with an external entity as PNG: error = %v", err) if requests.Load() != 0 { t.Errorf("rendering the SVGs made %d requests, want none", requests.Load()) } } // TestImageProcessor_RendersSVGLargerThanMaxInputDimension checks that an // SVG whose own size is above MaxInputDimension is limited only by the // output checks: it is rendered at a raster size, orig of it is the SVG // itself, and a raster format at its own size is ErrSVGTooLargeForOrigSize. // The 1000000x1000000 one is rendered at a ten-thousandth of its own size. func TestImageProcessor_RendersSVGLargerThanMaxInputDimension(t *testing.T) { t.Parallel() for _, svg := range []string{ `` + ``, ``, } { result, _, err := processSVG(t, svg, imagerequest.Transform{ Size: imagerequest.Size{Width: 100, Height: 100}, Format: imagerequest.FormatPNG, }) if err != nil { t.Errorf("%s as PNG at 100x100: %v", svg, err) } else { t.Logf("%s as PNG at 100x100: %dx%d", svg, result.Width, result.Height) if result.Width != 100 || result.Height != 100 { t.Errorf("%s as PNG at 100x100 = %dx%d, want 100x100", svg, result.Width, result.Height) } } result, output, err := processSVG(t, svg, imagerequest.Transform{Format: imagerequest.FormatOriginal}) if err != nil || result.ContentType != imagerequest.FormatSVG.MIMEType() || string(output) != svg { t.Errorf("orig of %s: error = %v, want the SVG itself", svg, err) } _, _, err = processSVG(t, svg, imagerequest.Transform{Format: imagerequest.FormatPNG}) if !errors.Is(err, ErrSVGTooLargeForOrigSize) { t.Errorf("%s as PNG at its own size: error = %v, want %v", svg, err, ErrSVGTooLargeForOrigSize) } } } // TestImageProcessor_RefusesSVGTooThin checks that an SVG with one side more // than MaxInputDimension times the other is ErrSVGTooThin at a raster size, // that one at exactly that proportion is rendered, and that orig of either // is the SVG itself. func TestImageProcessor_RefusesSVGTooThin(t *testing.T) { t.Parallel() tooThin := `` atTheLimit := `` size := imagerequest.Size{Width: 100, Height: 100} _, _, err := processSVG(t, tooThin, imagerequest.Transform{Size: size, Format: imagerequest.FormatPNG}) if !errors.Is(err, ErrSVGTooThin) { t.Errorf("81930x10 SVG as PNG at 100x100: error = %v, want %v", err, ErrSVGTooThin) } result, _, err := processSVG(t, atTheLimit, imagerequest.Transform{Size: size, Format: imagerequest.FormatPNG}) if err != nil { t.Errorf("81920x10 SVG as PNG at 100x100: %v", err) } else if result.Width != 100 || result.Height != 100 { t.Errorf("81920x10 SVG as PNG at 100x100 = %dx%d, want 100x100", result.Width, result.Height) } for _, svg := range []string{tooThin, atTheLimit} { result, output, err := processSVG(t, svg, imagerequest.Transform{Format: imagerequest.FormatOriginal}) if err != nil || result.ContentType != imagerequest.FormatSVG.MIMEType() || string(output) != svg { t.Errorf("orig of %s: error = %v, want the SVG itself", svg, err) } } } // testSVGRenderTimeout is the time limit of the SVG time limit tests, short // so that they finish in a few seconds. Under the race detector a child // process takes about a second to start and read an SVG's size. const testSVGRenderTimeout = 3 * time.Second // processSVGTimed has processor process svg, as an SVG source, with transform, // and returns the error and how long it took. func processSVGTimed( t *testing.T, processor *ImageProcessor, svg string, transform imagerequest.Transform, ) (time.Duration, error) { t.Helper() start := time.Now() _, err := processor.Process(t.Context(), strings.NewReader(svg), imagerequest.FormatSVG, &transform) return time.Since(start), err } // checkProcessesAnotherImage checks that processor, after refusing an SVG, // processes a PNG. func checkProcessesAnotherImage(t *testing.T, processor *ImageProcessor) { t.Helper() _, err := processor.Process(t.Context(), bytes.NewReader(createTestPNG(t, 64, 48)), imagerequest.FormatPNG, &imagerequest.Transform{ Size: imagerequest.Size{Width: 32, Height: 24}, Format: imagerequest.FormatJPEG, }) if err != nil { t.Errorf("a PNG after the SVG: %v", err) } } // TestImageProcessor_StopsSlowSVGRender checks that an SVG that takes about // 12 seconds to render, 600 shapes each drawn with an feTurbulence filter // effect, is ErrSVGRenderFailed soon after a time limit of // testSVGRenderTimeout, not when the render would end. The processor then // processes another image. func TestImageProcessor_StopsSlowSVGRender(t *testing.T) { t.Parallel() svg := `` + `` + `` + strings.Repeat(``, 600) + `` processor := newSVGTestProcessor() processor.svgRenderTimeout = testSVGRenderTimeout elapsed, err := processSVGTimed(t, processor, svg, imagerequest.Transform{ Size: imagerequest.Size{Width: 256, Height: 256}, Format: imagerequest.FormatPNG, }) t.Logf("slow SVG as PNG: error %v after %v", err, elapsed) if !errors.Is(err, ErrSVGRenderFailed) { t.Errorf("slow SVG as PNG: error = %v, want %v", err, ErrSVGRenderFailed) } if elapsed > testSVGRenderTimeout+2*time.Second { t.Errorf("slow SVG as PNG: refused after %v, want soon after %v", elapsed, testSVGRenderTimeout) } checkProcessesAnotherImage(t, processor) } // TestImageProcessor_StopsSlowSVGSizeRead checks that orig of an SVG whose // size takes about 10 seconds to read is ErrSVGRenderFailed soon after a time // limit of testSVGRenderTimeout. To read the size, librsvg matches each of the // SVG's 2500 CSS rules against each of its 2500 shapes, walking up the 75 // groups around it. The processor then processes another image. func TestImageProcessor_StopsSlowSVGSizeRead(t *testing.T) { t.Parallel() var rules strings.Builder for i := range 2500 { fmt.Fprintf(&rules, ".group%d rect { fill: red }", i) } svg := `` + `` + strings.Repeat(``, 75) + strings.Repeat(``, 2500) + strings.Repeat(``, 75) + `` processor := newSVGTestProcessor() processor.svgRenderTimeout = testSVGRenderTimeout elapsed, err := processSVGTimed(t, processor, svg, imagerequest.Transform{Format: imagerequest.FormatOriginal}) t.Logf("orig of the SVG: error %v after %v", err, elapsed) if !errors.Is(err, ErrSVGRenderFailed) { t.Errorf("orig of the SVG: error = %v, want %v", err, ErrSVGRenderFailed) } if elapsed > testSVGRenderTimeout+2*time.Second { t.Errorf("orig of the SVG: refused after %v, want soon after %v", elapsed, testSVGRenderTimeout) } checkProcessesAnotherImage(t, processor) } // TestImageProcessor_StopsSVGRenderOverMemoryLimit checks that an SVG that // needs about 500 MiB more than a child process's own memory to render, a // chain of 2000 blurs that each keep their result, is ErrSVGRenderFailed with // a memory limit of 512 MiB, and that a plain SVG renders under the same // limit. A child process needs about 130 MiB of its own, and about 350 MiB // under the race detector, so the chain also renders under MaxSVGRenderMemory. func TestImageProcessor_StopsSVGRenderOverMemoryLimit(t *testing.T) { t.Parallel() var blurs strings.Builder blurs.WriteString(``) for i := 1; i < 2000; i++ { fmt.Fprintf(&blurs, ``, i-1, i) } svg := `` + `` + blurs.String() + `` + `` size := imagerequest.Size{Width: 256, Height: 256} processor := newSVGTestProcessor() processor.maxSVGRenderMemory = 512 << 20 elapsed, err := processSVGTimed(t, processor, svg, imagerequest.Transform{Size: size, Format: imagerequest.FormatPNG}) t.Logf("SVG of 2000 blurs as PNG: error %v after %v", err, elapsed) if !errors.Is(err, ErrSVGRenderFailed) { t.Errorf("SVG of 2000 blurs as PNG: error = %v, want %v", err, ErrSVGRenderFailed) } _, err = processSVGTimed(t, processor, testSVG, imagerequest.Transform{Size: size, Format: imagerequest.FormatPNG}) if err != nil { t.Errorf("plain SVG as PNG after it: %v", err) } } // TestSVGRenderSize checks the size at which an SVG is rendered to cover an // output size, and that it is held to MaxInputDimension. func TestSVGRenderSize(t *testing.T) { t.Parallel() tests := []struct { name string width, height int outputWidth, outputHeight int wantWidth, wantHeight int }{ {"own size", 16, 12, 16, 12, 16, 12}, {"100 times larger", 16, 12, 1600, 1200, 1600, 1200}, {"wider than the SVG's proportions", 16, 12, 160, 12, 160, 120}, {"10 times smaller", 100, 100, 10, 10, 10, 10}, {"held to MaxInputDimension", 8192, 1, 8192, 8192, 8192, 1}, {"held to MaxInputDimension when enlarged", 1024, 1, 8192, 8192, 8192, 8}, {"far above MaxInputDimension", 1000000, 1000000, 100, 100, 100, 100}, } for _, tt := range tests { gotWidth, gotHeight := svgRenderSize(tt.width, tt.height, tt.outputWidth, tt.outputHeight) if gotWidth != tt.wantWidth || gotHeight != tt.wantHeight { t.Errorf("%s: svgRenderSize(%d, %d, %d, %d) = %dx%d, want %dx%d", tt.name, tt.width, tt.height, tt.outputWidth, tt.outputHeight, gotWidth, gotHeight, tt.wantWidth, tt.wantHeight) } } }