detached 513887c

Merge pull request #346 from bnema/feat/webp-favicons

feat(favicon): decode WebP favicons safely
commit
513887caaed76906369620157f1c762fb4b16793
author
bnema · · 2026-07-14 17:26 UTC
committer
GitHub · · 2026-07-14 17:26 UTC
merge of
45b5ee0 + d921a00 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
19 files · +1902 -11
M README.md +3 -2
@@ -134,7 +134,7 @@ Dumber uses Chromium Embedded Framework by default. WebKitGTK is available as a
134134 On Arch Linux, install the CEF runtime with:
135135
136136 ```bash
137-sudo pacman -S cef
137+sudo pacman -S cef libwebp
138138 ```
139139
140140 For hardware video decoding, `cef-vaapi` is also available from the AUR as an optional CEF build.
@@ -179,7 +179,7 @@ CEF OSR frame rate adapts to the active Wayland monitor refresh rate when both `
179179 **Arch Linux:**
180180
181181 ```bash
182-sudo pacman -S cef webkitgtk-6.0 gtk4 gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugin-va
182+sudo pacman -S cef webkitgtk-6.0 gtk4 libwebp gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugin-va
183183 ```
184184
185185 **Debian/Ubuntu:**
@@ -213,6 +213,7 @@ Set `ENV=dev` to use `.dev/dumber/` for config and data instead of XDG paths.
213213 - CEF runtime (default backend)
214214 - WebKitGTK 6.0 development/runtime packages (fallback backend and runtime checks)
215215 - Brotli for compressed systemviews assets
216+- libwebp runtime for WebP favicon decoding (for example `libwebp` on Arch)
216217
217218 Systemviews assets are generated with `go tool templ` and Go's `js/wasm` toolchain; no root Node toolchain is required.
218219
@@ -134,7 +134,7 @@ Dumber uses Chromium Embedded Framework by default. WebKitGTK is available as a
134 On Arch Linux, install the CEF runtime with:134 On Arch Linux, install the CEF runtime with:
135 135
136 ```bash136 ```bash
137-sudo pacman -S cef137+sudo pacman -S cef libwebp
138 ```138 ```
139 139
140 For hardware video decoding, `cef-vaapi` is also available from the AUR as an optional CEF build.140 For hardware video decoding, `cef-vaapi` is also available from the AUR as an optional CEF build.
@@ -179,7 +179,7 @@ CEF OSR frame rate adapts to the active Wayland monitor refresh rate when both `
179 **Arch Linux:**179 **Arch Linux:**
180 180
181 ```bash181 ```bash
182-sudo pacman -S cef webkitgtk-6.0 gtk4 gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugin-va182+sudo pacman -S cef webkitgtk-6.0 gtk4 libwebp gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugin-va
183 ```183 ```
184 184
185 **Debian/Ubuntu:**185 **Debian/Ubuntu:**
@@ -213,6 +213,7 @@ Set `ENV=dev` to use `.dev/dumber/` for config and data instead of XDG paths.
213 - CEF runtime (default backend)213 - CEF runtime (default backend)
214 - WebKitGTK 6.0 development/runtime packages (fallback backend and runtime checks)214 - WebKitGTK 6.0 development/runtime packages (fallback backend and runtime checks)
215 - Brotli for compressed systemviews assets215 - Brotli for compressed systemviews assets
216+- libwebp runtime for WebP favicon decoding (for example `libwebp` on Arch)
216 217
217 Systemviews assets are generated with `go tool templ` and Go's `js/wasm` toolchain; no root Node toolchain is required.218 Systemviews assets are generated with `go tool templ` and Go's `js/wasm` toolchain; no root Node toolchain is required.
218 219
M aur/dumber-browser-bin/.SRCINFO +1 -0
@@ -8,6 +8,7 @@ pkgbase = dumber-browser-bin
88 depends = gtk4
99 depends = cef
1010 depends = webkitgtk-6.0
11+ depends = libwebp
1112 optdepends = gst-plugins-base: Base media codecs
1213 optdepends = gst-plugins-good: Additional media codecs
1314 optdepends = gst-plugins-bad: Extended media codecs
@@ -8,6 +8,7 @@ pkgbase = dumber-browser-bin
8 depends = gtk48 depends = gtk4
9 depends = cef9 depends = cef
10 depends = webkitgtk-6.010 depends = webkitgtk-6.0
11+ depends = libwebp
11 optdepends = gst-plugins-base: Base media codecs12 optdepends = gst-plugins-base: Base media codecs
12 optdepends = gst-plugins-good: Additional media codecs13 optdepends = gst-plugins-good: Additional media codecs
13 optdepends = gst-plugins-bad: Extended media codecs14 optdepends = gst-plugins-bad: Extended media codecs
M aur/dumber-browser-bin/PKGBUILD +1 -1
@@ -6,7 +6,7 @@ pkgdesc="A minimal keyboard-driven browser for tiling WMs"
66 arch=('x86_64')
77 url="https://github.com/bnema/dumber"
88 license=('MIT')
9-depends=('gtk4' 'cef' 'webkitgtk-6.0')
9+depends=('gtk4' 'cef' 'webkitgtk-6.0' 'libwebp')
1010 optdepends=(
1111 'gst-plugins-base: Base media codecs'
1212 'gst-plugins-good: Additional media codecs'
@@ -6,7 +6,7 @@ pkgdesc="A minimal keyboard-driven browser for tiling WMs"
6 arch=('x86_64')6 arch=('x86_64')
7 url="https://github.com/bnema/dumber"7 url="https://github.com/bnema/dumber"
8 license=('MIT')8 license=('MIT')
9-depends=('gtk4' 'cef' 'webkitgtk-6.0')9+depends=('gtk4' 'cef' 'webkitgtk-6.0' 'libwebp')
10 optdepends=(10 optdepends=(
11 'gst-plugins-base: Base media codecs'11 'gst-plugins-base: Base media codecs'
12 'gst-plugins-good: Additional media codecs'12 'gst-plugins-good: Additional media codecs'
M aur/dumber-browser-git/.SRCINFO +1 -0
@@ -11,6 +11,7 @@ pkgbase = dumber-browser-git
1111 depends = gtk4
1212 depends = cef
1313 depends = webkitgtk-6.0
14+ depends = libwebp
1415 optdepends = gst-plugins-base: Base media codecs
1516 optdepends = gst-plugins-good: Additional media codecs
1617 optdepends = gst-plugins-bad: Extended media codecs
@@ -11,6 +11,7 @@ pkgbase = dumber-browser-git
11 depends = gtk411 depends = gtk4
12 depends = cef12 depends = cef
13 depends = webkitgtk-6.013 depends = webkitgtk-6.0
14+ depends = libwebp
14 optdepends = gst-plugins-base: Base media codecs15 optdepends = gst-plugins-base: Base media codecs
15 optdepends = gst-plugins-good: Additional media codecs16 optdepends = gst-plugins-good: Additional media codecs
16 optdepends = gst-plugins-bad: Extended media codecs17 optdepends = gst-plugins-bad: Extended media codecs
M aur/dumber-browser-git/PKGBUILD +1 -1
@@ -7,7 +7,7 @@ arch=('x86_64')
77 url="https://github.com/bnema/dumber"
88 license=('MIT')
99 makedepends=('go>=1.26' 'git' 'brotli')
10-depends=('gtk4' 'cef' 'webkitgtk-6.0')
10+depends=('gtk4' 'cef' 'webkitgtk-6.0' 'libwebp')
1111 optdepends=(
1212 'gst-plugins-base: Base media codecs'
1313 'gst-plugins-good: Additional media codecs'
@@ -7,7 +7,7 @@ arch=('x86_64')
7 url="https://github.com/bnema/dumber"7 url="https://github.com/bnema/dumber"
8 license=('MIT')8 license=('MIT')
9 makedepends=('go>=1.26' 'git' 'brotli')9 makedepends=('go>=1.26' 'git' 'brotli')
10-depends=('gtk4' 'cef' 'webkitgtk-6.0')10+depends=('gtk4' 'cef' 'webkitgtk-6.0' 'libwebp')
11 optdepends=(11 optdepends=(
12 'gst-plugins-base: Base media codecs'12 'gst-plugins-base: Base media codecs'
13 'gst-plugins-good: Additional media codecs'13 'gst-plugins-good: Additional media codecs'
M flatpak/dev.bnema.Dumber.yml +1 -0
@@ -1,6 +1,7 @@
11 id: dev.bnema.Dumber
22 runtime: org.gnome.Platform
33 runtime-version: '49'
4+# org.gnome.Platform provides libwebp at runtime for purego WebP favicon decoding.
45 sdk: org.gnome.Sdk
56 command: dumber
67
@@ -1,6 +1,7 @@
1 id: dev.bnema.Dumber1 id: dev.bnema.Dumber
2 runtime: org.gnome.Platform2 runtime: org.gnome.Platform
3 runtime-version: '49'3 runtime-version: '49'
4+# org.gnome.Platform provides libwebp at runtime for purego WebP favicon decoding.
4 sdk: org.gnome.Sdk5 sdk: org.gnome.Sdk
5 command: dumber6 command: dumber
6 7
M go.mod +1 -0
@@ -10,6 +10,7 @@ require (
1010 github.com/bnema/purego-cef2gtk v0.8.4-0.20260714160309-bbd397409ebe
1111 github.com/bnema/purego-pipewire v0.1.5-0.20260714124450-6504b9cdf02a
1212 github.com/bnema/purego-sqlite v0.1.4-0.20260714131321-3614fe683b9c
13+ github.com/bnema/purego-webp v0.2.1-0.20260714152238-7bc9c402fdf6
1314 github.com/bnema/puregotk v0.7.1-0.20260714170452-0b6b9b901234
1415 github.com/charmbracelet/bubbles v1.0.0
1516 github.com/charmbracelet/bubbletea v1.3.10
@@ -10,6 +10,7 @@ require (
10 github.com/bnema/purego-cef2gtk v0.8.4-0.20260714160309-bbd397409ebe10 github.com/bnema/purego-cef2gtk v0.8.4-0.20260714160309-bbd397409ebe
11 github.com/bnema/purego-pipewire v0.1.5-0.20260714124450-6504b9cdf02a11 github.com/bnema/purego-pipewire v0.1.5-0.20260714124450-6504b9cdf02a
12 github.com/bnema/purego-sqlite v0.1.4-0.20260714131321-3614fe683b9c12 github.com/bnema/purego-sqlite v0.1.4-0.20260714131321-3614fe683b9c
13+ github.com/bnema/purego-webp v0.2.1-0.20260714152238-7bc9c402fdf6
13 github.com/bnema/puregotk v0.7.1-0.20260714170452-0b6b9b90123414 github.com/bnema/puregotk v0.7.1-0.20260714170452-0b6b9b901234
14 github.com/charmbracelet/bubbles v1.0.015 github.com/charmbracelet/bubbles v1.0.0
15 github.com/charmbracelet/bubbletea v1.3.1016 github.com/charmbracelet/bubbletea v1.3.10
M go.sum +2 -0 generated file: collapsed
M internal/application/usecase/favicon_test.go +19 -0
@@ -404,6 +404,25 @@ func TestFaviconObserveConversionErrorDoesNotMutateExistingAssets(t *testing.T)
404404 }
405405 }
406406
407+func TestFaviconObserveUnavailableWebPPreservesCachedAssets(t *testing.T) {
408+ fx := newFaviconFixture(t)
409+ fx.seedOriginal([]byte("old"))
410+ fx.blobs.png["example.com"] = []byte("old-png")
411+ fx.blobs.sized["example.com"] = map[int][]byte{32: []byte("old-32")}
412+ fx.converter.errByContentType = map[string]error{"image/webp": ErrFaviconMiss}
413+
414+ _, err := fx.uc.Observe(context.Background(), "https://example.com", "https://example.com/favicon.webp", []byte("webp"), favicon.SourceEngine, "image/webp")
415+ if !errors.Is(err, ErrFaviconMiss) {
416+ t.Fatalf("Observe err=%v, want favicon miss", err)
417+ }
418+ if string(fx.blobs.original["example.com"]) != "old" || string(fx.blobs.png["example.com"]) != "old-png" || string(fx.blobs.sized["example.com"][32]) != "old-32" {
419+ t.Fatalf("cached assets changed after unavailable WebP decoder")
420+ }
421+ if fx.blobs.writeOriginals != 0 || fx.blobs.removeDerived != 0 || fx.invalidators.calls != 0 {
422+ t.Fatalf("unavailable decoder mutated cache: writes=%d removes=%d invalidations=%d", fx.blobs.writeOriginals, fx.blobs.removeDerived, fx.invalidators.calls)
423+ }
424+}
425+
407426 func TestFaviconEnsureSizedPropagatesReadSizedErrors(t *testing.T) {
408427 fx := newFaviconFixture(t)
409428 fx.seedOriginal([]byte("old"))
@@ -404,6 +404,25 @@ func TestFaviconObserveConversionErrorDoesNotMutateExistingAssets(t *testing.T)
404 }404 }
405 }405 }
406 406
407+func TestFaviconObserveUnavailableWebPPreservesCachedAssets(t *testing.T) {
408+ fx := newFaviconFixture(t)
409+ fx.seedOriginal([]byte("old"))
410+ fx.blobs.png["example.com"] = []byte("old-png")
411+ fx.blobs.sized["example.com"] = map[int][]byte{32: []byte("old-32")}
412+ fx.converter.errByContentType = map[string]error{"image/webp": ErrFaviconMiss}
413+
414+ _, err := fx.uc.Observe(context.Background(), "https://example.com", "https://example.com/favicon.webp", []byte("webp"), favicon.SourceEngine, "image/webp")
415+ if !errors.Is(err, ErrFaviconMiss) {
416+ t.Fatalf("Observe err=%v, want favicon miss", err)
417+ }
418+ if string(fx.blobs.original["example.com"]) != "old" || string(fx.blobs.png["example.com"]) != "old-png" || string(fx.blobs.sized["example.com"][32]) != "old-32" {
419+ t.Fatalf("cached assets changed after unavailable WebP decoder")
420+ }
421+ if fx.blobs.writeOriginals != 0 || fx.blobs.removeDerived != 0 || fx.invalidators.calls != 0 {
422+ t.Fatalf("unavailable decoder mutated cache: writes=%d removes=%d invalidations=%d", fx.blobs.writeOriginals, fx.blobs.removeDerived, fx.invalidators.calls)
423+ }
424+}
425+
407 func TestFaviconEnsureSizedPropagatesReadSizedErrors(t *testing.T) {426 func TestFaviconEnsureSizedPropagatesReadSizedErrors(t *testing.T) {
408 fx := newFaviconFixture(t)427 fx := newFaviconFixture(t)
409 fx.seedOriginal([]byte("old"))428 fx.seedOriginal([]byte("old"))
M internal/infrastructure/favicon/converter.go +19 -7
@@ -16,17 +16,25 @@ import (
1616 )
1717
1818 // ImageConverter converts safe favicon image formats into normalized PNGs.
19-type ImageConverter struct{}
19+type ImageConverter struct {
20+ webp *webPDecoder
21+}
22+
23+func NewImageConverter() *ImageConverter { return &ImageConverter{webp: newDefaultWebPDecoder()} }
2024
21-func NewImageConverter() *ImageConverter { return &ImageConverter{} }
25+// newImageConverterWithWebPRuntime provides an infrastructure test seam while
26+// application ports remain independent of the libwebp implementation.
27+func newImageConverterWithWebPRuntime(runtime webpRuntime) *ImageConverter {
28+ return &ImageConverter{webp: newWebPDecoder(runtime)}
29+}
2230
23-func (*ImageConverter) Convert(ctx context.Context, original []byte, contentType string, sizes []int) (*appport.ConvertedFavicon, error) {
31+func (c *ImageConverter) Convert(ctx context.Context, original []byte, contentType string, sizes []int) (*appport.ConvertedFavicon, error) {
2432 _ = ctx
2533 if len(original) == 0 {
2634 return nil, fmt.Errorf("%w: empty image", appport.ErrFaviconMiss)
2735 }
2836
29- img, err := decodeFaviconImage(original, normalizeContentType(contentType))
37+ img, err := decodeFaviconImage(original, normalizeContentType(contentType), c.webp)
3038 if err != nil {
3139 return nil, err
3240 }
@@ -48,7 +56,7 @@ func (*ImageConverter) Convert(ctx context.Context, original []byte, contentType
4856 return out, nil
4957 }
5058
51-func decodeFaviconImage(data []byte, ct string) (image.Image, error) {
59+func decodeFaviconImage(data []byte, ct string, webp *webPDecoder) (image.Image, error) {
5260 switch ct {
5361 case "image/svg+xml":
5462 return nil, fmt.Errorf("%w: svg favicon conversion is disabled", appport.ErrFaviconMiss)
@@ -59,9 +67,13 @@ func decodeFaviconImage(data []byte, ct string) (image.Image, error) {
5967 }
6068 return img, nil
6169 case "image/webp":
62- return nil, fmt.Errorf("%w: webp favicon conversion is disabled", appport.ErrFaviconMiss)
70+ img, err := webp.Decode(data)
71+ if err != nil {
72+ return nil, fmt.Errorf("%w: decode webp favicon: %w", appport.ErrFaviconMiss, err)
73+ }
74+ return img, nil
6375 }
64- if ct != "" && ct != "image/png" && ct != "image/jpeg" && ct != "image/gif" && ct != "image/webp" {
76+ if ct != "" && ct != "image/png" && ct != "image/jpeg" && ct != "image/gif" {
6577 return nil, fmt.Errorf("%w: unsupported favicon content type %s", appport.ErrFaviconMiss, ct)
6678 }
6779 img, _, err := image.Decode(bytes.NewReader(data))
@@ -16,17 +16,25 @@ import (
16 )16 )
17 17
18 // ImageConverter converts safe favicon image formats into normalized PNGs.18 // ImageConverter converts safe favicon image formats into normalized PNGs.
19-type ImageConverter struct{}19+type ImageConverter struct {
20+ webp *webPDecoder
21+}
22+
23+func NewImageConverter() *ImageConverter { return &ImageConverter{webp: newDefaultWebPDecoder()} }
20 24
21-func NewImageConverter() *ImageConverter { return &ImageConverter{} }25+// newImageConverterWithWebPRuntime provides an infrastructure test seam while
26+// application ports remain independent of the libwebp implementation.
27+func newImageConverterWithWebPRuntime(runtime webpRuntime) *ImageConverter {
28+ return &ImageConverter{webp: newWebPDecoder(runtime)}
29+}
22 30
23-func (*ImageConverter) Convert(ctx context.Context, original []byte, contentType string, sizes []int) (*appport.ConvertedFavicon, error) {31+func (c *ImageConverter) Convert(ctx context.Context, original []byte, contentType string, sizes []int) (*appport.ConvertedFavicon, error) {
24 _ = ctx32 _ = ctx
25 if len(original) == 0 {33 if len(original) == 0 {
26 return nil, fmt.Errorf("%w: empty image", appport.ErrFaviconMiss)34 return nil, fmt.Errorf("%w: empty image", appport.ErrFaviconMiss)
27 }35 }
28 36
29- img, err := decodeFaviconImage(original, normalizeContentType(contentType))37+ img, err := decodeFaviconImage(original, normalizeContentType(contentType), c.webp)
30 if err != nil {38 if err != nil {
31 return nil, err39 return nil, err
32 }40 }
@@ -48,7 +56,7 @@ func (*ImageConverter) Convert(ctx context.Context, original []byte, contentType
48 return out, nil56 return out, nil
49 }57 }
50 58
51-func decodeFaviconImage(data []byte, ct string) (image.Image, error) {59+func decodeFaviconImage(data []byte, ct string, webp *webPDecoder) (image.Image, error) {
52 switch ct {60 switch ct {
53 case "image/svg+xml":61 case "image/svg+xml":
54 return nil, fmt.Errorf("%w: svg favicon conversion is disabled", appport.ErrFaviconMiss)62 return nil, fmt.Errorf("%w: svg favicon conversion is disabled", appport.ErrFaviconMiss)
@@ -59,9 +67,13 @@ func decodeFaviconImage(data []byte, ct string) (image.Image, error) {
59 }67 }
60 return img, nil68 return img, nil
61 case "image/webp":69 case "image/webp":
62- return nil, fmt.Errorf("%w: webp favicon conversion is disabled", appport.ErrFaviconMiss)70+ img, err := webp.Decode(data)
71+ if err != nil {
72+ return nil, fmt.Errorf("%w: decode webp favicon: %w", appport.ErrFaviconMiss, err)
73+ }
74+ return img, nil
63 }75 }
64- if ct != "" && ct != "image/png" && ct != "image/jpeg" && ct != "image/gif" && ct != "image/webp" {76+ if ct != "" && ct != "image/png" && ct != "image/jpeg" && ct != "image/gif" {
65 return nil, fmt.Errorf("%w: unsupported favicon content type %s", appport.ErrFaviconMiss, ct)77 return nil, fmt.Errorf("%w: unsupported favicon content type %s", appport.ErrFaviconMiss, ct)
66 }78 }
67 img, _, err := image.Decode(bytes.NewReader(data))79 img, _, err := image.Decode(bytes.NewReader(data))
A internal/infrastructure/favicon/testdata/alpha.webp +0 -0 binary file: no text diff

binary file: no text diff

A internal/infrastructure/favicon/webp_decoder.go +93 -0
@@ -0,0 +1,93 @@
1+package favicon
2+
3+import (
4+ "errors"
5+ "image"
6+ "math"
7+
8+ "github.com/bnema/purego-webp/libwebp"
9+)
10+
11+const (
12+ // maxWebPInputBytes bounds untrusted compressed favicon input before it is
13+ // handed to libwebp.
14+ maxWebPInputBytes = 5 << 20
15+ // maxWebPDimension and maxWebPPixels bound the final RGBA allocation.
16+ maxWebPDimension = 4096
17+ maxWebPPixels = 4 << 20
18+)
19+
20+var (
21+ errWebPInputTooLarge = errors.New("webp favicon input exceeds size limit")
22+ errWebPImageTooLarge = errors.New("webp favicon dimensions exceed size limit")
23+ errWebPInvalidMetadata = errors.New("invalid webp favicon metadata")
24+)
25+
26+// webpRuntime is the narrow infrastructure seam around the dynamically loaded
27+// libwebp binding. It deliberately exposes only Go values, keeping backend
28+// types out of ports and the domain.
29+type webpRuntime interface {
30+ Info(data []byte) (width, height int, err error)
31+ DecodeRGBAInto(data, pix []byte, stride, width, height int) error
32+}
33+
34+type libwebpRuntime struct{}
35+
36+func (libwebpRuntime) Info(data []byte) (int, int, error) {
37+ width, height, ok, err := libwebp.WebPGetInfo(data)
38+ if err != nil {
39+ return 0, 0, err
40+ }
41+ if !ok {
42+ return 0, 0, errWebPInvalidMetadata
43+ }
44+ return width, height, nil
45+}
46+
47+func (libwebpRuntime) DecodeRGBAInto(data, pix []byte, stride, width, height int) error {
48+ return libwebp.WebPDecodeRGBAIntoWithInfo(data, pix, stride, width, height)
49+}
50+
51+// webPDecoder owns the failure boundary for the optional libwebp runtime.
52+type webPDecoder struct{ runtime webpRuntime }
53+
54+func newWebPDecoder(runtime webpRuntime) *webPDecoder { return &webPDecoder{runtime: runtime} }
55+
56+func newDefaultWebPDecoder() *webPDecoder { return newWebPDecoder(libwebpRuntime{}) }
57+
58+func (d *webPDecoder) Decode(data []byte) (*image.NRGBA, error) {
59+ if len(data) == 0 {
60+ return nil, errWebPInvalidMetadata
61+ }
62+ if len(data) > maxWebPInputBytes {
63+ return nil, errWebPInputTooLarge
64+ }
65+ if d == nil || d.runtime == nil {
66+ return nil, errors.New("webp decoder unavailable")
67+ }
68+ width, height, err := d.runtime.Info(data)
69+ if err != nil {
70+ return nil, err
71+ }
72+ if width <= 0 || height <= 0 {
73+ return nil, errWebPInvalidMetadata
74+ }
75+ if width > maxWebPDimension || height > maxWebPDimension || width > maxWebPPixels/height {
76+ return nil, errWebPImageTooLarge
77+ }
78+ if width > math.MaxInt/4 {
79+ return nil, errWebPImageTooLarge
80+ }
81+ stride := width * 4
82+ if height > math.MaxInt/stride {
83+ return nil, errWebPImageTooLarge
84+ }
85+
86+ // Allocate exactly the destination buffer used by libwebp; no native image
87+ // buffer or second decoded copy crosses this adapter boundary.
88+ img := image.NewNRGBA(image.Rect(0, 0, width, height))
89+ if err := d.runtime.DecodeRGBAInto(data, img.Pix, img.Stride, width, height); err != nil {
90+ return nil, err
91+ }
92+ return img, nil
93+}
@@ -0,0 +1,93 @@
1+package favicon
2+
3+import (
4+ "errors"
5+ "image"
6+ "math"
7+
8+ "github.com/bnema/purego-webp/libwebp"
9+)
10+
11+const (
12+ // maxWebPInputBytes bounds untrusted compressed favicon input before it is
13+ // handed to libwebp.
14+ maxWebPInputBytes = 5 << 20
15+ // maxWebPDimension and maxWebPPixels bound the final RGBA allocation.
16+ maxWebPDimension = 4096
17+ maxWebPPixels = 4 << 20
18+)
19+
20+var (
21+ errWebPInputTooLarge = errors.New("webp favicon input exceeds size limit")
22+ errWebPImageTooLarge = errors.New("webp favicon dimensions exceed size limit")
23+ errWebPInvalidMetadata = errors.New("invalid webp favicon metadata")
24+)
25+
26+// webpRuntime is the narrow infrastructure seam around the dynamically loaded
27+// libwebp binding. It deliberately exposes only Go values, keeping backend
28+// types out of ports and the domain.
29+type webpRuntime interface {
30+ Info(data []byte) (width, height int, err error)
31+ DecodeRGBAInto(data, pix []byte, stride, width, height int) error
32+}
33+
34+type libwebpRuntime struct{}
35+
36+func (libwebpRuntime) Info(data []byte) (int, int, error) {
37+ width, height, ok, err := libwebp.WebPGetInfo(data)
38+ if err != nil {
39+ return 0, 0, err
40+ }
41+ if !ok {
42+ return 0, 0, errWebPInvalidMetadata
43+ }
44+ return width, height, nil
45+}
46+
47+func (libwebpRuntime) DecodeRGBAInto(data, pix []byte, stride, width, height int) error {
48+ return libwebp.WebPDecodeRGBAIntoWithInfo(data, pix, stride, width, height)
49+}
50+
51+// webPDecoder owns the failure boundary for the optional libwebp runtime.
52+type webPDecoder struct{ runtime webpRuntime }
53+
54+func newWebPDecoder(runtime webpRuntime) *webPDecoder { return &webPDecoder{runtime: runtime} }
55+
56+func newDefaultWebPDecoder() *webPDecoder { return newWebPDecoder(libwebpRuntime{}) }
57+
58+func (d *webPDecoder) Decode(data []byte) (*image.NRGBA, error) {
59+ if len(data) == 0 {
60+ return nil, errWebPInvalidMetadata
61+ }
62+ if len(data) > maxWebPInputBytes {
63+ return nil, errWebPInputTooLarge
64+ }
65+ if d == nil || d.runtime == nil {
66+ return nil, errors.New("webp decoder unavailable")
67+ }
68+ width, height, err := d.runtime.Info(data)
69+ if err != nil {
70+ return nil, err
71+ }
72+ if width <= 0 || height <= 0 {
73+ return nil, errWebPInvalidMetadata
74+ }
75+ if width > maxWebPDimension || height > maxWebPDimension || width > maxWebPPixels/height {
76+ return nil, errWebPImageTooLarge
77+ }
78+ if width > math.MaxInt/4 {
79+ return nil, errWebPImageTooLarge
80+ }
81+ stride := width * 4
82+ if height > math.MaxInt/stride {
83+ return nil, errWebPImageTooLarge
84+ }
85+
86+ // Allocate exactly the destination buffer used by libwebp; no native image
87+ // buffer or second decoded copy crosses this adapter boundary.
88+ img := image.NewNRGBA(image.Rect(0, 0, width, height))
89+ if err := d.runtime.DecodeRGBAInto(data, img.Pix, img.Stride, width, height); err != nil {
90+ return nil, err
91+ }
92+ return img, nil
93+}
A internal/infrastructure/favicon/webp_decoder_test.go +92 -0
@@ -0,0 +1,92 @@
1+package favicon
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "image"
8+ "image/color"
9+ "image/png"
10+ "os"
11+ "testing"
12+
13+ appport "github.com/bnema/dumber/internal/application/port"
14+)
15+
16+func TestConverterWebPConvertsAlphaFixture(t *testing.T) {
17+ data, err := os.ReadFile("testdata/alpha.webp")
18+ if err != nil {
19+ t.Fatal(err)
20+ }
21+ conv := NewImageConverter()
22+ out, err := conv.Convert(context.Background(), data, "image/webp", []int{32})
23+ if err != nil {
24+ t.Fatal(err)
25+ }
26+ img, err := png.Decode(bytes.NewReader(out.PNG))
27+ if err != nil {
28+ t.Fatal(err)
29+ }
30+ if got := color.NRGBAModel.Convert(img.At(0, 0)).(color.NRGBA); got.A != 64 || got.R != 255 {
31+ t.Fatalf("alpha pixel = %#v, want red alpha 64", got)
32+ }
33+ if sized, err := png.Decode(bytes.NewReader(out.SizedPNG[32])); err != nil || sized.Bounds() != image.Rect(0, 0, 32, 32) {
34+ t.Fatalf("sized WebP = %v, %v", sized.Bounds(), err)
35+ }
36+}
37+
38+func TestConverterWebPFailuresAreFaviconMisses(t *testing.T) {
39+ for _, tc := range []struct {
40+ name string
41+ data []byte
42+ dec webpRuntime
43+ }{
44+ {name: "truncated", data: []byte("RIFF\x04\x00\x00\x00WEBPVP8 "), dec: &fakeWebPRuntime{infoErr: errors.New("truncated")}},
45+ {name: "unavailable", data: []byte("webp"), dec: &fakeWebPRuntime{infoErr: errors.New("libwebp unavailable")}},
46+ } {
47+ t.Run(tc.name, func(t *testing.T) {
48+ conv := newImageConverterWithWebPRuntime(tc.dec)
49+ if _, err := conv.Convert(context.Background(), tc.data, "image/webp", nil); !errors.Is(err, appport.ErrFaviconMiss) {
50+ t.Fatalf("err = %v, want favicon miss", err)
51+ }
52+ })
53+ }
54+}
55+
56+func TestWebPDecoderRejectsInputAndDecodedLimitsBeforeAllocation(t *testing.T) {
57+ for _, tc := range []struct {
58+ name string
59+ data []byte
60+ width, height int
61+ }{
62+ {name: "input", data: bytes.Repeat([]byte("x"), maxWebPInputBytes+1), width: 1, height: 1},
63+ {name: "dimension", data: []byte("webp"), width: maxWebPDimension + 1, height: 1},
64+ {name: "pixels", data: []byte("webp"), width: maxWebPDimension, height: maxWebPDimension},
65+ } {
66+ t.Run(tc.name, func(t *testing.T) {
67+ runtime := &fakeWebPRuntime{width: tc.width, height: tc.height}
68+ _, err := newWebPDecoder(runtime).Decode(tc.data)
69+ if err == nil {
70+ t.Fatal("Decode succeeded")
71+ }
72+ if runtime.decodeCalls != 0 {
73+ t.Fatalf("decode calls = %d, want no allocation/decode", runtime.decodeCalls)
74+ }
75+ })
76+ }
77+}
78+
79+type fakeWebPRuntime struct {
80+ width, height int
81+ infoErr error
82+ decodeCalls int
83+}
84+
85+func (r *fakeWebPRuntime) Info(_ []byte) (int, int, error) { return r.width, r.height, r.infoErr }
86+func (r *fakeWebPRuntime) DecodeRGBAInto(_, pix []byte, _, _, _ int) error {
87+ r.decodeCalls++
88+ for i := range pix {
89+ pix[i] = 0xff
90+ }
91+ return nil
92+}
@@ -0,0 +1,92 @@
1+package favicon
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "image"
8+ "image/color"
9+ "image/png"
10+ "os"
11+ "testing"
12+
13+ appport "github.com/bnema/dumber/internal/application/port"
14+)
15+
16+func TestConverterWebPConvertsAlphaFixture(t *testing.T) {
17+ data, err := os.ReadFile("testdata/alpha.webp")
18+ if err != nil {
19+ t.Fatal(err)
20+ }
21+ conv := NewImageConverter()
22+ out, err := conv.Convert(context.Background(), data, "image/webp", []int{32})
23+ if err != nil {
24+ t.Fatal(err)
25+ }
26+ img, err := png.Decode(bytes.NewReader(out.PNG))
27+ if err != nil {
28+ t.Fatal(err)
29+ }
30+ if got := color.NRGBAModel.Convert(img.At(0, 0)).(color.NRGBA); got.A != 64 || got.R != 255 {
31+ t.Fatalf("alpha pixel = %#v, want red alpha 64", got)
32+ }
33+ if sized, err := png.Decode(bytes.NewReader(out.SizedPNG[32])); err != nil || sized.Bounds() != image.Rect(0, 0, 32, 32) {
34+ t.Fatalf("sized WebP = %v, %v", sized.Bounds(), err)
35+ }
36+}
37+
38+func TestConverterWebPFailuresAreFaviconMisses(t *testing.T) {
39+ for _, tc := range []struct {
40+ name string
41+ data []byte
42+ dec webpRuntime
43+ }{
44+ {name: "truncated", data: []byte("RIFF\x04\x00\x00\x00WEBPVP8 "), dec: &fakeWebPRuntime{infoErr: errors.New("truncated")}},
45+ {name: "unavailable", data: []byte("webp"), dec: &fakeWebPRuntime{infoErr: errors.New("libwebp unavailable")}},
46+ } {
47+ t.Run(tc.name, func(t *testing.T) {
48+ conv := newImageConverterWithWebPRuntime(tc.dec)
49+ if _, err := conv.Convert(context.Background(), tc.data, "image/webp", nil); !errors.Is(err, appport.ErrFaviconMiss) {
50+ t.Fatalf("err = %v, want favicon miss", err)
51+ }
52+ })
53+ }
54+}
55+
56+func TestWebPDecoderRejectsInputAndDecodedLimitsBeforeAllocation(t *testing.T) {
57+ for _, tc := range []struct {
58+ name string
59+ data []byte
60+ width, height int
61+ }{
62+ {name: "input", data: bytes.Repeat([]byte("x"), maxWebPInputBytes+1), width: 1, height: 1},
63+ {name: "dimension", data: []byte("webp"), width: maxWebPDimension + 1, height: 1},
64+ {name: "pixels", data: []byte("webp"), width: maxWebPDimension, height: maxWebPDimension},
65+ } {
66+ t.Run(tc.name, func(t *testing.T) {
67+ runtime := &fakeWebPRuntime{width: tc.width, height: tc.height}
68+ _, err := newWebPDecoder(runtime).Decode(tc.data)
69+ if err == nil {
70+ t.Fatal("Decode succeeded")
71+ }
72+ if runtime.decodeCalls != 0 {
73+ t.Fatalf("decode calls = %d, want no allocation/decode", runtime.decodeCalls)
74+ }
75+ })
76+ }
77+}
78+
79+type fakeWebPRuntime struct {
80+ width, height int
81+ infoErr error
82+ decodeCalls int
83+}
84+
85+func (r *fakeWebPRuntime) Info(_ []byte) (int, int, error) { return r.width, r.height, r.infoErr }
86+func (r *fakeWebPRuntime) DecodeRGBAInto(_, pix []byte, _, _, _ int) error {
87+ r.decodeCalls++
88+ for i := range pix {
89+ pix[i] = 0xff
90+ }
91+ return nil
92+}
A vendor/github.com/bnema/purego-webp/LICENSE +21 -0 generated file: collapsed
A vendor/github.com/bnema/purego-webp/internal/libwebp/generated_symbols.go +513 -0 generated file: collapsed
A vendor/github.com/bnema/purego-webp/internal/libwebp/loader.go +86 -0
@@ -0,0 +1,86 @@
1+package libwebp
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "runtime"
7+ "sync"
8+
9+ "github.com/bnema/purego"
10+)
11+
12+var (
13+ loadOnce sync.Once
14+ loadErr error
15+)
16+
17+func EnsureLoaded() error {
18+ loadOnce.Do(func() {
19+ h, err := openLib()
20+ if err != nil {
21+ loadErr = err
22+ return
23+ }
24+
25+ if err := registerAll(h); err != nil {
26+ loadErr = err
27+ }
28+ })
29+
30+ return loadErr
31+}
32+
33+func Available() bool {
34+ return EnsureLoaded() == nil
35+}
36+
37+func register(lib uintptr, fnPtr interface{}, symbol string) error {
38+ addr, err := purego.Dlsym(lib, symbol)
39+ if err != nil {
40+ return fmt.Errorf("resolve %s: %w", symbol, err)
41+ }
42+ purego.RegisterFunc(fnPtr, addr)
43+ return nil
44+}
45+
46+// registerOptional resolves symbol from lib and registers fnPtr if found.
47+// Missing symbols are silently ignored; the function pointer is left nil.
48+func registerOptional(lib uintptr, fnPtr interface{}, symbol string) {
49+ addr, err := purego.Dlsym(lib, symbol)
50+ if err != nil {
51+ return
52+ }
53+ purego.RegisterFunc(fnPtr, addr)
54+}
55+
56+// ValidateDecoderConfigAvailable reports whether WebPValidateDecoderConfig
57+// was found in the loaded libwebp. It was added in libwebp 1.6.0 (2025-03).
58+func ValidateDecoderConfigAvailable() bool {
59+ return EnsureLoaded() == nil && xWebPValidateDecoderConfig != nil
60+}
61+
62+func openLib() (uintptr, error) {
63+ var errs []error
64+ for _, name := range candidateLibNames() {
65+ lib, err := purego.Dlopen(name, purego.RTLD_NOW|purego.RTLD_GLOBAL)
66+ if err == nil {
67+ return lib, nil
68+ }
69+ errs = append(errs, fmt.Errorf("%s: %w", name, err))
70+ }
71+
72+ return 0, errors.Join(errs...)
73+}
74+
75+func candidateLibNames() []string {
76+ switch runtime.GOOS {
77+ case "linux":
78+ return []string{"libwebp.so", "libwebp.so.8", "libwebp.so.7", "libwebp.so.6"}
79+ case "darwin":
80+ return []string{"libwebp.dylib"}
81+ case "windows":
82+ return []string{"libwebp.dll", "webp.dll"}
83+ default:
84+ return []string{"libwebp.so"}
85+ }
86+}
@@ -0,0 +1,86 @@
1+package libwebp
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "runtime"
7+ "sync"
8+
9+ "github.com/bnema/purego"
10+)
11+
12+var (
13+ loadOnce sync.Once
14+ loadErr error
15+)
16+
17+func EnsureLoaded() error {
18+ loadOnce.Do(func() {
19+ h, err := openLib()
20+ if err != nil {
21+ loadErr = err
22+ return
23+ }
24+
25+ if err := registerAll(h); err != nil {
26+ loadErr = err
27+ }
28+ })
29+
30+ return loadErr
31+}
32+
33+func Available() bool {
34+ return EnsureLoaded() == nil
35+}
36+
37+func register(lib uintptr, fnPtr interface{}, symbol string) error {
38+ addr, err := purego.Dlsym(lib, symbol)
39+ if err != nil {
40+ return fmt.Errorf("resolve %s: %w", symbol, err)
41+ }
42+ purego.RegisterFunc(fnPtr, addr)
43+ return nil
44+}
45+
46+// registerOptional resolves symbol from lib and registers fnPtr if found.
47+// Missing symbols are silently ignored; the function pointer is left nil.
48+func registerOptional(lib uintptr, fnPtr interface{}, symbol string) {
49+ addr, err := purego.Dlsym(lib, symbol)
50+ if err != nil {
51+ return
52+ }
53+ purego.RegisterFunc(fnPtr, addr)
54+}
55+
56+// ValidateDecoderConfigAvailable reports whether WebPValidateDecoderConfig
57+// was found in the loaded libwebp. It was added in libwebp 1.6.0 (2025-03).
58+func ValidateDecoderConfigAvailable() bool {
59+ return EnsureLoaded() == nil && xWebPValidateDecoderConfig != nil
60+}
61+
62+func openLib() (uintptr, error) {
63+ var errs []error
64+ for _, name := range candidateLibNames() {
65+ lib, err := purego.Dlopen(name, purego.RTLD_NOW|purego.RTLD_GLOBAL)
66+ if err == nil {
67+ return lib, nil
68+ }
69+ errs = append(errs, fmt.Errorf("%s: %w", name, err))
70+ }
71+
72+ return 0, errors.Join(errs...)
73+}
74+
75+func candidateLibNames() []string {
76+ switch runtime.GOOS {
77+ case "linux":
78+ return []string{"libwebp.so", "libwebp.so.8", "libwebp.so.7", "libwebp.so.6"}
79+ case "darwin":
80+ return []string{"libwebp.dylib"}
81+ case "windows":
82+ return []string{"libwebp.dll", "webp.dll"}
83+ default:
84+ return []string{"libwebp.so"}
85+ }
86+}
A vendor/github.com/bnema/purego-webp/internal/libwebp/types.go +164 -0 generated file: collapsed
A vendor/github.com/bnema/purego-webp/libwebp/libwebp.go +880 -0 generated file: collapsed
M vendor/modules.txt +4 -0 generated file: collapsed

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close