detached 57f21d6

Merge pull request 'fix(favicon): rasterize SVG favicons instead of rejecting them' (#14) from fix/favicon-svg-raster into main

Reviewed-on: https://git.bnema.dev/bnema/dumber/pulls/14
commit
57f21d6174df43de322ef72ef137c16eeb22921a
author
bnema · · 2026-10-18 06:50 UTC
merge of
edd8138 + a5fd538 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
refs
tag: v0.33.2
5 files · +175 -2
M go.mod +2 -0
@@ -23,6 +23,8 @@ require (
2323 github.com/spf13/cobra v1.10.2
2424 github.com/spf13/pflag v1.0.10
2525 github.com/spf13/viper v1.21.0
26+ github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
27+ github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
2628 github.com/stretchr/testify v1.12.1
2729 golang.org/x/image v0.47.0
2830 golang.org/x/net v0.61.0
@@ -23,6 +23,8 @@ require (
23 github.com/spf13/cobra v1.10.223 github.com/spf13/cobra v1.10.2
24 github.com/spf13/pflag v1.0.1024 github.com/spf13/pflag v1.0.10
25 github.com/spf13/viper v1.21.025 github.com/spf13/viper v1.21.0
26+ github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
27+ github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
26 github.com/stretchr/testify v1.12.128 github.com/stretchr/testify v1.12.1
27 golang.org/x/image v0.47.029 golang.org/x/image v0.47.0
28 golang.org/x/net v0.61.030 golang.org/x/net v0.61.0
M go.sum +4 -0 generated file: collapsed
M internal/infrastructure/favicon/converter.go +1 -1
@@ -59,7 +59,7 @@ func (c *ImageConverter) Convert(ctx context.Context, original []byte, contentTy
5959 func decodeFaviconImage(data []byte, ct string, webp *webPDecoder) (image.Image, error) {
6060 switch ct {
6161 case "image/svg+xml":
62- return nil, fmt.Errorf("%w: svg favicon conversion is disabled", appport.ErrFaviconMiss)
62+ return rasterizeSVG(data)
6363 case "image/x-icon", "image/vnd.microsoft.icon", "image/ico":
6464 img, err := decodeICO(data)
6565 if err != nil {
@@ -59,7 +59,7 @@ func (c *ImageConverter) Convert(ctx context.Context, original []byte, contentTy
59 func decodeFaviconImage(data []byte, ct string, webp *webPDecoder) (image.Image, error) {59 func decodeFaviconImage(data []byte, ct string, webp *webPDecoder) (image.Image, error) {
60 switch ct {60 switch ct {
61 case "image/svg+xml":61 case "image/svg+xml":
62- return nil, fmt.Errorf("%w: svg favicon conversion is disabled", appport.ErrFaviconMiss)62+ return rasterizeSVG(data)
63 case "image/x-icon", "image/vnd.microsoft.icon", "image/ico":63 case "image/x-icon", "image/vnd.microsoft.icon", "image/ico":
64 img, err := decodeICO(data)64 img, err := decodeICO(data)
65 if err != nil {65 if err != nil {
M internal/infrastructure/favicon/converter_test.go +52 -1
@@ -65,8 +65,8 @@ func TestConverterRejectsFormatsThatWouldProducePlaceholders(t *testing.T) {
6565 name, ct string
6666 data []byte
6767 }{
68- {"svg", "image/svg+xml", []byte(`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><rect width="16" height="16" fill="#ff0000"/></svg>`)},
6968 {"webp", "image/webp", []byte("RIFF\x04\x00\x00\x00WEBPVP8 ")},
69+ {"empty svg", "image/svg+xml", []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"></svg>`)},
7070 } {
7171 t.Run(tc.name, func(t *testing.T) {
7272 if _, err := conv.Convert(context.Background(), tc.data, tc.ct, []int{32}); !errors.Is(err, appport.ErrFaviconMiss) {
@@ -76,6 +76,57 @@ func TestConverterRejectsFormatsThatWouldProducePlaceholders(t *testing.T) {
7676 }
7777 }
7878
79+func TestConverterRasterizesSVG(t *testing.T) {
80+ conv := NewImageConverter()
81+ svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
82+ <rect x="0" y="0" width="32" height="16" fill="#ff0000"/>
83+ <rect x="0" y="16" width="32" height="16" fill="#0000ff"/>
84+</svg>`)
85+ out, err := conv.Convert(context.Background(), svg, "image/svg+xml", []int{32})
86+ if err != nil {
87+ t.Fatal(err)
88+ }
89+ img, err := png.Decode(bytes.NewReader(out.SizedPNG[32]))
90+ if err != nil {
91+ t.Fatal(err)
92+ }
93+ if img.Bounds().Dx() != 32 || img.Bounds().Dy() != 32 {
94+ t.Fatalf("size = %v", img.Bounds())
95+ }
96+ r, _, b, a := img.At(16, 4).RGBA()
97+ if a == 0 || r>>8 < 200 || b>>8 > 50 {
98+ t.Fatalf("top pixel = r%d b%d a%d, want red", r>>8, b>>8, a>>8)
99+ }
100+ r, _, b, a = img.At(16, 28).RGBA()
101+ if a == 0 || b>>8 < 200 || r>>8 > 50 {
102+ t.Fatalf("bottom pixel = r%d b%d a%d, want blue", r>>8, b>>8, a>>8)
103+ }
104+}
105+
106+func TestConverterRejectsRecursiveSVGUse(t *testing.T) {
107+ conv := NewImageConverter()
108+ for _, tc := range []struct{ name, svg string }{
109+ {"self cycle", `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><g id="a"><use href="#a"/></g></defs><use href="#a"/></svg>`},
110+ {"mutual cycle", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 16 16"><defs><g id="a"><use xlink:href="#b"/></g><g id="b"><use xlink:href="#a"/></g></defs><use xlink:href="#a"/></svg>`},
111+ {"use targets use", `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><use id="a" href="#a"/></defs><use href="#a"/></svg>`},
112+ {"flat def run after sibling id", `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><g id="a"></g><use href="#a"/></defs><use href="#a"/></svg>`},
113+ {"anonymous def", `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><use href="#"/></defs><use href="#"/></svg>`},
114+ } {
115+ t.Run(tc.name, func(t *testing.T) {
116+ if _, err := conv.Convert(context.Background(), []byte(tc.svg), "image/svg+xml", []int{32}); !errors.Is(err, appport.ErrFaviconMiss) {
117+ t.Fatalf("err = %v, want favicon miss", err)
118+ }
119+ })
120+ }
121+}
122+
123+func TestConverterRendersFlatSVGUse(t *testing.T) {
124+ svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><rect id="r" width="16" height="16" fill="#00ff00"/></defs><use href="#r"/></svg>`)
125+ if _, err := NewImageConverter().Convert(context.Background(), svg, "image/svg+xml", []int{32}); err != nil {
126+ t.Fatal(err)
127+ }
128+}
129+
79130 func TestConverterMalformedInputsAreMisses(t *testing.T) {
80131 conv := NewImageConverter()
81132 for _, tc := range []struct {
@@ -65,8 +65,8 @@ func TestConverterRejectsFormatsThatWouldProducePlaceholders(t *testing.T) {
65 name, ct string65 name, ct string
66 data []byte66 data []byte
67 }{67 }{
68- {"svg", "image/svg+xml", []byte(`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><rect width="16" height="16" fill="#ff0000"/></svg>`)},
69 {"webp", "image/webp", []byte("RIFF\x04\x00\x00\x00WEBPVP8 ")},68 {"webp", "image/webp", []byte("RIFF\x04\x00\x00\x00WEBPVP8 ")},
69+ {"empty svg", "image/svg+xml", []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"></svg>`)},
70 } {70 } {
71 t.Run(tc.name, func(t *testing.T) {71 t.Run(tc.name, func(t *testing.T) {
72 if _, err := conv.Convert(context.Background(), tc.data, tc.ct, []int{32}); !errors.Is(err, appport.ErrFaviconMiss) {72 if _, err := conv.Convert(context.Background(), tc.data, tc.ct, []int{32}); !errors.Is(err, appport.ErrFaviconMiss) {
@@ -76,6 +76,57 @@ func TestConverterRejectsFormatsThatWouldProducePlaceholders(t *testing.T) {
76 }76 }
77 }77 }
78 78
79+func TestConverterRasterizesSVG(t *testing.T) {
80+ conv := NewImageConverter()
81+ svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
82+ <rect x="0" y="0" width="32" height="16" fill="#ff0000"/>
83+ <rect x="0" y="16" width="32" height="16" fill="#0000ff"/>
84+</svg>`)
85+ out, err := conv.Convert(context.Background(), svg, "image/svg+xml", []int{32})
86+ if err != nil {
87+ t.Fatal(err)
88+ }
89+ img, err := png.Decode(bytes.NewReader(out.SizedPNG[32]))
90+ if err != nil {
91+ t.Fatal(err)
92+ }
93+ if img.Bounds().Dx() != 32 || img.Bounds().Dy() != 32 {
94+ t.Fatalf("size = %v", img.Bounds())
95+ }
96+ r, _, b, a := img.At(16, 4).RGBA()
97+ if a == 0 || r>>8 < 200 || b>>8 > 50 {
98+ t.Fatalf("top pixel = r%d b%d a%d, want red", r>>8, b>>8, a>>8)
99+ }
100+ r, _, b, a = img.At(16, 28).RGBA()
101+ if a == 0 || b>>8 < 200 || r>>8 > 50 {
102+ t.Fatalf("bottom pixel = r%d b%d a%d, want blue", r>>8, b>>8, a>>8)
103+ }
104+}
105+
106+func TestConverterRejectsRecursiveSVGUse(t *testing.T) {
107+ conv := NewImageConverter()
108+ for _, tc := range []struct{ name, svg string }{
109+ {"self cycle", `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><g id="a"><use href="#a"/></g></defs><use href="#a"/></svg>`},
110+ {"mutual cycle", `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 16 16"><defs><g id="a"><use xlink:href="#b"/></g><g id="b"><use xlink:href="#a"/></g></defs><use xlink:href="#a"/></svg>`},
111+ {"use targets use", `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><use id="a" href="#a"/></defs><use href="#a"/></svg>`},
112+ {"flat def run after sibling id", `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><g id="a"></g><use href="#a"/></defs><use href="#a"/></svg>`},
113+ {"anonymous def", `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><use href="#"/></defs><use href="#"/></svg>`},
114+ } {
115+ t.Run(tc.name, func(t *testing.T) {
116+ if _, err := conv.Convert(context.Background(), []byte(tc.svg), "image/svg+xml", []int{32}); !errors.Is(err, appport.ErrFaviconMiss) {
117+ t.Fatalf("err = %v, want favicon miss", err)
118+ }
119+ })
120+ }
121+}
122+
123+func TestConverterRendersFlatSVGUse(t *testing.T) {
124+ svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><rect id="r" width="16" height="16" fill="#00ff00"/></defs><use href="#r"/></svg>`)
125+ if _, err := NewImageConverter().Convert(context.Background(), svg, "image/svg+xml", []int{32}); err != nil {
126+ t.Fatal(err)
127+ }
128+}
129+
79 func TestConverterMalformedInputsAreMisses(t *testing.T) {130 func TestConverterMalformedInputsAreMisses(t *testing.T) {
80 conv := NewImageConverter()131 conv := NewImageConverter()
81 for _, tc := range []struct {132 for _, tc := range []struct {
A internal/infrastructure/favicon/svg.go +116 -0
@@ -0,0 +1,116 @@
1+package favicon
2+
3+import (
4+ "bytes"
5+ "encoding/xml"
6+ "errors"
7+ "fmt"
8+ "image"
9+ "io"
10+
11+ appport "github.com/bnema/dumber/internal/application/port"
12+ "github.com/srwiley/oksvg"
13+ "github.com/srwiley/rasterx"
14+)
15+
16+// svgRasterSize is the square size SVG favicons are rendered at before the
17+// normal PNG normalization and resizing steps.
18+const svgRasterSize = 128
19+
20+// rasterizeSVG renders an SVG favicon to a square RGBA image. The parser only
21+// reads the given bytes: it never resolves external references.
22+func rasterizeSVG(data []byte) (img image.Image, err error) {
23+ // The SVG parser handles untrusted page content; a parser panic must
24+ // degrade to a favicon miss instead of crashing the browser.
25+ defer func() {
26+ if r := recover(); r != nil {
27+ img = nil
28+ err = fmt.Errorf("%w: svg favicon render panic: %v", appport.ErrFaviconMiss, r)
29+ }
30+ }()
31+ trimmed := bytes.TrimSpace(data)
32+ if !bytes.Contains(trimmed, []byte("<svg")) {
33+ return nil, fmt.Errorf("%w: malformed svg", appport.ErrFaviconMiss)
34+ }
35+ if validateErr := validateSVGStructure(trimmed); validateErr != nil {
36+ return nil, validateErr
37+ }
38+ icon, err := oksvg.ReadIconStream(bytes.NewReader(trimmed), oksvg.IgnoreErrorMode)
39+ if err != nil {
40+ return nil, fmt.Errorf("%w: parse svg favicon: %w", appport.ErrFaviconMiss, err)
41+ }
42+ if icon.ViewBox.W <= 0 || icon.ViewBox.H <= 0 {
43+ return nil, fmt.Errorf("%w: svg favicon has no usable size", appport.ErrFaviconMiss)
44+ }
45+ dst := image.NewRGBA(image.Rect(0, 0, svgRasterSize, svgRasterSize))
46+ icon.SetTarget(0, 0, svgRasterSize, svgRasterSize)
47+ scanner := rasterx.NewScannerGV(svgRasterSize, svgRasterSize, dst, dst.Bounds())
48+ icon.Draw(rasterx.NewDasher(svgRasterSize, svgRasterSize, scanner), 1)
49+ if isFullyTransparent(dst) {
50+ return nil, fmt.Errorf("%w: svg favicon rendered empty", appport.ErrFaviconMiss)
51+ }
52+ return dst, nil
53+}
54+
55+const (
56+ // maxSVGUseElements bounds <use> expansion work for one favicon.
57+ maxSVGUseElements = 64
58+ // maxSVGDepth bounds element nesting depth.
59+ maxSVGDepth = 64
60+)
61+
62+// validateSVGStructure rejects SVGs that could make the renderer recurse
63+// without bound. oksvg expands <use> references recursively with no cycle
64+// detection, and a runtime stack overflow cannot be recovered. oksvg only
65+// resolves <use> targets from <defs> content, so forbidding <use> inside
66+// <defs> limits expansion to one level and rules out cycles. The decoder
67+// is strict like oksvg's, so both see the same element tree.
68+func validateSVGStructure(data []byte) error {
69+ dec := xml.NewDecoder(bytes.NewReader(data))
70+ depth, defsDepth, uses := 0, 0, 0
71+ for {
72+ tok, err := dec.Token()
73+ if errors.Is(err, io.EOF) {
74+ break
75+ }
76+ if err != nil {
77+ return fmt.Errorf("%w: malformed svg: %w", appport.ErrFaviconMiss, err)
78+ }
79+ switch t := tok.(type) {
80+ case xml.StartElement:
81+ depth++
82+ if depth > maxSVGDepth {
83+ return fmt.Errorf("%w: svg favicon nested too deeply", appport.ErrFaviconMiss)
84+ }
85+ switch t.Name.Local {
86+ case "defs":
87+ if defsDepth == 0 {
88+ defsDepth = depth
89+ }
90+ case "use":
91+ if defsDepth > 0 {
92+ return fmt.Errorf("%w: svg favicon has <use> inside <defs>", appport.ErrFaviconMiss)
93+ }
94+ uses++
95+ if uses > maxSVGUseElements {
96+ return fmt.Errorf("%w: svg favicon has too many <use> elements", appport.ErrFaviconMiss)
97+ }
98+ }
99+ case xml.EndElement:
100+ if depth == defsDepth {
101+ defsDepth = 0
102+ }
103+ depth--
104+ }
105+ }
106+ return nil
107+}
108+
109+func isFullyTransparent(img *image.RGBA) bool {
110+ for i := 3; i < len(img.Pix); i += 4 {
111+ if img.Pix[i] != 0 {
112+ return false
113+ }
114+ }
115+ return true
116+}
@@ -0,0 +1,116 @@
1+package favicon
2+
3+import (
4+ "bytes"
5+ "encoding/xml"
6+ "errors"
7+ "fmt"
8+ "image"
9+ "io"
10+
11+ appport "github.com/bnema/dumber/internal/application/port"
12+ "github.com/srwiley/oksvg"
13+ "github.com/srwiley/rasterx"
14+)
15+
16+// svgRasterSize is the square size SVG favicons are rendered at before the
17+// normal PNG normalization and resizing steps.
18+const svgRasterSize = 128
19+
20+// rasterizeSVG renders an SVG favicon to a square RGBA image. The parser only
21+// reads the given bytes: it never resolves external references.
22+func rasterizeSVG(data []byte) (img image.Image, err error) {
23+ // The SVG parser handles untrusted page content; a parser panic must
24+ // degrade to a favicon miss instead of crashing the browser.
25+ defer func() {
26+ if r := recover(); r != nil {
27+ img = nil
28+ err = fmt.Errorf("%w: svg favicon render panic: %v", appport.ErrFaviconMiss, r)
29+ }
30+ }()
31+ trimmed := bytes.TrimSpace(data)
32+ if !bytes.Contains(trimmed, []byte("<svg")) {
33+ return nil, fmt.Errorf("%w: malformed svg", appport.ErrFaviconMiss)
34+ }
35+ if validateErr := validateSVGStructure(trimmed); validateErr != nil {
36+ return nil, validateErr
37+ }
38+ icon, err := oksvg.ReadIconStream(bytes.NewReader(trimmed), oksvg.IgnoreErrorMode)
39+ if err != nil {
40+ return nil, fmt.Errorf("%w: parse svg favicon: %w", appport.ErrFaviconMiss, err)
41+ }
42+ if icon.ViewBox.W <= 0 || icon.ViewBox.H <= 0 {
43+ return nil, fmt.Errorf("%w: svg favicon has no usable size", appport.ErrFaviconMiss)
44+ }
45+ dst := image.NewRGBA(image.Rect(0, 0, svgRasterSize, svgRasterSize))
46+ icon.SetTarget(0, 0, svgRasterSize, svgRasterSize)
47+ scanner := rasterx.NewScannerGV(svgRasterSize, svgRasterSize, dst, dst.Bounds())
48+ icon.Draw(rasterx.NewDasher(svgRasterSize, svgRasterSize, scanner), 1)
49+ if isFullyTransparent(dst) {
50+ return nil, fmt.Errorf("%w: svg favicon rendered empty", appport.ErrFaviconMiss)
51+ }
52+ return dst, nil
53+}
54+
55+const (
56+ // maxSVGUseElements bounds <use> expansion work for one favicon.
57+ maxSVGUseElements = 64
58+ // maxSVGDepth bounds element nesting depth.
59+ maxSVGDepth = 64
60+)
61+
62+// validateSVGStructure rejects SVGs that could make the renderer recurse
63+// without bound. oksvg expands <use> references recursively with no cycle
64+// detection, and a runtime stack overflow cannot be recovered. oksvg only
65+// resolves <use> targets from <defs> content, so forbidding <use> inside
66+// <defs> limits expansion to one level and rules out cycles. The decoder
67+// is strict like oksvg's, so both see the same element tree.
68+func validateSVGStructure(data []byte) error {
69+ dec := xml.NewDecoder(bytes.NewReader(data))
70+ depth, defsDepth, uses := 0, 0, 0
71+ for {
72+ tok, err := dec.Token()
73+ if errors.Is(err, io.EOF) {
74+ break
75+ }
76+ if err != nil {
77+ return fmt.Errorf("%w: malformed svg: %w", appport.ErrFaviconMiss, err)
78+ }
79+ switch t := tok.(type) {
80+ case xml.StartElement:
81+ depth++
82+ if depth > maxSVGDepth {
83+ return fmt.Errorf("%w: svg favicon nested too deeply", appport.ErrFaviconMiss)
84+ }
85+ switch t.Name.Local {
86+ case "defs":
87+ if defsDepth == 0 {
88+ defsDepth = depth
89+ }
90+ case "use":
91+ if defsDepth > 0 {
92+ return fmt.Errorf("%w: svg favicon has <use> inside <defs>", appport.ErrFaviconMiss)
93+ }
94+ uses++
95+ if uses > maxSVGUseElements {
96+ return fmt.Errorf("%w: svg favicon has too many <use> elements", appport.ErrFaviconMiss)
97+ }
98+ }
99+ case xml.EndElement:
100+ if depth == defsDepth {
101+ defsDepth = 0
102+ }
103+ depth--
104+ }
105+ }
106+ return nil
107+}
108+
109+func isFullyTransparent(img *image.RGBA) bool {
110+ for i := 3; i < len(img.Pix); i += 4 {
111+ if img.Pix[i] != 0 {
112+ return false
113+ }
114+ }
115+ return true
116+}

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