package assets import ( "bytes" "context" "encoding/base64" "encoding/binary" "hash/crc32" "image" "image/color" "image/gif" "image/jpeg" "image/png" "math/rand" "net/http" "net/http/httptest" "net/url" "os" "strings" "sync" "testing" "golang.org/x/net/html" ) // photo returns a w×h colour image with smooth gradients and noise, which // uses many gray levels like a photograph. func photo(w, h int) image.Image { r := rand.New(rand.NewSource(1)) img := image.NewRGBA(image.Rect(0, 0, w, h)) for y := 0; y < h; y++ { for x := 0; x < w; x++ { n := uint8(r.Intn(40)) img.Set(x, y, color.RGBA{uint8(x*200/w) + n, uint8(y*200/h) + n, 120 + n, 255}) } } return img } // lineArt returns a transparent image with a black frame and a red bar, // like a diagram exported with a transparent background. func lineArt(w, h int) image.Image { img := image.NewNRGBA(image.Rect(0, 0, w, h)) for y := 0; y < h; y++ { for x := 0; x < w; x++ { switch { case x < 4 || y < 4 || x >= w-4 || y >= h-4: img.Set(x, y, color.NRGBA{0, 0, 0, 255}) case y > h/2-10 && y < h/2+10: img.Set(x, y, color.NRGBA{220, 30, 30, 255}) } } } return img } func encodePNG(img image.Image) []byte { var b bytes.Buffer if err := png.Encode(&b, img); err != nil { panic(err) } return b.Bytes() } func encodeJPEG(img image.Image) []byte { var b bytes.Buffer if err := jpeg.Encode(&b, img, &jpeg.Options{Quality: 90}); err != nil { panic(err) } return b.Bytes() } // animatedGIF has a white first frame and a black second frame. func animatedGIF() []byte { pal := color.Palette{color.White, color.Black} frame := func(c uint8) *image.Paletted { f := image.NewPaletted(image.Rect(0, 0, 50, 50), pal) for i := range f.Pix { f.Pix[i] = c } f.Pix[0] = 1 - c // one odd pixel so the frame isn't entirely flat return f } var b bytes.Buffer if err := gif.EncodeAll(&b, &gif.GIF{Image: []*image.Paletted{frame(0), frame(1)}, Delay: []int{10, 10}}); err != nil { panic(err) } return b.Bytes() } // pngBomb is a valid PNG header claiming 100000×100000 pixels. func pngBomb() []byte { var b bytes.Buffer b.Write([]byte("\x89PNG\r\n\x1a\n")) ihdr := make([]byte, 13) binary.BigEndian.PutUint32(ihdr[0:], 100000) binary.BigEndian.PutUint32(ihdr[4:], 100000) ihdr[8], ihdr[9] = 8, 2 // 8-bit RGB binary.Write(&b, binary.BigEndian, uint32(len(ihdr))) chunk := append([]byte("IHDR"), ihdr...) b.Write(chunk) binary.Write(&b, binary.BigEndian, crc32.ChecksumIEEE(chunk)) return b.Bytes() } const svgDoc = ` ` type testServer struct { *httptest.Server mu sync.Mutex requests map[string]int referers map[string]string } type file struct { contentType string body []byte } // files are the images the test server offers, generated once per run. var files = sync.OnceValue(func() map[string]file { webp, err := os.ReadFile("../../testdata/images/gradient.webp") if err != nil { panic(err) } return map[string]file{ "/photo.jpg": {"image/jpeg", encodeJPEG(photo(3000, 2000))}, "/small.jpg": {"image/jpeg", encodeJPEG(photo(300, 200))}, "/tall.jpg": {"image/jpeg", encodeJPEG(photo(1000, 4000))}, "/diagram.png": {"image/png", encodePNG(lineArt(600, 400))}, "/copy.png": {"image/png", encodePNG(lineArt(600, 400))}, "/anim.gif": {"image/gif", animatedGIF()}, "/drawing.svg": {"image/svg+xml", []byte(svgDoc)}, "/drawing-bin": {"application/octet-stream", []byte(svgDoc)}, "/gradient.webp": {"image/webp", webp}, "/pixel.png": {"image/png", encodePNG(image.NewGray(image.Rect(0, 0, 1, 1)))}, "/bomb.png": {"image/png", pngBomb()}, "/page.html": {"text/html", []byte("not an image")}, "/broken.jpg": {"image/jpeg", []byte("not really a jpeg")}, } }) func newServer(t *testing.T) *testServer { t.Helper() files := files() s := &testServer{requests: map[string]int{}, referers: map[string]string{}} s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s.mu.Lock() s.requests[r.URL.Path]++ s.referers[r.URL.Path] = r.Referer() s.mu.Unlock() f, ok := files[r.URL.Path] if !ok { http.NotFound(w, r) return } w.Header().Set("Content-Type", f.contentType) w.Write(f.body) })) t.Cleanup(s.Close) return s } type run struct { images []Image body string warnings []string } // process runs Process on markup (with {S} replaced by the server URL). func process(t *testing.T, s *testServer, markup string) run { t.Helper() doc, err := html.Parse(strings.NewReader("
" + strings.ReplaceAll(markup, "{S}", s.URL) + "")) if err != nil { t.Fatal(err) } body := doc.FirstChild.LastChild page, _ := url.Parse(s.URL + "/article") var r run p := New(s.Client(), "test-agent") p.Warn = func(msg string) { r.warnings = append(r.warnings, msg) } r.images, err = p.Process(context.Background(), body, page) if err != nil { t.Fatal(err) } var b strings.Builder for c := body.FirstChild; c != nil; c = c.NextSibling { html.Render(&b, c) } r.body = b.String() return r } func decodeImage(t *testing.T, img Image) image.Image { t.Helper() m, format, err := image.Decode(bytes.NewReader(img.Data)) if err != nil { t.Fatalf("%s doesn't decode: %v", img.Filename, err) } if want := strings.TrimPrefix(img.MediaType, "image/"); format != want { t.Errorf("%s: format %s, media type %s", img.Filename, format, img.MediaType) } return m } // assertGray fails unless every pixel of m is a shade of gray. func assertGray(t *testing.T, name string, m image.Image) { t.Helper() b := m.Bounds() for y := b.Min.Y; y < b.Max.Y; y += 7 { for x := b.Min.X; x < b.Max.X; x += 7 { r, g, bl, _ := m.At(x, y).RGBA() if r != g || g != bl { t.Fatalf("%s: pixel (%d,%d) is not gray: %d,%d,%d", name, x, y, r, g, bl) } } } } func TestPhotoBecomesGrayJPEGWithinScreen(t *testing.T) { s := newServer(t) r := process(t, s, `
`)
if len(r.images) != 1 {
t.Fatalf("got %d images, want 1; warnings: %v", len(r.images), r.warnings)
}
img := r.images[0]
if img.MediaType != "image/jpeg" || !strings.HasSuffix(img.Filename, ".jpg") {
t.Errorf("photo stored as %s (%s), want JPEG", img.Filename, img.MediaType)
}
m := decodeImage(t, img)
if b := m.Bounds(); b.Dx() != 1404 || b.Dy() != 936 {
t.Errorf("photo is %dx%d, want 1404x936", b.Dx(), b.Dy())
}
if _, ok := m.(*image.Gray); !ok {
t.Errorf("photo decodes as %T, want a grayscale JPEG", m)
}
if want := `
`)
m := decodeImage(t, r.images[0])
if b := m.Bounds(); b.Dx() != 468 || b.Dy() != 1872 {
t.Errorf("tall image is %dx%d, want 468x1872", b.Dx(), b.Dy())
}
}
func TestSmallImageIsNotEnlarged(t *testing.T) {
r := process(t, newServer(t), `
`)
m := decodeImage(t, r.images[0])
if b := m.Bounds(); b.Dx() != 300 || b.Dy() != 200 {
t.Errorf("small image is %dx%d, want 300x200", b.Dx(), b.Dy())
}
}
func TestLineArtBecomes16LevelPNGOnWhite(t *testing.T) {
r := process(t, newServer(t), `
`)
if len(r.images) != 1 {
t.Fatalf("got %d images; warnings: %v", len(r.images), r.warnings)
}
img := r.images[0]
if img.MediaType != "image/png" {
t.Fatalf("diagram stored as %s, want PNG", img.MediaType)
}
m := decodeImage(t, img)
assertGray(t, img.Filename, m)
pal, ok := m.(*image.Paletted)
if !ok || len(pal.Palette) != 16 {
t.Fatalf("diagram decodes as %T, want a 16-colour paletted PNG", m)
}
// The transparent background must be white, not black.
if r, _, _, _ := m.At(300, 100).RGBA(); r>>8 != 255 {
t.Errorf("transparent area became %d, want white (255)", r>>8)
}
if r, _, _, _ := m.At(1, 1).RGBA(); r>>8 != 0 {
t.Errorf("black frame became %d, want black (0)", r>>8)
}
}
func TestFormatsAreConverted(t *testing.T) {
s := newServer(t)
for _, path := range []string{"/gradient.webp", "/anim.gif", "/drawing.svg", "/drawing-bin"} {
t.Run(path, func(t *testing.T) {
r := process(t, s, `
`)
m := decodeImage(t, r.images[0])
if v, _, _, _ := m.At(25, 25).RGBA(); v>>8 != 255 {
t.Errorf("pixel = %d, want white from the first frame", v>>8)
}
}
func TestSVGIsRasterized(t *testing.T) {
r := process(t, newServer(t), `

