detached a5fd538

fix(favicon): rasterize SVG favicons instead of rejecting them

Sites whose only icon link is SVG never stored their own favicon, so
subdomains kept showing the parent domain icon. Render SVG with oksvg,
and reject <use> inside <defs> plus deep or oversized trees so cyclic
references cannot overflow the stack.
commit
a5fd538bb80d9af5d7ea2feb3a8573e8c069d8ef
author
brice · · 2026-10-18 06:36 UTC
parent
edd8138
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
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
@@ -133,6 +133,10 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
133133 github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
134134 github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
135135 github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
136+github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
137+github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
138+github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
139+github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
136140 github.com/stretchr/objx v0.7.0 h1:vyXn+M0i636+5W3S8uu9xLCMODOUu4cRJvByx7EjOXo=
137141 github.com/stretchr/objx v0.7.0/go.mod h1:xSzHnPHkdEv7vtgdQej8atlCuU1mdX+ELge8bn0hCsE=
138142 github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
@@ -133,6 +133,10 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
133 github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=133 github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
134 github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=134 github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
135 github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=135 github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
136+github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
137+github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
138+github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
139+github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
136 github.com/stretchr/objx v0.7.0 h1:vyXn+M0i636+5W3S8uu9xLCMODOUu4cRJvByx7EjOXo=140 github.com/stretchr/objx v0.7.0 h1:vyXn+M0i636+5W3S8uu9xLCMODOUu4cRJvByx7EjOXo=
137 github.com/stretchr/objx v0.7.0/go.mod h1:xSzHnPHkdEv7vtgdQej8atlCuU1mdX+ELge8bn0hCsE=141 github.com/stretchr/objx v0.7.0/go.mod h1:xSzHnPHkdEv7vtgdQej8atlCuU1mdX+ELge8bn0hCsE=
138 github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=142 github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
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