1
0

assets_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. package assets
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/base64"
  6. "encoding/binary"
  7. "hash/crc32"
  8. "image"
  9. "image/color"
  10. "image/gif"
  11. "image/jpeg"
  12. "image/png"
  13. "math/rand"
  14. "net/http"
  15. "net/http/httptest"
  16. "net/url"
  17. "os"
  18. "strings"
  19. "sync"
  20. "testing"
  21. "golang.org/x/net/html"
  22. )
  23. // photo returns a w×h colour image with smooth gradients and noise, which
  24. // uses many gray levels like a photograph.
  25. func photo(w, h int) image.Image {
  26. r := rand.New(rand.NewSource(1))
  27. img := image.NewRGBA(image.Rect(0, 0, w, h))
  28. for y := 0; y < h; y++ {
  29. for x := 0; x < w; x++ {
  30. n := uint8(r.Intn(40))
  31. img.Set(x, y, color.RGBA{uint8(x*200/w) + n, uint8(y*200/h) + n, 120 + n, 255})
  32. }
  33. }
  34. return img
  35. }
  36. // lineArt returns a transparent image with a black frame and a red bar,
  37. // like a diagram exported with a transparent background.
  38. func lineArt(w, h int) image.Image {
  39. img := image.NewNRGBA(image.Rect(0, 0, w, h))
  40. for y := 0; y < h; y++ {
  41. for x := 0; x < w; x++ {
  42. switch {
  43. case x < 4 || y < 4 || x >= w-4 || y >= h-4:
  44. img.Set(x, y, color.NRGBA{0, 0, 0, 255})
  45. case y > h/2-10 && y < h/2+10:
  46. img.Set(x, y, color.NRGBA{220, 30, 30, 255})
  47. }
  48. }
  49. }
  50. return img
  51. }
  52. func encodePNG(img image.Image) []byte {
  53. var b bytes.Buffer
  54. if err := png.Encode(&b, img); err != nil {
  55. panic(err)
  56. }
  57. return b.Bytes()
  58. }
  59. func encodeJPEG(img image.Image) []byte {
  60. var b bytes.Buffer
  61. if err := jpeg.Encode(&b, img, &jpeg.Options{Quality: 90}); err != nil {
  62. panic(err)
  63. }
  64. return b.Bytes()
  65. }
  66. // animatedGIF has a white first frame and a black second frame.
  67. func animatedGIF() []byte {
  68. pal := color.Palette{color.White, color.Black}
  69. frame := func(c uint8) *image.Paletted {
  70. f := image.NewPaletted(image.Rect(0, 0, 50, 50), pal)
  71. for i := range f.Pix {
  72. f.Pix[i] = c
  73. }
  74. f.Pix[0] = 1 - c // one odd pixel so the frame isn't entirely flat
  75. return f
  76. }
  77. var b bytes.Buffer
  78. if err := gif.EncodeAll(&b, &gif.GIF{Image: []*image.Paletted{frame(0), frame(1)}, Delay: []int{10, 10}}); err != nil {
  79. panic(err)
  80. }
  81. return b.Bytes()
  82. }
  83. // pngBomb is a valid PNG header claiming 100000×100000 pixels.
  84. func pngBomb() []byte {
  85. var b bytes.Buffer
  86. b.Write([]byte("\x89PNG\r\n\x1a\n"))
  87. ihdr := make([]byte, 13)
  88. binary.BigEndian.PutUint32(ihdr[0:], 100000)
  89. binary.BigEndian.PutUint32(ihdr[4:], 100000)
  90. ihdr[8], ihdr[9] = 8, 2 // 8-bit RGB
  91. binary.Write(&b, binary.BigEndian, uint32(len(ihdr)))
  92. chunk := append([]byte("IHDR"), ihdr...)
  93. b.Write(chunk)
  94. binary.Write(&b, binary.BigEndian, crc32.ChecksumIEEE(chunk))
  95. return b.Bytes()
  96. }
  97. const svgDoc = `<?xml version="1.0"?>
  98. <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100" width="200" height="100">
  99. <rect x="10" y="10" width="180" height="80" fill="none" stroke="black" stroke-width="4"/>
  100. <circle cx="100" cy="50" r="30" fill="#3366cc"/>
  101. </svg>`
  102. type testServer struct {
  103. *httptest.Server
  104. mu sync.Mutex
  105. requests map[string]int
  106. referers map[string]string
  107. }
  108. type file struct {
  109. contentType string
  110. body []byte
  111. }
  112. // files are the images the test server offers, generated once per run.
  113. var files = sync.OnceValue(func() map[string]file {
  114. webp, err := os.ReadFile("../../testdata/images/gradient.webp")
  115. if err != nil {
  116. panic(err)
  117. }
  118. return map[string]file{
  119. "/photo.jpg": {"image/jpeg", encodeJPEG(photo(3000, 2000))},
  120. "/small.jpg": {"image/jpeg", encodeJPEG(photo(300, 200))},
  121. "/tall.jpg": {"image/jpeg", encodeJPEG(photo(1000, 4000))},
  122. "/diagram.png": {"image/png", encodePNG(lineArt(600, 400))},
  123. "/copy.png": {"image/png", encodePNG(lineArt(600, 400))},
  124. "/anim.gif": {"image/gif", animatedGIF()},
  125. "/drawing.svg": {"image/svg+xml", []byte(svgDoc)},
  126. "/drawing-bin": {"application/octet-stream", []byte(svgDoc)},
  127. "/gradient.webp": {"image/webp", webp},
  128. "/pixel.png": {"image/png", encodePNG(image.NewGray(image.Rect(0, 0, 1, 1)))},
  129. "/bomb.png": {"image/png", pngBomb()},
  130. "/page.html": {"text/html", []byte("<html>not an image</html>")},
  131. "/broken.jpg": {"image/jpeg", []byte("not really a jpeg")},
  132. }
  133. })
  134. func newServer(t *testing.T) *testServer {
  135. t.Helper()
  136. files := files()
  137. s := &testServer{requests: map[string]int{}, referers: map[string]string{}}
  138. s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  139. s.mu.Lock()
  140. s.requests[r.URL.Path]++
  141. s.referers[r.URL.Path] = r.Referer()
  142. s.mu.Unlock()
  143. f, ok := files[r.URL.Path]
  144. if !ok {
  145. http.NotFound(w, r)
  146. return
  147. }
  148. w.Header().Set("Content-Type", f.contentType)
  149. w.Write(f.body)
  150. }))
  151. t.Cleanup(s.Close)
  152. return s
  153. }
  154. type run struct {
  155. images []Image
  156. body string
  157. warnings []string
  158. }
  159. // process runs Process on markup (with {S} replaced by the server URL).
  160. func process(t *testing.T, s *testServer, markup string) run {
  161. t.Helper()
  162. doc, err := html.Parse(strings.NewReader("<body>" + strings.ReplaceAll(markup, "{S}", s.URL) + "</body>"))
  163. if err != nil {
  164. t.Fatal(err)
  165. }
  166. body := doc.FirstChild.LastChild
  167. page, _ := url.Parse(s.URL + "/article")
  168. var r run
  169. p := New(s.Client(), "test-agent")
  170. p.Warn = func(msg string) { r.warnings = append(r.warnings, msg) }
  171. r.images, err = p.Process(context.Background(), body, page)
  172. if err != nil {
  173. t.Fatal(err)
  174. }
  175. var b strings.Builder
  176. for c := body.FirstChild; c != nil; c = c.NextSibling {
  177. html.Render(&b, c)
  178. }
  179. r.body = b.String()
  180. return r
  181. }
  182. func decodeImage(t *testing.T, img Image) image.Image {
  183. t.Helper()
  184. m, format, err := image.Decode(bytes.NewReader(img.Data))
  185. if err != nil {
  186. t.Fatalf("%s doesn't decode: %v", img.Filename, err)
  187. }
  188. if want := strings.TrimPrefix(img.MediaType, "image/"); format != want {
  189. t.Errorf("%s: format %s, media type %s", img.Filename, format, img.MediaType)
  190. }
  191. return m
  192. }
  193. // assertGray fails unless every pixel of m is a shade of gray.
  194. func assertGray(t *testing.T, name string, m image.Image) {
  195. t.Helper()
  196. b := m.Bounds()
  197. for y := b.Min.Y; y < b.Max.Y; y += 7 {
  198. for x := b.Min.X; x < b.Max.X; x += 7 {
  199. r, g, bl, _ := m.At(x, y).RGBA()
  200. if r != g || g != bl {
  201. t.Fatalf("%s: pixel (%d,%d) is not gray: %d,%d,%d", name, x, y, r, g, bl)
  202. }
  203. }
  204. }
  205. }
  206. func TestPhotoBecomesGrayJPEGWithinScreen(t *testing.T) {
  207. s := newServer(t)
  208. r := process(t, s, `<img src="{S}/photo.jpg" alt="A photo">`)
  209. if len(r.images) != 1 {
  210. t.Fatalf("got %d images, want 1; warnings: %v", len(r.images), r.warnings)
  211. }
  212. img := r.images[0]
  213. if img.MediaType != "image/jpeg" || !strings.HasSuffix(img.Filename, ".jpg") {
  214. t.Errorf("photo stored as %s (%s), want JPEG", img.Filename, img.MediaType)
  215. }
  216. m := decodeImage(t, img)
  217. if b := m.Bounds(); b.Dx() != 1404 || b.Dy() != 936 {
  218. t.Errorf("photo is %dx%d, want 1404x936", b.Dx(), b.Dy())
  219. }
  220. if _, ok := m.(*image.Gray); !ok {
  221. t.Errorf("photo decodes as %T, want a grayscale JPEG", m)
  222. }
  223. if want := `<img src="` + img.Filename + `" alt="A photo"/>`; r.body != want {
  224. t.Errorf("body = %q, want %q", r.body, want)
  225. }
  226. if ref := s.referers["/photo.jpg"]; ref != s.URL+"/article" {
  227. t.Errorf("Referer = %q, want the article URL", ref)
  228. }
  229. }
  230. func TestTallImageFitsHeight(t *testing.T) {
  231. r := process(t, newServer(t), `<img src="{S}/tall.jpg">`)
  232. m := decodeImage(t, r.images[0])
  233. if b := m.Bounds(); b.Dx() != 468 || b.Dy() != 1872 {
  234. t.Errorf("tall image is %dx%d, want 468x1872", b.Dx(), b.Dy())
  235. }
  236. }
  237. func TestSmallImageIsNotEnlarged(t *testing.T) {
  238. r := process(t, newServer(t), `<img src="{S}/small.jpg">`)
  239. m := decodeImage(t, r.images[0])
  240. if b := m.Bounds(); b.Dx() != 300 || b.Dy() != 200 {
  241. t.Errorf("small image is %dx%d, want 300x200", b.Dx(), b.Dy())
  242. }
  243. }
  244. func TestLineArtBecomes16LevelPNGOnWhite(t *testing.T) {
  245. r := process(t, newServer(t), `<img src="{S}/diagram.png" alt="Diagram">`)
  246. if len(r.images) != 1 {
  247. t.Fatalf("got %d images; warnings: %v", len(r.images), r.warnings)
  248. }
  249. img := r.images[0]
  250. if img.MediaType != "image/png" {
  251. t.Fatalf("diagram stored as %s, want PNG", img.MediaType)
  252. }
  253. m := decodeImage(t, img)
  254. assertGray(t, img.Filename, m)
  255. pal, ok := m.(*image.Paletted)
  256. if !ok || len(pal.Palette) != 16 {
  257. t.Fatalf("diagram decodes as %T, want a 16-colour paletted PNG", m)
  258. }
  259. // The transparent background must be white, not black.
  260. if r, _, _, _ := m.At(300, 100).RGBA(); r>>8 != 255 {
  261. t.Errorf("transparent area became %d, want white (255)", r>>8)
  262. }
  263. if r, _, _, _ := m.At(1, 1).RGBA(); r>>8 != 0 {
  264. t.Errorf("black frame became %d, want black (0)", r>>8)
  265. }
  266. }
  267. func TestFormatsAreConverted(t *testing.T) {
  268. s := newServer(t)
  269. for _, path := range []string{"/gradient.webp", "/anim.gif", "/drawing.svg", "/drawing-bin"} {
  270. t.Run(path, func(t *testing.T) {
  271. r := process(t, s, `<img src="{S}`+path+`">`)
  272. if len(r.images) != 1 {
  273. t.Fatalf("got %d images; warnings: %v", len(r.images), r.warnings)
  274. }
  275. img := r.images[0]
  276. if img.MediaType != "image/jpeg" && img.MediaType != "image/png" {
  277. t.Errorf("stored as %s, want JPEG or PNG", img.MediaType)
  278. }
  279. assertGray(t, path, decodeImage(t, img))
  280. })
  281. }
  282. }
  283. func TestAnimatedGIFUsesFirstFrame(t *testing.T) {
  284. r := process(t, newServer(t), `<img src="{S}/anim.gif">`)
  285. m := decodeImage(t, r.images[0])
  286. if v, _, _, _ := m.At(25, 25).RGBA(); v>>8 != 255 {
  287. t.Errorf("pixel = %d, want white from the first frame", v>>8)
  288. }
  289. }
  290. func TestSVGIsRasterized(t *testing.T) {
  291. r := process(t, newServer(t), `<img src="{S}/drawing.svg">`)
  292. m := decodeImage(t, r.images[0])
  293. // Drawn at twice its 200×100 size.
  294. if b := m.Bounds(); b.Dx() != 400 || b.Dy() != 200 {
  295. t.Errorf("SVG rendered at %dx%d, want 400x200", b.Dx(), b.Dy())
  296. }
  297. if v, _, _, _ := m.At(200, 100).RGBA(); v>>8 > 200 {
  298. t.Errorf("circle centre is %d, want a dark gray", v>>8)
  299. }
  300. }
  301. func TestDataURLImage(t *testing.T) {
  302. data := base64.StdEncoding.EncodeToString(encodePNG(lineArt(100, 80)))
  303. r := process(t, newServer(t), `<img src="data:image/png;base64,`+data+`" alt="inline">`)
  304. if len(r.images) != 1 || r.images[0].MediaType != "image/png" {
  305. t.Fatalf("images = %+v; warnings: %v", r.images, r.warnings)
  306. }
  307. }
  308. func TestDuplicatesAreStoredOnce(t *testing.T) {
  309. s := newServer(t)
  310. r := process(t, s, `<img src="{S}/diagram.png"><p><img src="{S}/copy.png"></p><img src="{S}/diagram.png">`)
  311. if len(r.images) != 1 {
  312. t.Fatalf("got %d images, want 1 (identical content)", len(r.images))
  313. }
  314. if strings.Count(r.body, r.images[0].Filename) != 3 {
  315. t.Errorf("body = %q, want all three images pointing at %s", r.body, r.images[0].Filename)
  316. }
  317. if n := s.requests["/diagram.png"]; n != 1 {
  318. t.Errorf("diagram.png downloaded %d times, want 1", n)
  319. }
  320. }
  321. func TestUnusableImages(t *testing.T) {
  322. s := newServer(t)
  323. tests := []struct {
  324. name, markup, want, warning string
  325. }{
  326. {"not found, with alt", `<p>See <img src="{S}/missing.jpg" alt="the chart">.</p>`,
  327. `<p>See <span class="missing-image">[Image: the chart]</span>.</p>`, "404 Not Found"},
  328. {"not found, without alt", `<p>See <img src="{S}/missing.jpg">.</p>`, `<p>See .</p>`, "404"},
  329. {"not an image", `<img src="{S}/page.html" alt="x">`, `<span class="missing-image">[Image: x]</span>`, "not an image"},
  330. {"damaged", `<img src="{S}/broken.jpg" alt="x">`, `<span class="missing-image">[Image: x]</span>`, "unsupported or damaged"},
  331. {"decompression bomb", `<img src="{S}/bomb.png" alt="x">`, `<span class="missing-image">[Image: x]</span>`, "too large"},
  332. {"unsupported scheme", `<img src="ftp://example.com/a.png" alt="x">`, `<span class="missing-image">[Image: x]</span>`, "unsupported URL scheme"},
  333. // Local files are never read, whatever form the path takes.
  334. {"file URL", `<img src="file:///etc/hosts" alt="x">`, `<span class="missing-image">[Image: x]</span>`, "unsupported URL scheme"},
  335. {"file URL with host", `<img src="file://localhost/etc/hosts" alt="x">`, `<span class="missing-image">[Image: x]</span>`, "unsupported URL scheme"},
  336. {"absolute path", `<img src="/etc/hosts" alt="x">`, `<span class="missing-image">[Image: x]</span>`, "unsupported URL scheme"},
  337. {"Windows path", `<img src="C:\Windows\win.ini" alt="x">`, `<span class="missing-image">[Image: x]</span>`, "skipped"},
  338. {"UNC path", `<img src="\\server\share\a.png" alt="x">`, `<span class="missing-image">[Image: x]</span>`, "skipped"},
  339. // Tracking pixels are dropped without a warning.
  340. {"tracking pixel", `<p>a<img src="{S}/pixel.png">b</p>`, `<p>ab</p>`, ""},
  341. }
  342. for _, tt := range tests {
  343. t.Run(tt.name, func(t *testing.T) {
  344. r := process(t, s, tt.markup)
  345. if len(r.images) != 0 {
  346. t.Errorf("got %d images, want 0", len(r.images))
  347. }
  348. if r.body != tt.want {
  349. t.Errorf("body = %q, want %q", r.body, tt.want)
  350. }
  351. switch {
  352. case tt.warning == "" && len(r.warnings) != 0:
  353. t.Errorf("warnings = %v, want none", r.warnings)
  354. case tt.warning != "" && (len(r.warnings) != 1 || !strings.Contains(r.warnings[0], tt.warning)):
  355. t.Errorf("warnings = %v, want one containing %q", r.warnings, tt.warning)
  356. }
  357. })
  358. }
  359. }
  360. func TestLargeDownloadsAreRefused(t *testing.T) {
  361. s := newServer(t)
  362. doc, _ := html.Parse(strings.NewReader(`<body><img src="` + s.URL + `/photo.jpg" alt="x"></body>`))
  363. page, _ := url.Parse(s.URL)
  364. var warnings []string
  365. p := New(s.Client(), "test-agent")
  366. p.MaxBytes = 1000
  367. p.Warn = func(msg string) { warnings = append(warnings, msg) }
  368. images, err := p.Process(context.Background(), doc, page)
  369. if err != nil || len(images) != 0 || len(warnings) != 1 || !strings.Contains(warnings[0], "larger than") {
  370. t.Errorf("images = %d, err = %v, warnings = %v", len(images), err, warnings)
  371. }
  372. }
  373. func TestCancelled(t *testing.T) {
  374. s := newServer(t)
  375. doc, _ := html.Parse(strings.NewReader(`<body><img src="` + s.URL + `/photo.jpg"></body>`))
  376. page, _ := url.Parse(s.URL)
  377. ctx, cancel := context.WithCancel(context.Background())
  378. cancel()
  379. if _, err := New(s.Client(), "test-agent").Process(ctx, doc, page); err == nil {
  380. t.Error("Process succeeded after cancellation, want an error")
  381. }
  382. }
  383. func TestDecodeRefusesLocalFiles(t *testing.T) {
  384. page, _ := url.Parse("https://example.com/article")
  385. p := New(http.DefaultClient, "test-agent")
  386. for _, src := range []string{"file:///etc/hosts", "/etc/hosts", `C:\Windows\win.ini`} {
  387. if _, err := p.Decode(context.Background(), src, page); err == nil {
  388. t.Errorf("Decode(%q) succeeded", src)
  389. }
  390. }
  391. }