`)
if len(r.images) != 1 {
t.Fatalf("got %d images, want 1 (identical content)", len(r.images))
}
if strings.Count(r.body, r.images[0].Filename) != 3 {
t.Errorf("body = %q, want all three images pointing at %s", r.body, r.images[0].Filename)
}
if n := s.requests["/diagram.png"]; n != 1 {
t.Errorf("diagram.png downloaded %d times, want 1", n)
}
}
func TestUnusableImages(t *testing.T) {
s := newServer(t)
tests := []struct {
name, markup, want, warning string
}{
{"not found, with alt", `See
.
See [Image: the chart].
`, "404 Not Found"}, {"not found, without alt", `See
.
See .
`, "404"}, {"not an image", `
`, `[Image: x]`, "unsupported or damaged"},
{"decompression bomb", `
`, `[Image: x]`, "too large"},
{"unsupported scheme", `
`, `[Image: x]`, "unsupported URL scheme"},
// Local files are never read, whatever form the path takes.
{"file URL", `
`, `[Image: x]`, "skipped"},
// Tracking pixels are dropped without a warning.
{"tracking pixel", `a
b
ab
`, ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := process(t, s, tt.markup) if len(r.images) != 0 { t.Errorf("got %d images, want 0", len(r.images)) } if r.body != tt.want { t.Errorf("body = %q, want %q", r.body, tt.want) } switch { case tt.warning == "" && len(r.warnings) != 0: t.Errorf("warnings = %v, want none", r.warnings) case tt.warning != "" && (len(r.warnings) != 1 || !strings.Contains(r.warnings[0], tt.warning)): t.Errorf("warnings = %v, want one containing %q", r.warnings, tt.warning) } }) } } func TestLargeDownloadsAreRefused(t *testing.T) { s := newServer(t) doc, _ := html.Parse(strings.NewReader(`
`))
page, _ := url.Parse(s.URL)
var warnings []string
p := New(s.Client(), "test-agent")
p.MaxBytes = 1000
p.Warn = func(msg string) { warnings = append(warnings, msg) }
images, err := p.Process(context.Background(), doc, page)
if err != nil || len(images) != 0 || len(warnings) != 1 || !strings.Contains(warnings[0], "larger than") {
t.Errorf("images = %d, err = %v, warnings = %v", len(images), err, warnings)
}
}
func TestCancelled(t *testing.T) {
s := newServer(t)
doc, _ := html.Parse(strings.NewReader(`
`))
page, _ := url.Parse(s.URL)
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := New(s.Client(), "test-agent").Process(ctx, doc, page); err == nil {
t.Error("Process succeeded after cancellation, want an error")
}
}
func TestDecodeRefusesLocalFiles(t *testing.T) {
page, _ := url.Parse("https://example.com/article")
p := New(http.DefaultClient, "test-agent")
for _, src := range []string{"file:///etc/hosts", "/etc/hosts", `C:\Windows\win.ini`} {
if _, err := p.Decode(context.Background(), src, page); err == nil {
t.Errorf("Decode(%q) succeeded", src)
}
}
}