detached 09043d6

fix(favicon): scope cached icons to exact page paths

commit
09043d62d836bf710b352af2339e38c2f06691b9
author
brice · · 2026-06-10 17:46 UTC
committer
brice · · 2026-06-10 18:51 UTC
parent
9807be4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +1231 -162
M internal/application/port/favicon_fetcher.go +4 -1
@@ -14,10 +14,13 @@ type FaviconFetchRequest struct {
1414 }
1515
1616 // FaviconFetchedIcon contains fetched favicon bytes and their source metadata.
17-// PageURL is the source page, IconURL is the resource URL, Source identifies discovery origin, and ContentType is the MIME type.
17+// PageURL is the source page, IconURL is the resource URL, ResolvedKey optionally
18+// overrides the storage key when a provider is host/domain scoped rather than exact-page scoped,
19+// Source identifies discovery origin, and ContentType is the MIME type.
1820 type FaviconFetchedIcon struct {
1921 PageURL string
2022 IconURL string
23+ ResolvedKey favicon.Key
2124 Bytes []byte
2225 Source favicon.Source
2326 ContentType string
@@ -14,10 +14,13 @@ type FaviconFetchRequest struct {
14 }14 }
15 15
16 // FaviconFetchedIcon contains fetched favicon bytes and their source metadata.16 // FaviconFetchedIcon contains fetched favicon bytes and their source metadata.
17-// PageURL is the source page, IconURL is the resource URL, Source identifies discovery origin, and ContentType is the MIME type.17+// PageURL is the source page, IconURL is the resource URL, ResolvedKey optionally
18+// overrides the storage key when a provider is host/domain scoped rather than exact-page scoped,
19+// Source identifies discovery origin, and ContentType is the MIME type.
18 type FaviconFetchedIcon struct {20 type FaviconFetchedIcon struct {
19 PageURL string21 PageURL string
20 IconURL string22 IconURL string
23+ ResolvedKey favicon.Key
21 Bytes []byte24 Bytes []byte
22 Source favicon.Source25 Source favicon.Source
23 ContentType string26 ContentType string
A internal/application/port/mocks/favicon_resolver.go +469 -0
@@ -0,0 +1,469 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/domain/favicon"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewMockFaviconResolver creates a new instance of MockFaviconResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewMockFaviconResolver(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *MockFaviconResolver {
21+ mock := &MockFaviconResolver{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() { mock.AssertExpectations(t) })
25+
26+ return mock
27+}
28+
29+// MockFaviconResolver is an autogenerated mock type for the FaviconResolver type
30+type MockFaviconResolver struct {
31+ mock.Mock
32+}
33+
34+type MockFaviconResolver_Expecter struct {
35+ mock *mock.Mock
36+}
37+
38+func (_m *MockFaviconResolver) EXPECT() *MockFaviconResolver_Expecter {
39+ return &MockFaviconResolver_Expecter{mock: &_m.Mock}
40+}
41+
42+// EnsureSized provides a mock function for the type MockFaviconResolver
43+func (_mock *MockFaviconResolver) EnsureSized(ctx context.Context, key favicon.Key, size int) error {
44+ ret := _mock.Called(ctx, key, size)
45+
46+ if len(ret) == 0 {
47+ panic("no return value specified for EnsureSized")
48+ }
49+
50+ var r0 error
51+ if returnFunc, ok := ret.Get(0).(func(context.Context, favicon.Key, int) error); ok {
52+ r0 = returnFunc(ctx, key, size)
53+ } else {
54+ r0 = ret.Error(0)
55+ }
56+ return r0
57+}
58+
59+// MockFaviconResolver_EnsureSized_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnsureSized'
60+type MockFaviconResolver_EnsureSized_Call struct {
61+ *mock.Call
62+}
63+
64+// EnsureSized is a helper method to define mock.On call
65+// - ctx context.Context
66+// - key favicon.Key
67+// - size int
68+func (_e *MockFaviconResolver_Expecter) EnsureSized(ctx interface{}, key interface{}, size interface{}) *MockFaviconResolver_EnsureSized_Call {
69+ return &MockFaviconResolver_EnsureSized_Call{Call: _e.mock.On("EnsureSized", ctx, key, size)}
70+}
71+
72+func (_c *MockFaviconResolver_EnsureSized_Call) Run(run func(ctx context.Context, key favicon.Key, size int)) *MockFaviconResolver_EnsureSized_Call {
73+ _c.Call.Run(func(args mock.Arguments) {
74+ var arg0 context.Context
75+ if args[0] != nil {
76+ arg0 = args[0].(context.Context)
77+ }
78+ var arg1 favicon.Key
79+ if args[1] != nil {
80+ arg1 = args[1].(favicon.Key)
81+ }
82+ var arg2 int
83+ if args[2] != nil {
84+ arg2 = args[2].(int)
85+ }
86+ run(
87+ arg0,
88+ arg1,
89+ arg2,
90+ )
91+ })
92+ return _c
93+}
94+
95+func (_c *MockFaviconResolver_EnsureSized_Call) Return(err error) *MockFaviconResolver_EnsureSized_Call {
96+ _c.Call.Return(err)
97+ return _c
98+}
99+
100+func (_c *MockFaviconResolver_EnsureSized_Call) RunAndReturn(run func(ctx context.Context, key favicon.Key, size int) error) *MockFaviconResolver_EnsureSized_Call {
101+ _c.Call.Return(run)
102+ return _c
103+}
104+
105+// Invalidate provides a mock function for the type MockFaviconResolver
106+func (_mock *MockFaviconResolver) Invalidate(ctx context.Context, key favicon.Key) error {
107+ ret := _mock.Called(ctx, key)
108+
109+ if len(ret) == 0 {
110+ panic("no return value specified for Invalidate")
111+ }
112+
113+ var r0 error
114+ if returnFunc, ok := ret.Get(0).(func(context.Context, favicon.Key) error); ok {
115+ r0 = returnFunc(ctx, key)
116+ } else {
117+ r0 = ret.Error(0)
118+ }
119+ return r0
120+}
121+
122+// MockFaviconResolver_Invalidate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Invalidate'
123+type MockFaviconResolver_Invalidate_Call struct {
124+ *mock.Call
125+}
126+
127+// Invalidate is a helper method to define mock.On call
128+// - ctx context.Context
129+// - key favicon.Key
130+func (_e *MockFaviconResolver_Expecter) Invalidate(ctx interface{}, key interface{}) *MockFaviconResolver_Invalidate_Call {
131+ return &MockFaviconResolver_Invalidate_Call{Call: _e.mock.On("Invalidate", ctx, key)}
132+}
133+
134+func (_c *MockFaviconResolver_Invalidate_Call) Run(run func(ctx context.Context, key favicon.Key)) *MockFaviconResolver_Invalidate_Call {
135+ _c.Call.Run(func(args mock.Arguments) {
136+ var arg0 context.Context
137+ if args[0] != nil {
138+ arg0 = args[0].(context.Context)
139+ }
140+ var arg1 favicon.Key
141+ if args[1] != nil {
142+ arg1 = args[1].(favicon.Key)
143+ }
144+ run(
145+ arg0,
146+ arg1,
147+ )
148+ })
149+ return _c
150+}
151+
152+func (_c *MockFaviconResolver_Invalidate_Call) Return(err error) *MockFaviconResolver_Invalidate_Call {
153+ _c.Call.Return(err)
154+ return _c
155+}
156+
157+func (_c *MockFaviconResolver_Invalidate_Call) RunAndReturn(run func(ctx context.Context, key favicon.Key) error) *MockFaviconResolver_Invalidate_Call {
158+ _c.Call.Return(run)
159+ return _c
160+}
161+
162+// Observe provides a mock function for the type MockFaviconResolver
163+func (_mock *MockFaviconResolver) Observe(ctx context.Context, pageURL string, iconURL string, bytes []byte, source favicon.Source, contentType string) (*favicon.Metadata, error) {
164+ ret := _mock.Called(ctx, pageURL, iconURL, bytes, source, contentType)
165+
166+ if len(ret) == 0 {
167+ panic("no return value specified for Observe")
168+ }
169+
170+ var r0 *favicon.Metadata
171+ var r1 error
172+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, []byte, favicon.Source, string) (*favicon.Metadata, error)); ok {
173+ return returnFunc(ctx, pageURL, iconURL, bytes, source, contentType)
174+ }
175+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, []byte, favicon.Source, string) *favicon.Metadata); ok {
176+ r0 = returnFunc(ctx, pageURL, iconURL, bytes, source, contentType)
177+ } else {
178+ if ret.Get(0) != nil {
179+ r0 = ret.Get(0).(*favicon.Metadata)
180+ }
181+ }
182+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, []byte, favicon.Source, string) error); ok {
183+ r1 = returnFunc(ctx, pageURL, iconURL, bytes, source, contentType)
184+ } else {
185+ r1 = ret.Error(1)
186+ }
187+ return r0, r1
188+}
189+
190+// MockFaviconResolver_Observe_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Observe'
191+type MockFaviconResolver_Observe_Call struct {
192+ *mock.Call
193+}
194+
195+// Observe is a helper method to define mock.On call
196+// - ctx context.Context
197+// - pageURL string
198+// - iconURL string
199+// - bytes []byte
200+// - source favicon.Source
201+// - contentType string
202+func (_e *MockFaviconResolver_Expecter) Observe(ctx interface{}, pageURL interface{}, iconURL interface{}, bytes interface{}, source interface{}, contentType interface{}) *MockFaviconResolver_Observe_Call {
203+ return &MockFaviconResolver_Observe_Call{Call: _e.mock.On("Observe", ctx, pageURL, iconURL, bytes, source, contentType)}
204+}
205+
206+func (_c *MockFaviconResolver_Observe_Call) Run(run func(ctx context.Context, pageURL string, iconURL string, bytes []byte, source favicon.Source, contentType string)) *MockFaviconResolver_Observe_Call {
207+ _c.Call.Run(func(args mock.Arguments) {
208+ var arg0 context.Context
209+ if args[0] != nil {
210+ arg0 = args[0].(context.Context)
211+ }
212+ var arg1 string
213+ if args[1] != nil {
214+ arg1 = args[1].(string)
215+ }
216+ var arg2 string
217+ if args[2] != nil {
218+ arg2 = args[2].(string)
219+ }
220+ var arg3 []byte
221+ if args[3] != nil {
222+ arg3 = args[3].([]byte)
223+ }
224+ var arg4 favicon.Source
225+ if args[4] != nil {
226+ arg4 = args[4].(favicon.Source)
227+ }
228+ var arg5 string
229+ if args[5] != nil {
230+ arg5 = args[5].(string)
231+ }
232+ run(
233+ arg0,
234+ arg1,
235+ arg2,
236+ arg3,
237+ arg4,
238+ arg5,
239+ )
240+ })
241+ return _c
242+}
243+
244+func (_c *MockFaviconResolver_Observe_Call) Return(metadata *favicon.Metadata, err error) *MockFaviconResolver_Observe_Call {
245+ _c.Call.Return(metadata, err)
246+ return _c
247+}
248+
249+func (_c *MockFaviconResolver_Observe_Call) RunAndReturn(run func(ctx context.Context, pageURL string, iconURL string, bytes []byte, source favicon.Source, contentType string) (*favicon.Metadata, error)) *MockFaviconResolver_Observe_Call {
250+ _c.Call.Return(run)
251+ return _c
252+}
253+
254+// RefreshFromIconURLs provides a mock function for the type MockFaviconResolver
255+func (_mock *MockFaviconResolver) RefreshFromIconURLs(ctx context.Context, pageURL string, iconURLs []string) error {
256+ ret := _mock.Called(ctx, pageURL, iconURLs)
257+
258+ if len(ret) == 0 {
259+ panic("no return value specified for RefreshFromIconURLs")
260+ }
261+
262+ var r0 error
263+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string) error); ok {
264+ r0 = returnFunc(ctx, pageURL, iconURLs)
265+ } else {
266+ r0 = ret.Error(0)
267+ }
268+ return r0
269+}
270+
271+// MockFaviconResolver_RefreshFromIconURLs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RefreshFromIconURLs'
272+type MockFaviconResolver_RefreshFromIconURLs_Call struct {
273+ *mock.Call
274+}
275+
276+// RefreshFromIconURLs is a helper method to define mock.On call
277+// - ctx context.Context
278+// - pageURL string
279+// - iconURLs []string
280+func (_e *MockFaviconResolver_Expecter) RefreshFromIconURLs(ctx interface{}, pageURL interface{}, iconURLs interface{}) *MockFaviconResolver_RefreshFromIconURLs_Call {
281+ return &MockFaviconResolver_RefreshFromIconURLs_Call{Call: _e.mock.On("RefreshFromIconURLs", ctx, pageURL, iconURLs)}
282+}
283+
284+func (_c *MockFaviconResolver_RefreshFromIconURLs_Call) Run(run func(ctx context.Context, pageURL string, iconURLs []string)) *MockFaviconResolver_RefreshFromIconURLs_Call {
285+ _c.Call.Run(func(args mock.Arguments) {
286+ var arg0 context.Context
287+ if args[0] != nil {
288+ arg0 = args[0].(context.Context)
289+ }
290+ var arg1 string
291+ if args[1] != nil {
292+ arg1 = args[1].(string)
293+ }
294+ var arg2 []string
295+ if args[2] != nil {
296+ arg2 = args[2].([]string)
297+ }
298+ run(
299+ arg0,
300+ arg1,
301+ arg2,
302+ )
303+ })
304+ return _c
305+}
306+
307+func (_c *MockFaviconResolver_RefreshFromIconURLs_Call) Return(err error) *MockFaviconResolver_RefreshFromIconURLs_Call {
308+ _c.Call.Return(err)
309+ return _c
310+}
311+
312+func (_c *MockFaviconResolver_RefreshFromIconURLs_Call) RunAndReturn(run func(ctx context.Context, pageURL string, iconURLs []string) error) *MockFaviconResolver_RefreshFromIconURLs_Call {
313+ _c.Call.Return(run)
314+ return _c
315+}
316+
317+// Resolve provides a mock function for the type MockFaviconResolver
318+func (_mock *MockFaviconResolver) Resolve(ctx context.Context, rawURLOrDomain string, size int, options port.FaviconResolveOptions) (*port.ResolvedFavicon, error) {
319+ ret := _mock.Called(ctx, rawURLOrDomain, size, options)
320+
321+ if len(ret) == 0 {
322+ panic("no return value specified for Resolve")
323+ }
324+
325+ var r0 *port.ResolvedFavicon
326+ var r1 error
327+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int, port.FaviconResolveOptions) (*port.ResolvedFavicon, error)); ok {
328+ return returnFunc(ctx, rawURLOrDomain, size, options)
329+ }
330+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int, port.FaviconResolveOptions) *port.ResolvedFavicon); ok {
331+ r0 = returnFunc(ctx, rawURLOrDomain, size, options)
332+ } else {
333+ if ret.Get(0) != nil {
334+ r0 = ret.Get(0).(*port.ResolvedFavicon)
335+ }
336+ }
337+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, int, port.FaviconResolveOptions) error); ok {
338+ r1 = returnFunc(ctx, rawURLOrDomain, size, options)
339+ } else {
340+ r1 = ret.Error(1)
341+ }
342+ return r0, r1
343+}
344+
345+// MockFaviconResolver_Resolve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Resolve'
346+type MockFaviconResolver_Resolve_Call struct {
347+ *mock.Call
348+}
349+
350+// Resolve is a helper method to define mock.On call
351+// - ctx context.Context
352+// - rawURLOrDomain string
353+// - size int
354+// - options port.FaviconResolveOptions
355+func (_e *MockFaviconResolver_Expecter) Resolve(ctx interface{}, rawURLOrDomain interface{}, size interface{}, options interface{}) *MockFaviconResolver_Resolve_Call {
356+ return &MockFaviconResolver_Resolve_Call{Call: _e.mock.On("Resolve", ctx, rawURLOrDomain, size, options)}
357+}
358+
359+func (_c *MockFaviconResolver_Resolve_Call) Run(run func(ctx context.Context, rawURLOrDomain string, size int, options port.FaviconResolveOptions)) *MockFaviconResolver_Resolve_Call {
360+ _c.Call.Run(func(args mock.Arguments) {
361+ var arg0 context.Context
362+ if args[0] != nil {
363+ arg0 = args[0].(context.Context)
364+ }
365+ var arg1 string
366+ if args[1] != nil {
367+ arg1 = args[1].(string)
368+ }
369+ var arg2 int
370+ if args[2] != nil {
371+ arg2 = args[2].(int)
372+ }
373+ var arg3 port.FaviconResolveOptions
374+ if args[3] != nil {
375+ arg3 = args[3].(port.FaviconResolveOptions)
376+ }
377+ run(
378+ arg0,
379+ arg1,
380+ arg2,
381+ arg3,
382+ )
383+ })
384+ return _c
385+}
386+
387+func (_c *MockFaviconResolver_Resolve_Call) Return(resolvedFavicon *port.ResolvedFavicon, err error) *MockFaviconResolver_Resolve_Call {
388+ _c.Call.Return(resolvedFavicon, err)
389+ return _c
390+}
391+
392+func (_c *MockFaviconResolver_Resolve_Call) RunAndReturn(run func(ctx context.Context, rawURLOrDomain string, size int, options port.FaviconResolveOptions) (*port.ResolvedFavicon, error)) *MockFaviconResolver_Resolve_Call {
393+ _c.Call.Return(run)
394+ return _c
395+}
396+
397+// ResolveSystemviewIcon provides a mock function for the type MockFaviconResolver
398+func (_mock *MockFaviconResolver) ResolveSystemviewIcon(ctx context.Context, rawDomain string, size int) (*port.ResolvedFavicon, error) {
399+ ret := _mock.Called(ctx, rawDomain, size)
400+
401+ if len(ret) == 0 {
402+ panic("no return value specified for ResolveSystemviewIcon")
403+ }
404+
405+ var r0 *port.ResolvedFavicon
406+ var r1 error
407+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int) (*port.ResolvedFavicon, error)); ok {
408+ return returnFunc(ctx, rawDomain, size)
409+ }
410+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int) *port.ResolvedFavicon); ok {
411+ r0 = returnFunc(ctx, rawDomain, size)
412+ } else {
413+ if ret.Get(0) != nil {
414+ r0 = ret.Get(0).(*port.ResolvedFavicon)
415+ }
416+ }
417+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, int) error); ok {
418+ r1 = returnFunc(ctx, rawDomain, size)
419+ } else {
420+ r1 = ret.Error(1)
421+ }
422+ return r0, r1
423+}
424+
425+// MockFaviconResolver_ResolveSystemviewIcon_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveSystemviewIcon'
426+type MockFaviconResolver_ResolveSystemviewIcon_Call struct {
427+ *mock.Call
428+}
429+
430+// ResolveSystemviewIcon is a helper method to define mock.On call
431+// - ctx context.Context
432+// - rawDomain string
433+// - size int
434+func (_e *MockFaviconResolver_Expecter) ResolveSystemviewIcon(ctx interface{}, rawDomain interface{}, size interface{}) *MockFaviconResolver_ResolveSystemviewIcon_Call {
435+ return &MockFaviconResolver_ResolveSystemviewIcon_Call{Call: _e.mock.On("ResolveSystemviewIcon", ctx, rawDomain, size)}
436+}
437+
438+func (_c *MockFaviconResolver_ResolveSystemviewIcon_Call) Run(run func(ctx context.Context, rawDomain string, size int)) *MockFaviconResolver_ResolveSystemviewIcon_Call {
439+ _c.Call.Run(func(args mock.Arguments) {
440+ var arg0 context.Context
441+ if args[0] != nil {
442+ arg0 = args[0].(context.Context)
443+ }
444+ var arg1 string
445+ if args[1] != nil {
446+ arg1 = args[1].(string)
447+ }
448+ var arg2 int
449+ if args[2] != nil {
450+ arg2 = args[2].(int)
451+ }
452+ run(
453+ arg0,
454+ arg1,
455+ arg2,
456+ )
457+ })
458+ return _c
459+}
460+
461+func (_c *MockFaviconResolver_ResolveSystemviewIcon_Call) Return(resolvedFavicon *port.ResolvedFavicon, err error) *MockFaviconResolver_ResolveSystemviewIcon_Call {
462+ _c.Call.Return(resolvedFavicon, err)
463+ return _c
464+}
465+
466+func (_c *MockFaviconResolver_ResolveSystemviewIcon_Call) RunAndReturn(run func(ctx context.Context, rawDomain string, size int) (*port.ResolvedFavicon, error)) *MockFaviconResolver_ResolveSystemviewIcon_Call {
467+ _c.Call.Return(run)
468+ return _c
469+}
@@ -0,0 +1,469 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/domain/favicon"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewMockFaviconResolver creates a new instance of MockFaviconResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewMockFaviconResolver(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *MockFaviconResolver {
21+ mock := &MockFaviconResolver{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() { mock.AssertExpectations(t) })
25+
26+ return mock
27+}
28+
29+// MockFaviconResolver is an autogenerated mock type for the FaviconResolver type
30+type MockFaviconResolver struct {
31+ mock.Mock
32+}
33+
34+type MockFaviconResolver_Expecter struct {
35+ mock *mock.Mock
36+}
37+
38+func (_m *MockFaviconResolver) EXPECT() *MockFaviconResolver_Expecter {
39+ return &MockFaviconResolver_Expecter{mock: &_m.Mock}
40+}
41+
42+// EnsureSized provides a mock function for the type MockFaviconResolver
43+func (_mock *MockFaviconResolver) EnsureSized(ctx context.Context, key favicon.Key, size int) error {
44+ ret := _mock.Called(ctx, key, size)
45+
46+ if len(ret) == 0 {
47+ panic("no return value specified for EnsureSized")
48+ }
49+
50+ var r0 error
51+ if returnFunc, ok := ret.Get(0).(func(context.Context, favicon.Key, int) error); ok {
52+ r0 = returnFunc(ctx, key, size)
53+ } else {
54+ r0 = ret.Error(0)
55+ }
56+ return r0
57+}
58+
59+// MockFaviconResolver_EnsureSized_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnsureSized'
60+type MockFaviconResolver_EnsureSized_Call struct {
61+ *mock.Call
62+}
63+
64+// EnsureSized is a helper method to define mock.On call
65+// - ctx context.Context
66+// - key favicon.Key
67+// - size int
68+func (_e *MockFaviconResolver_Expecter) EnsureSized(ctx interface{}, key interface{}, size interface{}) *MockFaviconResolver_EnsureSized_Call {
69+ return &MockFaviconResolver_EnsureSized_Call{Call: _e.mock.On("EnsureSized", ctx, key, size)}
70+}
71+
72+func (_c *MockFaviconResolver_EnsureSized_Call) Run(run func(ctx context.Context, key favicon.Key, size int)) *MockFaviconResolver_EnsureSized_Call {
73+ _c.Call.Run(func(args mock.Arguments) {
74+ var arg0 context.Context
75+ if args[0] != nil {
76+ arg0 = args[0].(context.Context)
77+ }
78+ var arg1 favicon.Key
79+ if args[1] != nil {
80+ arg1 = args[1].(favicon.Key)
81+ }
82+ var arg2 int
83+ if args[2] != nil {
84+ arg2 = args[2].(int)
85+ }
86+ run(
87+ arg0,
88+ arg1,
89+ arg2,
90+ )
91+ })
92+ return _c
93+}
94+
95+func (_c *MockFaviconResolver_EnsureSized_Call) Return(err error) *MockFaviconResolver_EnsureSized_Call {
96+ _c.Call.Return(err)
97+ return _c
98+}
99+
100+func (_c *MockFaviconResolver_EnsureSized_Call) RunAndReturn(run func(ctx context.Context, key favicon.Key, size int) error) *MockFaviconResolver_EnsureSized_Call {
101+ _c.Call.Return(run)
102+ return _c
103+}
104+
105+// Invalidate provides a mock function for the type MockFaviconResolver
106+func (_mock *MockFaviconResolver) Invalidate(ctx context.Context, key favicon.Key) error {
107+ ret := _mock.Called(ctx, key)
108+
109+ if len(ret) == 0 {
110+ panic("no return value specified for Invalidate")
111+ }
112+
113+ var r0 error
114+ if returnFunc, ok := ret.Get(0).(func(context.Context, favicon.Key) error); ok {
115+ r0 = returnFunc(ctx, key)
116+ } else {
117+ r0 = ret.Error(0)
118+ }
119+ return r0
120+}
121+
122+// MockFaviconResolver_Invalidate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Invalidate'
123+type MockFaviconResolver_Invalidate_Call struct {
124+ *mock.Call
125+}
126+
127+// Invalidate is a helper method to define mock.On call
128+// - ctx context.Context
129+// - key favicon.Key
130+func (_e *MockFaviconResolver_Expecter) Invalidate(ctx interface{}, key interface{}) *MockFaviconResolver_Invalidate_Call {
131+ return &MockFaviconResolver_Invalidate_Call{Call: _e.mock.On("Invalidate", ctx, key)}
132+}
133+
134+func (_c *MockFaviconResolver_Invalidate_Call) Run(run func(ctx context.Context, key favicon.Key)) *MockFaviconResolver_Invalidate_Call {
135+ _c.Call.Run(func(args mock.Arguments) {
136+ var arg0 context.Context
137+ if args[0] != nil {
138+ arg0 = args[0].(context.Context)
139+ }
140+ var arg1 favicon.Key
141+ if args[1] != nil {
142+ arg1 = args[1].(favicon.Key)
143+ }
144+ run(
145+ arg0,
146+ arg1,
147+ )
148+ })
149+ return _c
150+}
151+
152+func (_c *MockFaviconResolver_Invalidate_Call) Return(err error) *MockFaviconResolver_Invalidate_Call {
153+ _c.Call.Return(err)
154+ return _c
155+}
156+
157+func (_c *MockFaviconResolver_Invalidate_Call) RunAndReturn(run func(ctx context.Context, key favicon.Key) error) *MockFaviconResolver_Invalidate_Call {
158+ _c.Call.Return(run)
159+ return _c
160+}
161+
162+// Observe provides a mock function for the type MockFaviconResolver
163+func (_mock *MockFaviconResolver) Observe(ctx context.Context, pageURL string, iconURL string, bytes []byte, source favicon.Source, contentType string) (*favicon.Metadata, error) {
164+ ret := _mock.Called(ctx, pageURL, iconURL, bytes, source, contentType)
165+
166+ if len(ret) == 0 {
167+ panic("no return value specified for Observe")
168+ }
169+
170+ var r0 *favicon.Metadata
171+ var r1 error
172+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, []byte, favicon.Source, string) (*favicon.Metadata, error)); ok {
173+ return returnFunc(ctx, pageURL, iconURL, bytes, source, contentType)
174+ }
175+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, []byte, favicon.Source, string) *favicon.Metadata); ok {
176+ r0 = returnFunc(ctx, pageURL, iconURL, bytes, source, contentType)
177+ } else {
178+ if ret.Get(0) != nil {
179+ r0 = ret.Get(0).(*favicon.Metadata)
180+ }
181+ }
182+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, []byte, favicon.Source, string) error); ok {
183+ r1 = returnFunc(ctx, pageURL, iconURL, bytes, source, contentType)
184+ } else {
185+ r1 = ret.Error(1)
186+ }
187+ return r0, r1
188+}
189+
190+// MockFaviconResolver_Observe_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Observe'
191+type MockFaviconResolver_Observe_Call struct {
192+ *mock.Call
193+}
194+
195+// Observe is a helper method to define mock.On call
196+// - ctx context.Context
197+// - pageURL string
198+// - iconURL string
199+// - bytes []byte
200+// - source favicon.Source
201+// - contentType string
202+func (_e *MockFaviconResolver_Expecter) Observe(ctx interface{}, pageURL interface{}, iconURL interface{}, bytes interface{}, source interface{}, contentType interface{}) *MockFaviconResolver_Observe_Call {
203+ return &MockFaviconResolver_Observe_Call{Call: _e.mock.On("Observe", ctx, pageURL, iconURL, bytes, source, contentType)}
204+}
205+
206+func (_c *MockFaviconResolver_Observe_Call) Run(run func(ctx context.Context, pageURL string, iconURL string, bytes []byte, source favicon.Source, contentType string)) *MockFaviconResolver_Observe_Call {
207+ _c.Call.Run(func(args mock.Arguments) {
208+ var arg0 context.Context
209+ if args[0] != nil {
210+ arg0 = args[0].(context.Context)
211+ }
212+ var arg1 string
213+ if args[1] != nil {
214+ arg1 = args[1].(string)
215+ }
216+ var arg2 string
217+ if args[2] != nil {
218+ arg2 = args[2].(string)
219+ }
220+ var arg3 []byte
221+ if args[3] != nil {
222+ arg3 = args[3].([]byte)
223+ }
224+ var arg4 favicon.Source
225+ if args[4] != nil {
226+ arg4 = args[4].(favicon.Source)
227+ }
228+ var arg5 string
229+ if args[5] != nil {
230+ arg5 = args[5].(string)
231+ }
232+ run(
233+ arg0,
234+ arg1,
235+ arg2,
236+ arg3,
237+ arg4,
238+ arg5,
239+ )
240+ })
241+ return _c
242+}
243+
244+func (_c *MockFaviconResolver_Observe_Call) Return(metadata *favicon.Metadata, err error) *MockFaviconResolver_Observe_Call {
245+ _c.Call.Return(metadata, err)
246+ return _c
247+}
248+
249+func (_c *MockFaviconResolver_Observe_Call) RunAndReturn(run func(ctx context.Context, pageURL string, iconURL string, bytes []byte, source favicon.Source, contentType string) (*favicon.Metadata, error)) *MockFaviconResolver_Observe_Call {
250+ _c.Call.Return(run)
251+ return _c
252+}
253+
254+// RefreshFromIconURLs provides a mock function for the type MockFaviconResolver
255+func (_mock *MockFaviconResolver) RefreshFromIconURLs(ctx context.Context, pageURL string, iconURLs []string) error {
256+ ret := _mock.Called(ctx, pageURL, iconURLs)
257+
258+ if len(ret) == 0 {
259+ panic("no return value specified for RefreshFromIconURLs")
260+ }
261+
262+ var r0 error
263+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string) error); ok {
264+ r0 = returnFunc(ctx, pageURL, iconURLs)
265+ } else {
266+ r0 = ret.Error(0)
267+ }
268+ return r0
269+}
270+
271+// MockFaviconResolver_RefreshFromIconURLs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RefreshFromIconURLs'
272+type MockFaviconResolver_RefreshFromIconURLs_Call struct {
273+ *mock.Call
274+}
275+
276+// RefreshFromIconURLs is a helper method to define mock.On call
277+// - ctx context.Context
278+// - pageURL string
279+// - iconURLs []string
280+func (_e *MockFaviconResolver_Expecter) RefreshFromIconURLs(ctx interface{}, pageURL interface{}, iconURLs interface{}) *MockFaviconResolver_RefreshFromIconURLs_Call {
281+ return &MockFaviconResolver_RefreshFromIconURLs_Call{Call: _e.mock.On("RefreshFromIconURLs", ctx, pageURL, iconURLs)}
282+}
283+
284+func (_c *MockFaviconResolver_RefreshFromIconURLs_Call) Run(run func(ctx context.Context, pageURL string, iconURLs []string)) *MockFaviconResolver_RefreshFromIconURLs_Call {
285+ _c.Call.Run(func(args mock.Arguments) {
286+ var arg0 context.Context
287+ if args[0] != nil {
288+ arg0 = args[0].(context.Context)
289+ }
290+ var arg1 string
291+ if args[1] != nil {
292+ arg1 = args[1].(string)
293+ }
294+ var arg2 []string
295+ if args[2] != nil {
296+ arg2 = args[2].([]string)
297+ }
298+ run(
299+ arg0,
300+ arg1,
301+ arg2,
302+ )
303+ })
304+ return _c
305+}
306+
307+func (_c *MockFaviconResolver_RefreshFromIconURLs_Call) Return(err error) *MockFaviconResolver_RefreshFromIconURLs_Call {
308+ _c.Call.Return(err)
309+ return _c
310+}
311+
312+func (_c *MockFaviconResolver_RefreshFromIconURLs_Call) RunAndReturn(run func(ctx context.Context, pageURL string, iconURLs []string) error) *MockFaviconResolver_RefreshFromIconURLs_Call {
313+ _c.Call.Return(run)
314+ return _c
315+}
316+
317+// Resolve provides a mock function for the type MockFaviconResolver
318+func (_mock *MockFaviconResolver) Resolve(ctx context.Context, rawURLOrDomain string, size int, options port.FaviconResolveOptions) (*port.ResolvedFavicon, error) {
319+ ret := _mock.Called(ctx, rawURLOrDomain, size, options)
320+
321+ if len(ret) == 0 {
322+ panic("no return value specified for Resolve")
323+ }
324+
325+ var r0 *port.ResolvedFavicon
326+ var r1 error
327+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int, port.FaviconResolveOptions) (*port.ResolvedFavicon, error)); ok {
328+ return returnFunc(ctx, rawURLOrDomain, size, options)
329+ }
330+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int, port.FaviconResolveOptions) *port.ResolvedFavicon); ok {
331+ r0 = returnFunc(ctx, rawURLOrDomain, size, options)
332+ } else {
333+ if ret.Get(0) != nil {
334+ r0 = ret.Get(0).(*port.ResolvedFavicon)
335+ }
336+ }
337+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, int, port.FaviconResolveOptions) error); ok {
338+ r1 = returnFunc(ctx, rawURLOrDomain, size, options)
339+ } else {
340+ r1 = ret.Error(1)
341+ }
342+ return r0, r1
343+}
344+
345+// MockFaviconResolver_Resolve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Resolve'
346+type MockFaviconResolver_Resolve_Call struct {
347+ *mock.Call
348+}
349+
350+// Resolve is a helper method to define mock.On call
351+// - ctx context.Context
352+// - rawURLOrDomain string
353+// - size int
354+// - options port.FaviconResolveOptions
355+func (_e *MockFaviconResolver_Expecter) Resolve(ctx interface{}, rawURLOrDomain interface{}, size interface{}, options interface{}) *MockFaviconResolver_Resolve_Call {
356+ return &MockFaviconResolver_Resolve_Call{Call: _e.mock.On("Resolve", ctx, rawURLOrDomain, size, options)}
357+}
358+
359+func (_c *MockFaviconResolver_Resolve_Call) Run(run func(ctx context.Context, rawURLOrDomain string, size int, options port.FaviconResolveOptions)) *MockFaviconResolver_Resolve_Call {
360+ _c.Call.Run(func(args mock.Arguments) {
361+ var arg0 context.Context
362+ if args[0] != nil {
363+ arg0 = args[0].(context.Context)
364+ }
365+ var arg1 string
366+ if args[1] != nil {
367+ arg1 = args[1].(string)
368+ }
369+ var arg2 int
370+ if args[2] != nil {
371+ arg2 = args[2].(int)
372+ }
373+ var arg3 port.FaviconResolveOptions
374+ if args[3] != nil {
375+ arg3 = args[3].(port.FaviconResolveOptions)
376+ }
377+ run(
378+ arg0,
379+ arg1,
380+ arg2,
381+ arg3,
382+ )
383+ })
384+ return _c
385+}
386+
387+func (_c *MockFaviconResolver_Resolve_Call) Return(resolvedFavicon *port.ResolvedFavicon, err error) *MockFaviconResolver_Resolve_Call {
388+ _c.Call.Return(resolvedFavicon, err)
389+ return _c
390+}
391+
392+func (_c *MockFaviconResolver_Resolve_Call) RunAndReturn(run func(ctx context.Context, rawURLOrDomain string, size int, options port.FaviconResolveOptions) (*port.ResolvedFavicon, error)) *MockFaviconResolver_Resolve_Call {
393+ _c.Call.Return(run)
394+ return _c
395+}
396+
397+// ResolveSystemviewIcon provides a mock function for the type MockFaviconResolver
398+func (_mock *MockFaviconResolver) ResolveSystemviewIcon(ctx context.Context, rawDomain string, size int) (*port.ResolvedFavicon, error) {
399+ ret := _mock.Called(ctx, rawDomain, size)
400+
401+ if len(ret) == 0 {
402+ panic("no return value specified for ResolveSystemviewIcon")
403+ }
404+
405+ var r0 *port.ResolvedFavicon
406+ var r1 error
407+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int) (*port.ResolvedFavicon, error)); ok {
408+ return returnFunc(ctx, rawDomain, size)
409+ }
410+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int) *port.ResolvedFavicon); ok {
411+ r0 = returnFunc(ctx, rawDomain, size)
412+ } else {
413+ if ret.Get(0) != nil {
414+ r0 = ret.Get(0).(*port.ResolvedFavicon)
415+ }
416+ }
417+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, int) error); ok {
418+ r1 = returnFunc(ctx, rawDomain, size)
419+ } else {
420+ r1 = ret.Error(1)
421+ }
422+ return r0, r1
423+}
424+
425+// MockFaviconResolver_ResolveSystemviewIcon_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveSystemviewIcon'
426+type MockFaviconResolver_ResolveSystemviewIcon_Call struct {
427+ *mock.Call
428+}
429+
430+// ResolveSystemviewIcon is a helper method to define mock.On call
431+// - ctx context.Context
432+// - rawDomain string
433+// - size int
434+func (_e *MockFaviconResolver_Expecter) ResolveSystemviewIcon(ctx interface{}, rawDomain interface{}, size interface{}) *MockFaviconResolver_ResolveSystemviewIcon_Call {
435+ return &MockFaviconResolver_ResolveSystemviewIcon_Call{Call: _e.mock.On("ResolveSystemviewIcon", ctx, rawDomain, size)}
436+}
437+
438+func (_c *MockFaviconResolver_ResolveSystemviewIcon_Call) Run(run func(ctx context.Context, rawDomain string, size int)) *MockFaviconResolver_ResolveSystemviewIcon_Call {
439+ _c.Call.Run(func(args mock.Arguments) {
440+ var arg0 context.Context
441+ if args[0] != nil {
442+ arg0 = args[0].(context.Context)
443+ }
444+ var arg1 string
445+ if args[1] != nil {
446+ arg1 = args[1].(string)
447+ }
448+ var arg2 int
449+ if args[2] != nil {
450+ arg2 = args[2].(int)
451+ }
452+ run(
453+ arg0,
454+ arg1,
455+ arg2,
456+ )
457+ })
458+ return _c
459+}
460+
461+func (_c *MockFaviconResolver_ResolveSystemviewIcon_Call) Return(resolvedFavicon *port.ResolvedFavicon, err error) *MockFaviconResolver_ResolveSystemviewIcon_Call {
462+ _c.Call.Return(resolvedFavicon, err)
463+ return _c
464+}
465+
466+func (_c *MockFaviconResolver_ResolveSystemviewIcon_Call) RunAndReturn(run func(ctx context.Context, rawDomain string, size int) (*port.ResolvedFavicon, error)) *MockFaviconResolver_ResolveSystemviewIcon_Call {
467+ _c.Call.Return(run)
468+ return _c
469+}
M internal/application/usecase/favicon.go +38 -2
@@ -230,6 +230,20 @@ func (uc *FaviconUseCase) Observe(
230230 if !ok || len(bytes) == 0 {
231231 return nil, ErrFaviconMiss
232232 }
233+ return uc.observeKey(ctx, key, pageURL, iconURL, bytes, source, contentType)
234+}
235+
236+func (uc *FaviconUseCase) observeKey(
237+ ctx context.Context,
238+ key favicon.Key,
239+ pageURL, iconURL string,
240+ bytes []byte,
241+ source favicon.Source,
242+ contentType string,
243+) (*favicon.Metadata, error) {
244+ if key == "" || len(bytes) == 0 {
245+ return nil, ErrFaviconMiss
246+ }
233247 old, err := uc.repo.Get(ctx, key)
234248 if err != nil {
235249 return nil, err
@@ -310,7 +324,7 @@ func (uc *FaviconUseCase) RefreshFromIconURLs(ctx context.Context, pageURL strin
310324 if err != nil {
311325 return err
312326 }
313- _, err = uc.Observe(ctx, fetched.PageURL, fetched.IconURL, fetched.Bytes, fetched.Source, fetched.ContentType)
327+ err = uc.observeFetched(ctx, pageURL, fetched)
314328 if errors.Is(err, ErrFaviconMiss) {
315329 lastMiss = err
316330 continue
@@ -372,7 +386,29 @@ func (uc *FaviconUseCase) fetchAndObserve(ctx context.Context, pageURL string) e
372386 if err != nil {
373387 return err
374388 }
375- _, err = uc.Observe(ctx, fetched.PageURL, fetched.IconURL, fetched.Bytes, fetched.Source, fetched.ContentType)
389+ err = uc.observeFetched(ctx, pageURL, fetched)
390+ return err
391+}
392+
393+func (uc *FaviconUseCase) observeFetched(ctx context.Context, requestedPageURL string, fetched *appport.FaviconFetchedIcon) error {
394+ if fetched == nil {
395+ return ErrFaviconMiss
396+ }
397+ if fetched.ResolvedKey != "" {
398+ allowed := false
399+ for _, candidate := range favicon.Candidates(requestedPageURL) {
400+ if candidate == fetched.ResolvedKey {
401+ allowed = true
402+ break
403+ }
404+ }
405+ if !allowed {
406+ return ErrFaviconMiss
407+ }
408+ _, err := uc.observeKey(ctx, fetched.ResolvedKey, requestedPageURL, fetched.IconURL, fetched.Bytes, fetched.Source, fetched.ContentType)
409+ return err
410+ }
411+ _, err := uc.Observe(ctx, requestedPageURL, fetched.IconURL, fetched.Bytes, fetched.Source, fetched.ContentType)
376412 return err
377413 }
378414
@@ -230,6 +230,20 @@ func (uc *FaviconUseCase) Observe(
230 if !ok || len(bytes) == 0 {230 if !ok || len(bytes) == 0 {
231 return nil, ErrFaviconMiss231 return nil, ErrFaviconMiss
232 }232 }
233+ return uc.observeKey(ctx, key, pageURL, iconURL, bytes, source, contentType)
234+}
235+
236+func (uc *FaviconUseCase) observeKey(
237+ ctx context.Context,
238+ key favicon.Key,
239+ pageURL, iconURL string,
240+ bytes []byte,
241+ source favicon.Source,
242+ contentType string,
243+) (*favicon.Metadata, error) {
244+ if key == "" || len(bytes) == 0 {
245+ return nil, ErrFaviconMiss
246+ }
233 old, err := uc.repo.Get(ctx, key)247 old, err := uc.repo.Get(ctx, key)
234 if err != nil {248 if err != nil {
235 return nil, err249 return nil, err
@@ -310,7 +324,7 @@ func (uc *FaviconUseCase) RefreshFromIconURLs(ctx context.Context, pageURL strin
310 if err != nil {324 if err != nil {
311 return err325 return err
312 }326 }
313- _, err = uc.Observe(ctx, fetched.PageURL, fetched.IconURL, fetched.Bytes, fetched.Source, fetched.ContentType)327+ err = uc.observeFetched(ctx, pageURL, fetched)
314 if errors.Is(err, ErrFaviconMiss) {328 if errors.Is(err, ErrFaviconMiss) {
315 lastMiss = err329 lastMiss = err
316 continue330 continue
@@ -372,7 +386,29 @@ func (uc *FaviconUseCase) fetchAndObserve(ctx context.Context, pageURL string) e
372 if err != nil {386 if err != nil {
373 return err387 return err
374 }388 }
375- _, err = uc.Observe(ctx, fetched.PageURL, fetched.IconURL, fetched.Bytes, fetched.Source, fetched.ContentType)389+ err = uc.observeFetched(ctx, pageURL, fetched)
390+ return err
391+}
392+
393+func (uc *FaviconUseCase) observeFetched(ctx context.Context, requestedPageURL string, fetched *appport.FaviconFetchedIcon) error {
394+ if fetched == nil {
395+ return ErrFaviconMiss
396+ }
397+ if fetched.ResolvedKey != "" {
398+ allowed := false
399+ for _, candidate := range favicon.Candidates(requestedPageURL) {
400+ if candidate == fetched.ResolvedKey {
401+ allowed = true
402+ break
403+ }
404+ }
405+ if !allowed {
406+ return ErrFaviconMiss
407+ }
408+ _, err := uc.observeKey(ctx, fetched.ResolvedKey, requestedPageURL, fetched.IconURL, fetched.Bytes, fetched.Source, fetched.ContentType)
409+ return err
410+ }
411+ _, err := uc.Observe(ctx, requestedPageURL, fetched.IconURL, fetched.Bytes, fetched.Source, fetched.ContentType)
376 return err412 return err
377 }413 }
378 414
M internal/application/usecase/favicon_test.go +132 -10
@@ -34,6 +34,62 @@ func TestFaviconResolveParentFallback(t *testing.T) {
3434 }
3535 }
3636
37+func TestFaviconResolvePrefersParentPathFallbackBeforeDomain(t *testing.T) {
38+ fx := newFaviconFixture()
39+ fx.seed("github.com", []byte("github-default"), false)
40+ fx.seed("github.com/bnema/gordon", []byte("repo-default"), false)
41+
42+ got, err := fx.uc.Resolve(context.Background(), "https://github.com/bnema/gordon/issues/5", 32, ResolveOptions{Purpose: ResolvePurposeUI})
43+ if err != nil {
44+ t.Fatal(err)
45+ }
46+ if got.Key != "github.com/bnema/gordon" || string(got.Bytes) != "repo-default" {
47+ t.Fatalf("expected repository fallback before domain: key=%q bytes=%q", got.Key, got.Bytes)
48+ }
49+}
50+
51+func TestFaviconResolvePathSpecificDoesNotLeakToParentOrSiblings(t *testing.T) {
52+ fx := newFaviconFixture()
53+ fx.seed("github.com", []byte("github-default"), false)
54+
55+ if _, err := fx.uc.Observe(
56+ context.Background(),
57+ "https://github.com/bnema/gordon/pull/123",
58+ "https://github.com/favicon-status-success.png",
59+ []byte("pr-success"),
60+ favicon.SourceEngine,
61+ "image/png",
62+ ); err != nil {
63+ t.Fatal(err)
64+ }
65+
66+ pr, err := fx.uc.Resolve(context.Background(), "https://github.com/bnema/gordon/pull/123", 32, ResolveOptions{Purpose: ResolvePurposeUI})
67+ if err != nil {
68+ t.Fatal(err)
69+ }
70+ if pr.Key != "github.com/bnema/gordon/pull/123" || string(pr.Bytes) != "png32:pr-success" {
71+ t.Fatalf("PR resolve used wrong icon: key=%q bytes=%q", pr.Key, pr.Bytes)
72+ }
73+
74+ for _, rawURL := range []string{
75+ "https://github.com/bnema/gordon",
76+ "https://github.com/notifications",
77+ "https://github.com/bnema?tab=repositories",
78+ } {
79+ got, err := fx.uc.Resolve(context.Background(), rawURL, 32, ResolveOptions{Purpose: ResolvePurposeUI})
80+ if err != nil {
81+ t.Fatalf("Resolve(%q): %v", rawURL, err)
82+ }
83+ if got.Key != "github.com" || string(got.Bytes) != "github-default" {
84+ t.Fatalf("Resolve(%q) leaked path-specific favicon: key=%q bytes=%q", rawURL, got.Key, got.Bytes)
85+ }
86+ }
87+
88+ if _, err := fx.uc.Resolve(context.Background(), "https://gitlab.com/bnema/gordon", 32, ResolveOptions{Purpose: ResolvePurposeUI}); !errors.Is(err, ErrFaviconMiss) {
89+ t.Fatalf("expected cross-domain miss, got %v", err)
90+ }
91+}
92+
3793 func TestFaviconResolveRepairsOrphanCanonicalBlobMetadata(t *testing.T) {
3894 fx := newFaviconFixture()
3995 fx.blobs.png["example.com"] = []byte("png")
@@ -112,7 +168,7 @@ func TestFaviconResolveStaleExactRefreshIgnoresFreshParent(t *testing.T) {
112168 func TestFaviconObserveSameHashUpdatesMetadataOnly(t *testing.T) {
113169 fx := newFaviconFixture()
114170 fx.seedOriginal([]byte("same"))
115- if _, err := fx.uc.Observe(context.Background(), "https://example.com/page", "https://cdn.test/icon.png", []byte("same"), favicon.SourceEngine, "image/png"); err != nil {
171+ if _, err := fx.uc.Observe(context.Background(), "https://example.com", "https://cdn.test/icon.png", []byte("same"), favicon.SourceEngine, "image/png"); err != nil {
116172 t.Fatal(err)
117173 }
118174 if fx.blobs.writeOriginals != 0 || fx.converter.calls != 0 || fx.invalidators.calls != 0 {
@@ -126,7 +182,7 @@ func TestFaviconObserveSameHashUpdatesMetadataOnly(t *testing.T) {
126182 func TestFaviconObserveChangedHashRegeneratesAndInvalidates(t *testing.T) {
127183 fx := newFaviconFixture()
128184 fx.seedOriginal([]byte("old"))
129- if _, err := fx.uc.Observe(context.Background(), "https://example.com/page", "https://cdn.test/icon.png", []byte("new"), favicon.SourceEngine, "image/png"); err != nil {
185+ if _, err := fx.uc.Observe(context.Background(), "https://example.com", "https://cdn.test/icon.png", []byte("new"), favicon.SourceEngine, "image/png"); err != nil {
130186 t.Fatal(err)
131187 }
132188 if fx.blobs.writeOriginals != 1 || fx.converter.calls != 1 || fx.blobs.removeDerived != 1 || fx.invalidators.calls != 1 {
@@ -143,7 +199,7 @@ func TestFaviconObserveChangedHashRegeneratesAndInvalidates(t *testing.T) {
143199 func TestFaviconObserveWithoutConverterStoresOriginalOnly(t *testing.T) {
144200 fx := newFaviconFixture()
145201 fx.uc.converter = nil
146- if _, err := fx.uc.Observe(context.Background(), "https://example.com/page", "https://example.com/icon.png", []byte("raw"), favicon.SourceEngine, "image/png"); err != nil {
202+ if _, err := fx.uc.Observe(context.Background(), "https://example.com", "https://example.com/icon.png", []byte("raw"), favicon.SourceEngine, "image/png"); err != nil {
147203 t.Fatal(err)
148204 }
149205 if string(fx.blobs.original["example.com"]) != "raw" {
@@ -230,11 +286,71 @@ func TestFaviconRefreshFromIconURLsUsesPageURLKey(t *testing.T) {
230286 if err := fx.uc.RefreshFromIconURLs(context.Background(), "https://docs.example.com/page", []string{"https://cdn.example.net/icon.png"}); err != nil {
231287 t.Fatal(err)
232288 }
233- if _, ok := fx.repo.byKey["docs.example.com"]; !ok {
289+ if _, ok := fx.repo.byKey["docs.example.com/page"]; !ok {
234290 t.Fatalf("page URL key was not stored; keys=%v", fx.repo.byKey)
235291 }
236- if _, ok := fx.repo.byKey["cdn.example.net"]; ok {
237- t.Fatal("icon URL host should not be used as cache key")
292+ if _, ok := fx.repo.byKey["cdn.example.net/icon.png"]; ok {
293+ t.Fatal("icon URL should not be used as cache key")
294+ }
295+}
296+
297+func TestFaviconRefreshStoresDuckDuckGoFallbackUnderHostKey(t *testing.T) {
298+ fx := newFaviconFixture()
299+ fx.fetcher.responses = map[string]fakeFetchedIcon{
300+ "https://example.com/favicon.ico": {
301+ bytes: []byte("host-fallback"),
302+ contentType: "image/png",
303+ resolvedKey: "github.com",
304+ },
305+ }
306+
307+ if err := fx.uc.RefreshIfStale(context.Background(), "https://github.com/bnema/gordon/pull/123"); err != nil {
308+ t.Fatal(err)
309+ }
310+ if _, ok := fx.repo.byKey["github.com"]; !ok {
311+ t.Fatalf("host fallback key was not stored; keys=%v", fx.repo.byKey)
312+ }
313+ if _, ok := fx.repo.byKey["github.com/bnema/gordon/pull/123"]; ok {
314+ t.Fatal("duckduckgo host fallback should not be stored under the exact path key")
315+ }
316+}
317+
318+func TestFaviconRefreshRejectsResolvedKeyOutsideCandidateHierarchy(t *testing.T) {
319+ fx := newFaviconFixture()
320+ fx.fetcher.responses = map[string]fakeFetchedIcon{
321+ "https://example.com/favicon.ico": {
322+ bytes: []byte("bad-fallback"),
323+ contentType: "image/png",
324+ resolvedKey: "evil.example.com",
325+ },
326+ }
327+
328+ err := fx.uc.RefreshIfStale(context.Background(), "https://github.com/bnema/gordon/pull/123")
329+ if !errors.Is(err, ErrFaviconMiss) {
330+ t.Fatalf("RefreshIfStale err=%v, want favicon miss", err)
331+ }
332+ if len(fx.repo.byKey) != 0 {
333+ t.Fatalf("invalid resolved key should not persist metadata: %v", fx.repo.byKey)
334+ }
335+}
336+
337+func TestFaviconRefreshRejectsFetcherPageURLOutsideRequestedHierarchy(t *testing.T) {
338+ fx := newFaviconFixture()
339+ fx.fetcher.responses = map[string]fakeFetchedIcon{
340+ "https://example.com/favicon.ico": {
341+ bytes: []byte("bad-page-url"),
342+ contentType: "image/png",
343+ pageURL: "https://evil.example.com/pwned",
344+ resolvedKey: "evil.example.com/pwned",
345+ },
346+ }
347+
348+ err := fx.uc.RefreshIfStale(context.Background(), "https://github.com/bnema/gordon/pull/123")
349+ if !errors.Is(err, ErrFaviconMiss) {
350+ t.Fatalf("RefreshIfStale err=%v, want favicon miss", err)
351+ }
352+ if len(fx.repo.byKey) != 0 {
353+ t.Fatalf("fetcher-controlled pageURL should not expand storage scope: %v", fx.repo.byKey)
238354 }
239355 }
240356
@@ -256,15 +372,15 @@ func TestFaviconRefreshFromIconURLsSkipsUnsupportedCandidates(t *testing.T) {
256372 if fx.fetcher.calls != 2 {
257373 t.Fatalf("fetcher calls=%d, want 2", fx.fetcher.calls)
258374 }
259- meta := fx.repo.byKey["docs.example.com"]
375+ meta := fx.repo.byKey["docs.example.com/page"]
260376 if meta == nil {
261377 t.Fatalf("expected page URL key to be stored; keys=%v", fx.repo.byKey)
262378 }
263379 if meta.SourceURL != "https://cdn.example.net/icon.png" || meta.ContentType != "image/png" {
264380 t.Fatalf("stored meta = %+v, want png candidate", meta)
265381 }
266- if string(fx.blobs.original["docs.example.com"]) != "png" {
267- t.Fatalf("stored original = %q, want png", fx.blobs.original["docs.example.com"])
382+ if string(fx.blobs.original["docs.example.com/page"]) != "png" {
383+ t.Fatalf("stored original = %q, want png", fx.blobs.original["docs.example.com/page"])
268384 }
269385 }
270386
@@ -551,6 +667,8 @@ func (i *fakeInvalidators) InvalidateAll(_ context.Context, _ favicon.Key) error
551667 type fakeFetchedIcon struct {
552668 bytes []byte
553669 contentType string
670+ pageURL string
671+ resolvedKey favicon.Key
554672 }
555673
556674 type fakeFetcher struct {
@@ -572,5 +690,9 @@ func (f *fakeFetcher) Fetch(_ context.Context, req appport.FaviconFetchRequest)
572690 if response.bytes == nil {
573691 response = fakeFetchedIcon{bytes: []byte("fetched"), contentType: "image/png"}
574692 }
575- return &appport.FaviconFetchedIcon{PageURL: req.PageURL, IconURL: iconURL, Bytes: response.bytes, Source: favicon.SourceDuckDuckGo, ContentType: response.contentType}, nil
693+ pageURL := req.PageURL
694+ if response.pageURL != "" {
695+ pageURL = response.pageURL
696+ }
697+ return &appport.FaviconFetchedIcon{PageURL: pageURL, IconURL: iconURL, ResolvedKey: response.resolvedKey, Bytes: response.bytes, Source: favicon.SourceDuckDuckGo, ContentType: response.contentType}, nil
576698 }
@@ -34,6 +34,62 @@ func TestFaviconResolveParentFallback(t *testing.T) {
34 }34 }
35 }35 }
36 36
37+func TestFaviconResolvePrefersParentPathFallbackBeforeDomain(t *testing.T) {
38+ fx := newFaviconFixture()
39+ fx.seed("github.com", []byte("github-default"), false)
40+ fx.seed("github.com/bnema/gordon", []byte("repo-default"), false)
41+
42+ got, err := fx.uc.Resolve(context.Background(), "https://github.com/bnema/gordon/issues/5", 32, ResolveOptions{Purpose: ResolvePurposeUI})
43+ if err != nil {
44+ t.Fatal(err)
45+ }
46+ if got.Key != "github.com/bnema/gordon" || string(got.Bytes) != "repo-default" {
47+ t.Fatalf("expected repository fallback before domain: key=%q bytes=%q", got.Key, got.Bytes)
48+ }
49+}
50+
51+func TestFaviconResolvePathSpecificDoesNotLeakToParentOrSiblings(t *testing.T) {
52+ fx := newFaviconFixture()
53+ fx.seed("github.com", []byte("github-default"), false)
54+
55+ if _, err := fx.uc.Observe(
56+ context.Background(),
57+ "https://github.com/bnema/gordon/pull/123",
58+ "https://github.com/favicon-status-success.png",
59+ []byte("pr-success"),
60+ favicon.SourceEngine,
61+ "image/png",
62+ ); err != nil {
63+ t.Fatal(err)
64+ }
65+
66+ pr, err := fx.uc.Resolve(context.Background(), "https://github.com/bnema/gordon/pull/123", 32, ResolveOptions{Purpose: ResolvePurposeUI})
67+ if err != nil {
68+ t.Fatal(err)
69+ }
70+ if pr.Key != "github.com/bnema/gordon/pull/123" || string(pr.Bytes) != "png32:pr-success" {
71+ t.Fatalf("PR resolve used wrong icon: key=%q bytes=%q", pr.Key, pr.Bytes)
72+ }
73+
74+ for _, rawURL := range []string{
75+ "https://github.com/bnema/gordon",
76+ "https://github.com/notifications",
77+ "https://github.com/bnema?tab=repositories",
78+ } {
79+ got, err := fx.uc.Resolve(context.Background(), rawURL, 32, ResolveOptions{Purpose: ResolvePurposeUI})
80+ if err != nil {
81+ t.Fatalf("Resolve(%q): %v", rawURL, err)
82+ }
83+ if got.Key != "github.com" || string(got.Bytes) != "github-default" {
84+ t.Fatalf("Resolve(%q) leaked path-specific favicon: key=%q bytes=%q", rawURL, got.Key, got.Bytes)
85+ }
86+ }
87+
88+ if _, err := fx.uc.Resolve(context.Background(), "https://gitlab.com/bnema/gordon", 32, ResolveOptions{Purpose: ResolvePurposeUI}); !errors.Is(err, ErrFaviconMiss) {
89+ t.Fatalf("expected cross-domain miss, got %v", err)
90+ }
91+}
92+
37 func TestFaviconResolveRepairsOrphanCanonicalBlobMetadata(t *testing.T) {93 func TestFaviconResolveRepairsOrphanCanonicalBlobMetadata(t *testing.T) {
38 fx := newFaviconFixture()94 fx := newFaviconFixture()
39 fx.blobs.png["example.com"] = []byte("png")95 fx.blobs.png["example.com"] = []byte("png")
@@ -112,7 +168,7 @@ func TestFaviconResolveStaleExactRefreshIgnoresFreshParent(t *testing.T) {
112 func TestFaviconObserveSameHashUpdatesMetadataOnly(t *testing.T) {168 func TestFaviconObserveSameHashUpdatesMetadataOnly(t *testing.T) {
113 fx := newFaviconFixture()169 fx := newFaviconFixture()
114 fx.seedOriginal([]byte("same"))170 fx.seedOriginal([]byte("same"))
115- if _, err := fx.uc.Observe(context.Background(), "https://example.com/page", "https://cdn.test/icon.png", []byte("same"), favicon.SourceEngine, "image/png"); err != nil {171+ if _, err := fx.uc.Observe(context.Background(), "https://example.com", "https://cdn.test/icon.png", []byte("same"), favicon.SourceEngine, "image/png"); err != nil {
116 t.Fatal(err)172 t.Fatal(err)
117 }173 }
118 if fx.blobs.writeOriginals != 0 || fx.converter.calls != 0 || fx.invalidators.calls != 0 {174 if fx.blobs.writeOriginals != 0 || fx.converter.calls != 0 || fx.invalidators.calls != 0 {
@@ -126,7 +182,7 @@ func TestFaviconObserveSameHashUpdatesMetadataOnly(t *testing.T) {
126 func TestFaviconObserveChangedHashRegeneratesAndInvalidates(t *testing.T) {182 func TestFaviconObserveChangedHashRegeneratesAndInvalidates(t *testing.T) {
127 fx := newFaviconFixture()183 fx := newFaviconFixture()
128 fx.seedOriginal([]byte("old"))184 fx.seedOriginal([]byte("old"))
129- if _, err := fx.uc.Observe(context.Background(), "https://example.com/page", "https://cdn.test/icon.png", []byte("new"), favicon.SourceEngine, "image/png"); err != nil {185+ if _, err := fx.uc.Observe(context.Background(), "https://example.com", "https://cdn.test/icon.png", []byte("new"), favicon.SourceEngine, "image/png"); err != nil {
130 t.Fatal(err)186 t.Fatal(err)
131 }187 }
132 if fx.blobs.writeOriginals != 1 || fx.converter.calls != 1 || fx.blobs.removeDerived != 1 || fx.invalidators.calls != 1 {188 if fx.blobs.writeOriginals != 1 || fx.converter.calls != 1 || fx.blobs.removeDerived != 1 || fx.invalidators.calls != 1 {
@@ -143,7 +199,7 @@ func TestFaviconObserveChangedHashRegeneratesAndInvalidates(t *testing.T) {
143 func TestFaviconObserveWithoutConverterStoresOriginalOnly(t *testing.T) {199 func TestFaviconObserveWithoutConverterStoresOriginalOnly(t *testing.T) {
144 fx := newFaviconFixture()200 fx := newFaviconFixture()
145 fx.uc.converter = nil201 fx.uc.converter = nil
146- if _, err := fx.uc.Observe(context.Background(), "https://example.com/page", "https://example.com/icon.png", []byte("raw"), favicon.SourceEngine, "image/png"); err != nil {202+ if _, err := fx.uc.Observe(context.Background(), "https://example.com", "https://example.com/icon.png", []byte("raw"), favicon.SourceEngine, "image/png"); err != nil {
147 t.Fatal(err)203 t.Fatal(err)
148 }204 }
149 if string(fx.blobs.original["example.com"]) != "raw" {205 if string(fx.blobs.original["example.com"]) != "raw" {
@@ -230,11 +286,71 @@ func TestFaviconRefreshFromIconURLsUsesPageURLKey(t *testing.T) {
230 if err := fx.uc.RefreshFromIconURLs(context.Background(), "https://docs.example.com/page", []string{"https://cdn.example.net/icon.png"}); err != nil {286 if err := fx.uc.RefreshFromIconURLs(context.Background(), "https://docs.example.com/page", []string{"https://cdn.example.net/icon.png"}); err != nil {
231 t.Fatal(err)287 t.Fatal(err)
232 }288 }
233- if _, ok := fx.repo.byKey["docs.example.com"]; !ok {289+ if _, ok := fx.repo.byKey["docs.example.com/page"]; !ok {
234 t.Fatalf("page URL key was not stored; keys=%v", fx.repo.byKey)290 t.Fatalf("page URL key was not stored; keys=%v", fx.repo.byKey)
235 }291 }
236- if _, ok := fx.repo.byKey["cdn.example.net"]; ok {292+ if _, ok := fx.repo.byKey["cdn.example.net/icon.png"]; ok {
237- t.Fatal("icon URL host should not be used as cache key")293+ t.Fatal("icon URL should not be used as cache key")
294+ }
295+}
296+
297+func TestFaviconRefreshStoresDuckDuckGoFallbackUnderHostKey(t *testing.T) {
298+ fx := newFaviconFixture()
299+ fx.fetcher.responses = map[string]fakeFetchedIcon{
300+ "https://example.com/favicon.ico": {
301+ bytes: []byte("host-fallback"),
302+ contentType: "image/png",
303+ resolvedKey: "github.com",
304+ },
305+ }
306+
307+ if err := fx.uc.RefreshIfStale(context.Background(), "https://github.com/bnema/gordon/pull/123"); err != nil {
308+ t.Fatal(err)
309+ }
310+ if _, ok := fx.repo.byKey["github.com"]; !ok {
311+ t.Fatalf("host fallback key was not stored; keys=%v", fx.repo.byKey)
312+ }
313+ if _, ok := fx.repo.byKey["github.com/bnema/gordon/pull/123"]; ok {
314+ t.Fatal("duckduckgo host fallback should not be stored under the exact path key")
315+ }
316+}
317+
318+func TestFaviconRefreshRejectsResolvedKeyOutsideCandidateHierarchy(t *testing.T) {
319+ fx := newFaviconFixture()
320+ fx.fetcher.responses = map[string]fakeFetchedIcon{
321+ "https://example.com/favicon.ico": {
322+ bytes: []byte("bad-fallback"),
323+ contentType: "image/png",
324+ resolvedKey: "evil.example.com",
325+ },
326+ }
327+
328+ err := fx.uc.RefreshIfStale(context.Background(), "https://github.com/bnema/gordon/pull/123")
329+ if !errors.Is(err, ErrFaviconMiss) {
330+ t.Fatalf("RefreshIfStale err=%v, want favicon miss", err)
331+ }
332+ if len(fx.repo.byKey) != 0 {
333+ t.Fatalf("invalid resolved key should not persist metadata: %v", fx.repo.byKey)
334+ }
335+}
336+
337+func TestFaviconRefreshRejectsFetcherPageURLOutsideRequestedHierarchy(t *testing.T) {
338+ fx := newFaviconFixture()
339+ fx.fetcher.responses = map[string]fakeFetchedIcon{
340+ "https://example.com/favicon.ico": {
341+ bytes: []byte("bad-page-url"),
342+ contentType: "image/png",
343+ pageURL: "https://evil.example.com/pwned",
344+ resolvedKey: "evil.example.com/pwned",
345+ },
346+ }
347+
348+ err := fx.uc.RefreshIfStale(context.Background(), "https://github.com/bnema/gordon/pull/123")
349+ if !errors.Is(err, ErrFaviconMiss) {
350+ t.Fatalf("RefreshIfStale err=%v, want favicon miss", err)
351+ }
352+ if len(fx.repo.byKey) != 0 {
353+ t.Fatalf("fetcher-controlled pageURL should not expand storage scope: %v", fx.repo.byKey)
238 }354 }
239 }355 }
240 356
@@ -256,15 +372,15 @@ func TestFaviconRefreshFromIconURLsSkipsUnsupportedCandidates(t *testing.T) {
256 if fx.fetcher.calls != 2 {372 if fx.fetcher.calls != 2 {
257 t.Fatalf("fetcher calls=%d, want 2", fx.fetcher.calls)373 t.Fatalf("fetcher calls=%d, want 2", fx.fetcher.calls)
258 }374 }
259- meta := fx.repo.byKey["docs.example.com"]375+ meta := fx.repo.byKey["docs.example.com/page"]
260 if meta == nil {376 if meta == nil {
261 t.Fatalf("expected page URL key to be stored; keys=%v", fx.repo.byKey)377 t.Fatalf("expected page URL key to be stored; keys=%v", fx.repo.byKey)
262 }378 }
263 if meta.SourceURL != "https://cdn.example.net/icon.png" || meta.ContentType != "image/png" {379 if meta.SourceURL != "https://cdn.example.net/icon.png" || meta.ContentType != "image/png" {
264 t.Fatalf("stored meta = %+v, want png candidate", meta)380 t.Fatalf("stored meta = %+v, want png candidate", meta)
265 }381 }
266- if string(fx.blobs.original["docs.example.com"]) != "png" {382+ if string(fx.blobs.original["docs.example.com/page"]) != "png" {
267- t.Fatalf("stored original = %q, want png", fx.blobs.original["docs.example.com"])383+ t.Fatalf("stored original = %q, want png", fx.blobs.original["docs.example.com/page"])
268 }384 }
269 }385 }
270 386
@@ -551,6 +667,8 @@ func (i *fakeInvalidators) InvalidateAll(_ context.Context, _ favicon.Key) error
551 type fakeFetchedIcon struct {667 type fakeFetchedIcon struct {
552 bytes []byte668 bytes []byte
553 contentType string669 contentType string
670+ pageURL string
671+ resolvedKey favicon.Key
554 }672 }
555 673
556 type fakeFetcher struct {674 type fakeFetcher struct {
@@ -572,5 +690,9 @@ func (f *fakeFetcher) Fetch(_ context.Context, req appport.FaviconFetchRequest)
572 if response.bytes == nil {690 if response.bytes == nil {
573 response = fakeFetchedIcon{bytes: []byte("fetched"), contentType: "image/png"}691 response = fakeFetchedIcon{bytes: []byte("fetched"), contentType: "image/png"}
574 }692 }
575- return &appport.FaviconFetchedIcon{PageURL: req.PageURL, IconURL: iconURL, Bytes: response.bytes, Source: favicon.SourceDuckDuckGo, ContentType: response.contentType}, nil693+ pageURL := req.PageURL
694+ if response.pageURL != "" {
695+ pageURL = response.pageURL
696+ }
697+ return &appport.FaviconFetchedIcon{PageURL: pageURL, IconURL: iconURL, ResolvedKey: response.resolvedKey, Bytes: response.bytes, Source: favicon.SourceDuckDuckGo, ContentType: response.contentType}, nil
576 }698 }
M internal/domain/favicon/key.go +115 -22
@@ -3,6 +3,7 @@ package favicon
33 import (
44 "net"
55 "net/url"
6+ "path"
67 "slices"
78 "strings"
89
@@ -20,20 +21,40 @@ func CanonicalKey(raw string) (Key, bool) {
2021 return candidates[0], true
2122 }
2223
24+func CanonicalHostKey(raw string) (Key, bool) {
25+ candidates := hostCandidates(raw)
26+ if len(candidates) == 0 {
27+ return "", false
28+ }
29+ return candidates[0], true
30+}
31+
2332 func Candidates(raw string) []Key {
24- host, port, hasScheme, scheme, ok := parseHost(raw)
25- if !ok {
33+ parsed := parseHost(raw)
34+ if !parsed.ok {
2635 return nil
2736 }
37+ return withPathCandidates(hostCandidatesForParsed(parsed), parsed.rawPath)
38+}
2839
29- host = normalizeHost(host)
40+func hostCandidates(raw string) []Key {
41+ parsed := parseHost(raw)
42+ if !parsed.ok {
43+ return nil
44+ }
45+ return hostCandidatesForParsed(parsed)
46+}
47+
48+func hostCandidatesForParsed(parsed parsedHost) []Key {
49+ host := normalizeHost(parsed.host)
3050 if host == "" {
3151 return nil
3252 }
3353
54+ var candidates []Key
3455 if isExactHost(host) {
35- if port != "" && (!hasScheme || !isDefaultPort(scheme, port)) {
36- return []Key{Key(net.JoinHostPort(host, port))}
56+ if parsed.port != "" && (!parsed.hasScheme || !isDefaultPort(parsed.scheme, parsed.port)) {
57+ return []Key{Key(net.JoinHostPort(host, parsed.port))}
3758 }
3859 return []Key{Key(host)}
3960 }
@@ -45,9 +66,9 @@ func Candidates(raw string) []Key {
4566 host = strings.ToLower(strings.TrimSuffix(ascii, "."))
4667 host = stripWWW(host)
4768
48- if port != "" {
49- if !hasScheme || !isDefaultPort(scheme, port) {
50- return []Key{Key(net.JoinHostPort(host, port))}
69+ if parsed.port != "" {
70+ if !parsed.hasScheme || !isDefaultPort(parsed.scheme, parsed.port) {
71+ return []Key{Key(net.JoinHostPort(host, parsed.port))}
5172 }
5273 }
5374
@@ -61,55 +82,127 @@ func Candidates(raw string) []Key {
6182 return []Key{Key(host)}
6283 }
6384
64- var out []Key
6585 for i := range len(labels) {
6686 candidate := strings.Join(labels[i:], ".")
67- out = append(out, Key(candidate))
87+ candidates = append(candidates, Key(candidate))
6888 if candidate == registrable {
6989 break
7090 }
7191 }
92+ return candidates
93+}
94+
95+func withPathCandidates(hostCandidates []Key, rawPath string) []Key {
96+ if len(hostCandidates) == 0 {
97+ return nil
98+ }
99+ paths := pathCandidates(rawPath)
100+ if len(paths) == 0 {
101+ return hostCandidates
102+ }
103+
104+ out := make([]Key, 0, len(paths)+len(hostCandidates))
105+ base := string(hostCandidates[0])
106+ for _, p := range paths {
107+ out = append(out, Key(base+p))
108+ }
109+ out = append(out, hostCandidates...)
72110 return out
73111 }
74112
75-func parseHost(raw string) (host, port string, hasScheme bool, scheme string, ok bool) {
113+func pathCandidates(rawPath string) []string {
114+ p := normalizePath(rawPath)
115+ if p == "" {
116+ return nil
117+ }
118+
119+ var out []string
120+ for p != "" && p != "/" {
121+ out = append(out, p)
122+ idx := strings.LastIndex(p, "/")
123+ if idx <= 0 {
124+ break
125+ }
126+ p = p[:idx]
127+ }
128+ return out
129+}
130+
131+func normalizePath(rawPath string) string {
132+ if rawPath == "" {
133+ return ""
134+ }
135+ if cut := strings.IndexAny(rawPath, "?#"); cut >= 0 {
136+ rawPath = rawPath[:cut]
137+ }
138+ if rawPath == "" || rawPath == "/" {
139+ return ""
140+ }
141+ if !strings.HasPrefix(rawPath, "/") {
142+ rawPath = "/" + rawPath
143+ }
144+ cleaned := path.Clean(rawPath)
145+ if cleaned == "." || cleaned == "/" {
146+ return ""
147+ }
148+ return strings.TrimRight(cleaned, "/")
149+}
150+
151+type parsedHost struct {
152+ host string
153+ port string
154+ hasScheme bool
155+ scheme string
156+ rawPath string
157+ ok bool
158+}
159+
160+func parseHost(raw string) parsedHost {
76161 raw = strings.TrimSpace(raw)
77162 if raw == "" {
78- return "", "", false, "", false
163+ return parsedHost{}
79164 }
80165
81166 if strings.Contains(raw, "://") {
82167 u, err := url.Parse(raw)
83168 if err != nil || u.Scheme == "" {
84- return "", "", false, "", false
169+ return parsedHost{}
85170 }
86- scheme = strings.ToLower(u.Scheme)
171+ scheme := strings.ToLower(u.Scheme)
87172 if scheme != "http" && scheme != "https" {
88- return "", "", true, scheme, false
173+ return parsedHost{hasScheme: true, scheme: scheme}
174+ }
175+ host := u.Hostname()
176+ return parsedHost{
177+ host: host,
178+ port: u.Port(),
179+ hasScheme: true,
180+ scheme: scheme,
181+ rawPath: u.EscapedPath(),
182+ ok: host != "",
89183 }
90- host = u.Hostname()
91- port = u.Port()
92- return host, port, true, scheme, host != ""
93184 }
94185 if strings.HasPrefix(strings.ToLower(raw), "about:") {
95- return "", "", false, "", false
186+ return parsedHost{}
96187 }
97188
98189 hostport := raw
190+ rawPath := ""
99191 if cut := strings.IndexAny(hostport, "/?#"); cut >= 0 {
192+ rawPath = hostport[cut:]
100193 hostport = hostport[:cut]
101194 }
102195 hostport = strings.TrimSuffix(hostport, ".")
103196
104197 if h, p, err := net.SplitHostPort(hostport); err == nil {
105- return h, p, false, "", h != ""
198+ return parsedHost{host: h, port: p, rawPath: rawPath, ok: h != ""}
106199 }
107200 if strings.Count(hostport, ":") == 1 {
108201 if h, p, ok := strings.Cut(hostport, ":"); ok && h != "" && p != "" {
109- return h, p, false, "", true
202+ return parsedHost{host: h, port: p, rawPath: rawPath, ok: true}
110203 }
111204 }
112- return hostport, "", false, "", hostport != ""
205+ return parsedHost{host: hostport, rawPath: rawPath, ok: hostport != ""}
113206 }
114207
115208 func normalizeHost(host string) string {
@@ -3,6 +3,7 @@ package favicon
3 import (3 import (
4 "net"4 "net"
5 "net/url"5 "net/url"
6+ "path"
6 "slices"7 "slices"
7 "strings"8 "strings"
8 9
@@ -20,20 +21,40 @@ func CanonicalKey(raw string) (Key, bool) {
20 return candidates[0], true21 return candidates[0], true
21 }22 }
22 23
24+func CanonicalHostKey(raw string) (Key, bool) {
25+ candidates := hostCandidates(raw)
26+ if len(candidates) == 0 {
27+ return "", false
28+ }
29+ return candidates[0], true
30+}
31+
23 func Candidates(raw string) []Key {32 func Candidates(raw string) []Key {
24- host, port, hasScheme, scheme, ok := parseHost(raw)33+ parsed := parseHost(raw)
25- if !ok {34+ if !parsed.ok {
26 return nil35 return nil
27 }36 }
37+ return withPathCandidates(hostCandidatesForParsed(parsed), parsed.rawPath)
38+}
28 39
29- host = normalizeHost(host)40+func hostCandidates(raw string) []Key {
41+ parsed := parseHost(raw)
42+ if !parsed.ok {
43+ return nil
44+ }
45+ return hostCandidatesForParsed(parsed)
46+}
47+
48+func hostCandidatesForParsed(parsed parsedHost) []Key {
49+ host := normalizeHost(parsed.host)
30 if host == "" {50 if host == "" {
31 return nil51 return nil
32 }52 }
33 53
54+ var candidates []Key
34 if isExactHost(host) {55 if isExactHost(host) {
35- if port != "" && (!hasScheme || !isDefaultPort(scheme, port)) {56+ if parsed.port != "" && (!parsed.hasScheme || !isDefaultPort(parsed.scheme, parsed.port)) {
36- return []Key{Key(net.JoinHostPort(host, port))}57+ return []Key{Key(net.JoinHostPort(host, parsed.port))}
37 }58 }
38 return []Key{Key(host)}59 return []Key{Key(host)}
39 }60 }
@@ -45,9 +66,9 @@ func Candidates(raw string) []Key {
45 host = strings.ToLower(strings.TrimSuffix(ascii, "."))66 host = strings.ToLower(strings.TrimSuffix(ascii, "."))
46 host = stripWWW(host)67 host = stripWWW(host)
47 68
48- if port != "" {69+ if parsed.port != "" {
49- if !hasScheme || !isDefaultPort(scheme, port) {70+ if !parsed.hasScheme || !isDefaultPort(parsed.scheme, parsed.port) {
50- return []Key{Key(net.JoinHostPort(host, port))}71+ return []Key{Key(net.JoinHostPort(host, parsed.port))}
51 }72 }
52 }73 }
53 74
@@ -61,55 +82,127 @@ func Candidates(raw string) []Key {
61 return []Key{Key(host)}82 return []Key{Key(host)}
62 }83 }
63 84
64- var out []Key
65 for i := range len(labels) {85 for i := range len(labels) {
66 candidate := strings.Join(labels[i:], ".")86 candidate := strings.Join(labels[i:], ".")
67- out = append(out, Key(candidate))87+ candidates = append(candidates, Key(candidate))
68 if candidate == registrable {88 if candidate == registrable {
69 break89 break
70 }90 }
71 }91 }
92+ return candidates
93+}
94+
95+func withPathCandidates(hostCandidates []Key, rawPath string) []Key {
96+ if len(hostCandidates) == 0 {
97+ return nil
98+ }
99+ paths := pathCandidates(rawPath)
100+ if len(paths) == 0 {
101+ return hostCandidates
102+ }
103+
104+ out := make([]Key, 0, len(paths)+len(hostCandidates))
105+ base := string(hostCandidates[0])
106+ for _, p := range paths {
107+ out = append(out, Key(base+p))
108+ }
109+ out = append(out, hostCandidates...)
72 return out110 return out
73 }111 }
74 112
75-func parseHost(raw string) (host, port string, hasScheme bool, scheme string, ok bool) {113+func pathCandidates(rawPath string) []string {
114+ p := normalizePath(rawPath)
115+ if p == "" {
116+ return nil
117+ }
118+
119+ var out []string
120+ for p != "" && p != "/" {
121+ out = append(out, p)
122+ idx := strings.LastIndex(p, "/")
123+ if idx <= 0 {
124+ break
125+ }
126+ p = p[:idx]
127+ }
128+ return out
129+}
130+
131+func normalizePath(rawPath string) string {
132+ if rawPath == "" {
133+ return ""
134+ }
135+ if cut := strings.IndexAny(rawPath, "?#"); cut >= 0 {
136+ rawPath = rawPath[:cut]
137+ }
138+ if rawPath == "" || rawPath == "/" {
139+ return ""
140+ }
141+ if !strings.HasPrefix(rawPath, "/") {
142+ rawPath = "/" + rawPath
143+ }
144+ cleaned := path.Clean(rawPath)
145+ if cleaned == "." || cleaned == "/" {
146+ return ""
147+ }
148+ return strings.TrimRight(cleaned, "/")
149+}
150+
151+type parsedHost struct {
152+ host string
153+ port string
154+ hasScheme bool
155+ scheme string
156+ rawPath string
157+ ok bool
158+}
159+
160+func parseHost(raw string) parsedHost {
76 raw = strings.TrimSpace(raw)161 raw = strings.TrimSpace(raw)
77 if raw == "" {162 if raw == "" {
78- return "", "", false, "", false163+ return parsedHost{}
79 }164 }
80 165
81 if strings.Contains(raw, "://") {166 if strings.Contains(raw, "://") {
82 u, err := url.Parse(raw)167 u, err := url.Parse(raw)
83 if err != nil || u.Scheme == "" {168 if err != nil || u.Scheme == "" {
84- return "", "", false, "", false169+ return parsedHost{}
85 }170 }
86- scheme = strings.ToLower(u.Scheme)171+ scheme := strings.ToLower(u.Scheme)
87 if scheme != "http" && scheme != "https" {172 if scheme != "http" && scheme != "https" {
88- return "", "", true, scheme, false173+ return parsedHost{hasScheme: true, scheme: scheme}
174+ }
175+ host := u.Hostname()
176+ return parsedHost{
177+ host: host,
178+ port: u.Port(),
179+ hasScheme: true,
180+ scheme: scheme,
181+ rawPath: u.EscapedPath(),
182+ ok: host != "",
89 }183 }
90- host = u.Hostname()
91- port = u.Port()
92- return host, port, true, scheme, host != ""
93 }184 }
94 if strings.HasPrefix(strings.ToLower(raw), "about:") {185 if strings.HasPrefix(strings.ToLower(raw), "about:") {
95- return "", "", false, "", false186+ return parsedHost{}
96 }187 }
97 188
98 hostport := raw189 hostport := raw
190+ rawPath := ""
99 if cut := strings.IndexAny(hostport, "/?#"); cut >= 0 {191 if cut := strings.IndexAny(hostport, "/?#"); cut >= 0 {
192+ rawPath = hostport[cut:]
100 hostport = hostport[:cut]193 hostport = hostport[:cut]
101 }194 }
102 hostport = strings.TrimSuffix(hostport, ".")195 hostport = strings.TrimSuffix(hostport, ".")
103 196
104 if h, p, err := net.SplitHostPort(hostport); err == nil {197 if h, p, err := net.SplitHostPort(hostport); err == nil {
105- return h, p, false, "", h != ""198+ return parsedHost{host: h, port: p, rawPath: rawPath, ok: h != ""}
106 }199 }
107 if strings.Count(hostport, ":") == 1 {200 if strings.Count(hostport, ":") == 1 {
108 if h, p, ok := strings.Cut(hostport, ":"); ok && h != "" && p != "" {201 if h, p, ok := strings.Cut(hostport, ":"); ok && h != "" && p != "" {
109- return h, p, false, "", true202+ return parsedHost{host: h, port: p, rawPath: rawPath, ok: true}
110 }203 }
111 }204 }
112- return hostport, "", false, "", hostport != ""205+ return parsedHost{host: hostport, rawPath: rawPath, ok: hostport != ""}
113 }206 }
114 207
115 func normalizeHost(host string) string {208 func normalizeHost(host string) string {
M internal/domain/favicon/key_test.go +29 -11
@@ -8,20 +8,24 @@ func TestKeyCandidates(t *testing.T) {
88 raw string
99 want []Key
1010 }{
11- {name: "walks public host to registrable domain", raw: "https://app.docs.example.com/path", want: []Key{"app.docs.example.com", "docs.example.com", "example.com"}},
11+ {name: "walks path before public host to registrable domain", raw: "https://app.docs.example.com/path", want: []Key{"app.docs.example.com/path", "app.docs.example.com", "docs.example.com", "example.com"}},
1212 {name: "strips www", raw: "https://www.example.com", want: []Key{"example.com"}},
13- {name: "localhost with port is exact", raw: "http://localhost:1455/path", want: []Key{"localhost:1455"}},
14- {name: "ip with port is exact", raw: "http://127.0.0.1:8080/path", want: []Key{"127.0.0.1:8080"}},
15- {name: "ipv6 with port is exact", raw: "http://[::1]:8080/path", want: []Key{"[::1]:8080"}},
16- {name: "uses public suffix for co uk", raw: "https://news.bbc.co.uk/story", want: []Key{"news.bbc.co.uk", "bbc.co.uk"}},
13+ {name: "localhost with port is exact", raw: "http://localhost:1455/path", want: []Key{"localhost:1455/path", "localhost:1455"}},
14+ {name: "ip with port is exact", raw: "http://127.0.0.1:8080/path", want: []Key{"127.0.0.1:8080/path", "127.0.0.1:8080"}},
15+ {name: "ipv6 with port is exact", raw: "http://[::1]:8080/path", want: []Key{"[::1]:8080/path", "[::1]:8080"}},
16+ {name: "uses public suffix for co uk", raw: "https://news.bbc.co.uk/story", want: []Key{"news.bbc.co.uk/story", "news.bbc.co.uk", "bbc.co.uk"}},
17+ {name: "path candidates walk from exact path to host", raw: "https://github.com/bnema/gordon/pull/123", want: []Key{"github.com/bnema/gordon/pull/123", "github.com/bnema/gordon/pull", "github.com/bnema/gordon", "github.com/bnema", "github.com"}},
18+ {name: "trailing slash normalizes to parent path key", raw: "https://github.com/bnema/gordon/", want: []Key{"github.com/bnema/gordon", "github.com/bnema", "github.com"}},
19+ {name: "path normalization removes dot segments and query fragments", raw: "https://github.com/bnema/./gordon/issues/../pull/123?x#y", want: []Key{"github.com/bnema/gordon/pull/123", "github.com/bnema/gordon/pull", "github.com/bnema/gordon", "github.com/bnema", "github.com"}},
20+ {name: "host-like string preserves path before host fallback", raw: "github.com/bnema?tab=repositories", want: []Key{"github.com/bnema", "github.com"}},
1721
18- {name: "drops default https port", raw: "https://example.com:443/path", want: []Key{"example.com"}},
19- {name: "non default url port is exact", raw: "https://example.com:8443/path", want: []Key{"example.com:8443"}},
22+ {name: "drops default https port", raw: "https://example.com:443/path", want: []Key{"example.com/path", "example.com"}},
23+ {name: "non default url port is exact", raw: "https://example.com:8443/path", want: []Key{"example.com:8443/path", "example.com:8443"}},
2024 {name: "host-like string lowercases and trims trailing dot", raw: "Docs.Example.COM.", want: []Key{"docs.example.com", "example.com"}},
2125 {name: "strips www before preserving non default port", raw: "https://www.example.com:8443", want: []Key{"example.com:8443"}},
2226 {name: "host-like string with port preserves port exact", raw: "example.com:443", want: []Key{"example.com:443"}},
23- {name: "unicode host normalizes to punycode", raw: "https://bücher.example/path", want: []Key{"xn--bcher-kva.example"}},
24- {name: "punycode equivalent matches unicode", raw: "https://xn--bcher-kva.example/path", want: []Key{"xn--bcher-kva.example"}},
27+ {name: "unicode host normalizes to punycode", raw: "https://bücher.example/path", want: []Key{"xn--bcher-kva.example/path", "xn--bcher-kva.example"}},
28+ {name: "punycode equivalent matches unicode", raw: "https://xn--bcher-kva.example/path", want: []Key{"xn--bcher-kva.example/path", "xn--bcher-kva.example"}},
2529 {name: "empty input has no candidates", raw: "", want: nil},
2630 {name: "about url has no candidates", raw: "about:blank", want: nil},
2731 {name: "unsupported scheme has no candidates", raw: "file:///tmp/icon.html", want: nil},
@@ -47,11 +51,25 @@ func TestCanonicalKey(t *testing.T) {
4751 if !ok {
4852 t.Fatal("CanonicalKey returned false")
4953 }
50- if key != "example.com" {
51- t.Fatalf("CanonicalKey = %q, want example.com", key)
54+ if key != "example.com/path" {
55+ t.Fatalf("CanonicalKey = %q, want example.com/path", key)
5256 }
5357
5458 if key, ok := CanonicalKey("about:blank"); ok || key != "" {
5559 t.Fatalf("CanonicalKey(about:blank) = %q, %v; want empty false", key, ok)
5660 }
5761 }
62+
63+func TestCanonicalHostKey(t *testing.T) {
64+ key, ok := CanonicalHostKey("https://www.Example.COM.:443/path")
65+ if !ok {
66+ t.Fatal("CanonicalHostKey returned false")
67+ }
68+ if key != "example.com" {
69+ t.Fatalf("CanonicalHostKey = %q, want example.com", key)
70+ }
71+
72+ if key, ok := CanonicalHostKey("about:blank"); ok || key != "" {
73+ t.Fatalf("CanonicalHostKey(about:blank) = %q, %v; want empty false", key, ok)
74+ }
75+}
@@ -8,20 +8,24 @@ func TestKeyCandidates(t *testing.T) {
8 raw string8 raw string
9 want []Key9 want []Key
10 }{10 }{
11- {name: "walks public host to registrable domain", raw: "https://app.docs.example.com/path", want: []Key{"app.docs.example.com", "docs.example.com", "example.com"}},11+ {name: "walks path before public host to registrable domain", raw: "https://app.docs.example.com/path", want: []Key{"app.docs.example.com/path", "app.docs.example.com", "docs.example.com", "example.com"}},
12 {name: "strips www", raw: "https://www.example.com", want: []Key{"example.com"}},12 {name: "strips www", raw: "https://www.example.com", want: []Key{"example.com"}},
13- {name: "localhost with port is exact", raw: "http://localhost:1455/path", want: []Key{"localhost:1455"}},13+ {name: "localhost with port is exact", raw: "http://localhost:1455/path", want: []Key{"localhost:1455/path", "localhost:1455"}},
14- {name: "ip with port is exact", raw: "http://127.0.0.1:8080/path", want: []Key{"127.0.0.1:8080"}},14+ {name: "ip with port is exact", raw: "http://127.0.0.1:8080/path", want: []Key{"127.0.0.1:8080/path", "127.0.0.1:8080"}},
15- {name: "ipv6 with port is exact", raw: "http://[::1]:8080/path", want: []Key{"[::1]:8080"}},15+ {name: "ipv6 with port is exact", raw: "http://[::1]:8080/path", want: []Key{"[::1]:8080/path", "[::1]:8080"}},
16- {name: "uses public suffix for co uk", raw: "https://news.bbc.co.uk/story", want: []Key{"news.bbc.co.uk", "bbc.co.uk"}},16+ {name: "uses public suffix for co uk", raw: "https://news.bbc.co.uk/story", want: []Key{"news.bbc.co.uk/story", "news.bbc.co.uk", "bbc.co.uk"}},
17+ {name: "path candidates walk from exact path to host", raw: "https://github.com/bnema/gordon/pull/123", want: []Key{"github.com/bnema/gordon/pull/123", "github.com/bnema/gordon/pull", "github.com/bnema/gordon", "github.com/bnema", "github.com"}},
18+ {name: "trailing slash normalizes to parent path key", raw: "https://github.com/bnema/gordon/", want: []Key{"github.com/bnema/gordon", "github.com/bnema", "github.com"}},
19+ {name: "path normalization removes dot segments and query fragments", raw: "https://github.com/bnema/./gordon/issues/../pull/123?x#y", want: []Key{"github.com/bnema/gordon/pull/123", "github.com/bnema/gordon/pull", "github.com/bnema/gordon", "github.com/bnema", "github.com"}},
20+ {name: "host-like string preserves path before host fallback", raw: "github.com/bnema?tab=repositories", want: []Key{"github.com/bnema", "github.com"}},
17 21
18- {name: "drops default https port", raw: "https://example.com:443/path", want: []Key{"example.com"}},22+ {name: "drops default https port", raw: "https://example.com:443/path", want: []Key{"example.com/path", "example.com"}},
19- {name: "non default url port is exact", raw: "https://example.com:8443/path", want: []Key{"example.com:8443"}},23+ {name: "non default url port is exact", raw: "https://example.com:8443/path", want: []Key{"example.com:8443/path", "example.com:8443"}},
20 {name: "host-like string lowercases and trims trailing dot", raw: "Docs.Example.COM.", want: []Key{"docs.example.com", "example.com"}},24 {name: "host-like string lowercases and trims trailing dot", raw: "Docs.Example.COM.", want: []Key{"docs.example.com", "example.com"}},
21 {name: "strips www before preserving non default port", raw: "https://www.example.com:8443", want: []Key{"example.com:8443"}},25 {name: "strips www before preserving non default port", raw: "https://www.example.com:8443", want: []Key{"example.com:8443"}},
22 {name: "host-like string with port preserves port exact", raw: "example.com:443", want: []Key{"example.com:443"}},26 {name: "host-like string with port preserves port exact", raw: "example.com:443", want: []Key{"example.com:443"}},
23- {name: "unicode host normalizes to punycode", raw: "https://bücher.example/path", want: []Key{"xn--bcher-kva.example"}},27+ {name: "unicode host normalizes to punycode", raw: "https://bücher.example/path", want: []Key{"xn--bcher-kva.example/path", "xn--bcher-kva.example"}},
24- {name: "punycode equivalent matches unicode", raw: "https://xn--bcher-kva.example/path", want: []Key{"xn--bcher-kva.example"}},28+ {name: "punycode equivalent matches unicode", raw: "https://xn--bcher-kva.example/path", want: []Key{"xn--bcher-kva.example/path", "xn--bcher-kva.example"}},
25 {name: "empty input has no candidates", raw: "", want: nil},29 {name: "empty input has no candidates", raw: "", want: nil},
26 {name: "about url has no candidates", raw: "about:blank", want: nil},30 {name: "about url has no candidates", raw: "about:blank", want: nil},
27 {name: "unsupported scheme has no candidates", raw: "file:///tmp/icon.html", want: nil},31 {name: "unsupported scheme has no candidates", raw: "file:///tmp/icon.html", want: nil},
@@ -47,11 +51,25 @@ func TestCanonicalKey(t *testing.T) {
47 if !ok {51 if !ok {
48 t.Fatal("CanonicalKey returned false")52 t.Fatal("CanonicalKey returned false")
49 }53 }
50- if key != "example.com" {54+ if key != "example.com/path" {
51- t.Fatalf("CanonicalKey = %q, want example.com", key)55+ t.Fatalf("CanonicalKey = %q, want example.com/path", key)
52 }56 }
53 57
54 if key, ok := CanonicalKey("about:blank"); ok || key != "" {58 if key, ok := CanonicalKey("about:blank"); ok || key != "" {
55 t.Fatalf("CanonicalKey(about:blank) = %q, %v; want empty false", key, ok)59 t.Fatalf("CanonicalKey(about:blank) = %q, %v; want empty false", key, ok)
56 }60 }
57 }61 }
62+
63+func TestCanonicalHostKey(t *testing.T) {
64+ key, ok := CanonicalHostKey("https://www.Example.COM.:443/path")
65+ if !ok {
66+ t.Fatal("CanonicalHostKey returned false")
67+ }
68+ if key != "example.com" {
69+ t.Fatalf("CanonicalHostKey = %q, want example.com", key)
70+ }
71+
72+ if key, ok := CanonicalHostKey("about:blank"); ok || key != "" {
73+ t.Fatalf("CanonicalHostKey(about:blank) = %q, %v; want empty false", key, ok)
74+ }
75+}
M internal/domain/url/normalize.go +33 -16
@@ -2,6 +2,8 @@
22 package url
33
44 import (
5+ "crypto/sha256"
6+ "encoding/hex"
57 "fmt"
68 "net"
79 "net/url"
@@ -207,39 +209,54 @@ func DisplayDomain(raw string) string {
207209 return domain
208210 }
209211
210-// SanitizeDomainForFilename converts a domain to a safe filename with .ico extension.
211-// Replaces unsafe filesystem characters with underscores.
212+// maxSanitizedFilenameLen leaves headroom under common 255-byte filename
213+// limits for favicon sidecars such as ".content-type".
214+const maxSanitizedFilenameLen = 240
215+
216+// SanitizeDomainForFilename converts a domain/key to a safe bounded filename with .ico extension.
212217 func SanitizeDomainForFilename(domain string) string {
213- return sanitizeDomain(domain) + ".ico"
218+ return sanitizeDomainWithSuffix(domain, ".ico")
214219 }
215220
216-// SanitizeDomainForPNG converts a domain to a safe filename with .png extension.
221+// SanitizeDomainForPNG converts a domain/key to a safe bounded filename with .png extension.
217222 // Used for favicon export for tools like rofi/fuzzel that require PNG format.
218223 func SanitizeDomainForPNG(domain string) string {
219- return sanitizeDomain(domain) + ".png"
224+ return sanitizeDomainWithSuffix(domain, ".png")
220225 }
221226
222-// sanitizeDomain replaces unsafe filesystem characters with underscores.
227+// sanitizeDomain percent-escapes filesystem-unsafe characters while preserving
228+// ordinary domain filenames. Percent escapes avoid collisions between path keys
229+// such as example.com/a_b and example.com/a/b.
223230 func sanitizeDomain(domain string) string {
224231 replacer := strings.NewReplacer(
225- ":", "_",
226- "/", "_",
227- "\\", "_",
228- "*", "_",
229- "?", "_",
230- "\"", "_",
231- "<", "_",
232- ">", "_",
233- "|", "_",
232+ "%", "%25",
233+ ":", "%3A",
234+ "/", "%2F",
235+ "\\", "%5C",
236+ "*", "%2A",
237+ "?", "%3F",
238+ "\"", "%22",
239+ "<", "%3C",
240+ ">", "%3E",
241+ "|", "%7C",
234242 )
235243 return replacer.Replace(domain)
236244 }
237245
246+func sanitizeDomainWithSuffix(domain, suffix string) string {
247+ sanitized := sanitizeDomain(domain)
248+ if len(sanitized)+len(suffix) <= maxSanitizedFilenameLen {
249+ return sanitized + suffix
250+ }
251+ sum := sha256.Sum256([]byte(domain))
252+ return "sha256-" + hex.EncodeToString(sum[:]) + suffix
253+}
254+
238255 // SanitizeDomainForPNGSized converts a domain to a safe filename with size suffix.
239256 // Example: "google.com" with size 32 -> "google.com.32.png"
240257 // Used for normalized favicon export for tools like rofi/fuzzel.
241258 func SanitizeDomainForPNGSized(domain string, size int) string {
242- return fmt.Sprintf("%s.%d.png", sanitizeDomain(domain), size)
259+ return sanitizeDomainWithSuffix(domain, fmt.Sprintf(".%d.png", size))
243260 }
244261
245262 // TrimLeadingSpacesIfURL removes leading whitespace from input if the trimmed
@@ -2,6 +2,8 @@
2 package url2 package url
3 3
4 import (4 import (
5+ "crypto/sha256"
6+ "encoding/hex"
5 "fmt"7 "fmt"
6 "net"8 "net"
7 "net/url"9 "net/url"
@@ -207,39 +209,54 @@ func DisplayDomain(raw string) string {
207 return domain209 return domain
208 }210 }
209 211
210-// SanitizeDomainForFilename converts a domain to a safe filename with .ico extension.212+// maxSanitizedFilenameLen leaves headroom under common 255-byte filename
211-// Replaces unsafe filesystem characters with underscores.213+// limits for favicon sidecars such as ".content-type".
214+const maxSanitizedFilenameLen = 240
215+
216+// SanitizeDomainForFilename converts a domain/key to a safe bounded filename with .ico extension.
212 func SanitizeDomainForFilename(domain string) string {217 func SanitizeDomainForFilename(domain string) string {
213- return sanitizeDomain(domain) + ".ico"218+ return sanitizeDomainWithSuffix(domain, ".ico")
214 }219 }
215 220
216-// SanitizeDomainForPNG converts a domain to a safe filename with .png extension.221+// SanitizeDomainForPNG converts a domain/key to a safe bounded filename with .png extension.
217 // Used for favicon export for tools like rofi/fuzzel that require PNG format.222 // Used for favicon export for tools like rofi/fuzzel that require PNG format.
218 func SanitizeDomainForPNG(domain string) string {223 func SanitizeDomainForPNG(domain string) string {
219- return sanitizeDomain(domain) + ".png"224+ return sanitizeDomainWithSuffix(domain, ".png")
220 }225 }
221 226
222-// sanitizeDomain replaces unsafe filesystem characters with underscores.227+// sanitizeDomain percent-escapes filesystem-unsafe characters while preserving
228+// ordinary domain filenames. Percent escapes avoid collisions between path keys
229+// such as example.com/a_b and example.com/a/b.
223 func sanitizeDomain(domain string) string {230 func sanitizeDomain(domain string) string {
224 replacer := strings.NewReplacer(231 replacer := strings.NewReplacer(
225- ":", "_",232+ "%", "%25",
226- "/", "_",233+ ":", "%3A",
227- "\\", "_",234+ "/", "%2F",
228- "*", "_",235+ "\\", "%5C",
229- "?", "_",236+ "*", "%2A",
230- "\"", "_",237+ "?", "%3F",
231- "<", "_",238+ "\"", "%22",
232- ">", "_",239+ "<", "%3C",
233- "|", "_",240+ ">", "%3E",
241+ "|", "%7C",
234 )242 )
235 return replacer.Replace(domain)243 return replacer.Replace(domain)
236 }244 }
237 245
246+func sanitizeDomainWithSuffix(domain, suffix string) string {
247+ sanitized := sanitizeDomain(domain)
248+ if len(sanitized)+len(suffix) <= maxSanitizedFilenameLen {
249+ return sanitized + suffix
250+ }
251+ sum := sha256.Sum256([]byte(domain))
252+ return "sha256-" + hex.EncodeToString(sum[:]) + suffix
253+}
254+
238 // SanitizeDomainForPNGSized converts a domain to a safe filename with size suffix.255 // SanitizeDomainForPNGSized converts a domain to a safe filename with size suffix.
239 // Example: "google.com" with size 32 -> "google.com.32.png"256 // Example: "google.com" with size 32 -> "google.com.32.png"
240 // Used for normalized favicon export for tools like rofi/fuzzel.257 // Used for normalized favicon export for tools like rofi/fuzzel.
241 func SanitizeDomainForPNGSized(domain string, size int) string {258 func SanitizeDomainForPNGSized(domain string, size int) string {
242- return fmt.Sprintf("%s.%d.png", sanitizeDomain(domain), size)259+ return sanitizeDomainWithSuffix(domain, fmt.Sprintf(".%d.png", size))
243 }260 }
244 261
245 // TrimLeadingSpacesIfURL removes leading whitespace from input if the trimmed262 // TrimLeadingSpacesIfURL removes leading whitespace from input if the trimmed
M internal/domain/url/normalize_test.go +48 -0
@@ -3,6 +3,7 @@ package url
33 import (
44 "os"
55 "path/filepath"
6+ "strings"
67 "testing"
78 )
89
@@ -478,6 +479,53 @@ func TestNormalize_IPAddressGetsHTTPByDefault(t *testing.T) {
478479 }
479480 }
480481
482+func TestSanitizeDomainForPNGPathKeysDoNotCollide(t *testing.T) {
483+ pathKey := SanitizeDomainForPNG("example.com/a/b")
484+ underscoreKey := SanitizeDomainForPNG("example.com/a_b")
485+ if pathKey == underscoreKey {
486+ t.Fatalf("path key filename collision: %q", pathKey)
487+ }
488+ if pathKey != "example.com%2Fa%2Fb.png" {
489+ t.Fatalf("path key filename = %q, want example.com%%2Fa%%2Fb.png", pathKey)
490+ }
491+ if underscoreKey != "example.com%2Fa_b.png" {
492+ t.Fatalf("underscore key filename = %q, want example.com%%2Fa_b.png", underscoreKey)
493+ }
494+}
495+
496+func TestSanitizeDomainForLongPathKeysUsesBoundedDeterministicFilenames(t *testing.T) {
497+ longKeyA := "example.com/" + strings.Repeat("very-long-segment/", 24) + "alpha"
498+ longKeyB := "example.com/" + strings.Repeat("very-long-segment/", 24) + "beta"
499+
500+ pngA := SanitizeDomainForPNG(longKeyA)
501+ pngB := SanitizeDomainForPNG(longKeyB)
502+ if len(pngA) > 240 {
503+ t.Fatalf("png filename too long: len=%d name=%q", len(pngA), pngA)
504+ }
505+ if pngA == pngB {
506+ t.Fatalf("distinct long keys collided: %q", pngA)
507+ }
508+ if !strings.HasSuffix(pngA, ".png") {
509+ t.Fatalf("png filename missing suffix: %q", pngA)
510+ }
511+
512+ ico := SanitizeDomainForFilename(longKeyA)
513+ if len(ico) > 240 {
514+ t.Fatalf("ico filename too long: len=%d name=%q", len(ico), ico)
515+ }
516+ if !strings.HasSuffix(ico, ".ico") {
517+ t.Fatalf("ico filename missing suffix: %q", ico)
518+ }
519+
520+ sized := SanitizeDomainForPNGSized(longKeyA, 32)
521+ if len(sized) > 240 {
522+ t.Fatalf("sized png filename too long: len=%d name=%q", len(sized), sized)
523+ }
524+ if !strings.HasSuffix(sized, ".32.png") {
525+ t.Fatalf("sized png filename missing suffix: %q", sized)
526+ }
527+}
528+
481529 func TestIsExternalScheme(t *testing.T) {
482530 tests := []struct {
483531 name string
@@ -3,6 +3,7 @@ package url
3 import (3 import (
4 "os"4 "os"
5 "path/filepath"5 "path/filepath"
6+ "strings"
6 "testing"7 "testing"
7 )8 )
8 9
@@ -478,6 +479,53 @@ func TestNormalize_IPAddressGetsHTTPByDefault(t *testing.T) {
478 }479 }
479 }480 }
480 481
482+func TestSanitizeDomainForPNGPathKeysDoNotCollide(t *testing.T) {
483+ pathKey := SanitizeDomainForPNG("example.com/a/b")
484+ underscoreKey := SanitizeDomainForPNG("example.com/a_b")
485+ if pathKey == underscoreKey {
486+ t.Fatalf("path key filename collision: %q", pathKey)
487+ }
488+ if pathKey != "example.com%2Fa%2Fb.png" {
489+ t.Fatalf("path key filename = %q, want example.com%%2Fa%%2Fb.png", pathKey)
490+ }
491+ if underscoreKey != "example.com%2Fa_b.png" {
492+ t.Fatalf("underscore key filename = %q, want example.com%%2Fa_b.png", underscoreKey)
493+ }
494+}
495+
496+func TestSanitizeDomainForLongPathKeysUsesBoundedDeterministicFilenames(t *testing.T) {
497+ longKeyA := "example.com/" + strings.Repeat("very-long-segment/", 24) + "alpha"
498+ longKeyB := "example.com/" + strings.Repeat("very-long-segment/", 24) + "beta"
499+
500+ pngA := SanitizeDomainForPNG(longKeyA)
501+ pngB := SanitizeDomainForPNG(longKeyB)
502+ if len(pngA) > 240 {
503+ t.Fatalf("png filename too long: len=%d name=%q", len(pngA), pngA)
504+ }
505+ if pngA == pngB {
506+ t.Fatalf("distinct long keys collided: %q", pngA)
507+ }
508+ if !strings.HasSuffix(pngA, ".png") {
509+ t.Fatalf("png filename missing suffix: %q", pngA)
510+ }
511+
512+ ico := SanitizeDomainForFilename(longKeyA)
513+ if len(ico) > 240 {
514+ t.Fatalf("ico filename too long: len=%d name=%q", len(ico), ico)
515+ }
516+ if !strings.HasSuffix(ico, ".ico") {
517+ t.Fatalf("ico filename missing suffix: %q", ico)
518+ }
519+
520+ sized := SanitizeDomainForPNGSized(longKeyA, 32)
521+ if len(sized) > 240 {
522+ t.Fatalf("sized png filename too long: len=%d name=%q", len(sized), sized)
523+ }
524+ if !strings.HasSuffix(sized, ".32.png") {
525+ t.Fatalf("sized png filename missing suffix: %q", sized)
526+ }
527+}
528+
481 func TestIsExternalScheme(t *testing.T) {529 func TestIsExternalScheme(t *testing.T) {
482 tests := []struct {530 tests := []struct {
483 name string531 name string
M internal/infrastructure/favicon/fetcher.go +16 -9
@@ -68,7 +68,7 @@ func (f *Fetcher) safeTransport(base http.RoundTripper) http.RoundTripper {
6868 }
6969
7070 func (f *Fetcher) Fetch(ctx context.Context, req appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) {
71- fetchURL, source, err := f.resolveURL(req)
71+ fetchURL, resolvedKey, source, err := f.resolveURL(req)
7272 if err != nil {
7373 return nil, err
7474 }
@@ -76,7 +76,14 @@ func (f *Fetcher) Fetch(ctx context.Context, req appport.FaviconFetchRequest) (*
7676 if err != nil {
7777 return nil, err
7878 }
79- return &appport.FaviconFetchedIcon{PageURL: req.PageURL, IconURL: fetchURL, Bytes: data, Source: source, ContentType: ct}, nil
79+ return &appport.FaviconFetchedIcon{
80+ PageURL: req.PageURL,
81+ IconURL: fetchURL,
82+ ResolvedKey: resolvedKey,
83+ Bytes: data,
84+ Source: source,
85+ ContentType: ct,
86+ }, nil
8087 }
8188
8289 // FetchDomain preserves the old DuckDuckGo-only API for legacy callers.
@@ -94,22 +101,22 @@ func (f *Fetcher) FetchDomain(ctx context.Context, domain string) ([]byte, error
94101 return got.Bytes, nil
95102 }
96103
97-func (f *Fetcher) resolveURL(req appport.FaviconFetchRequest) (string, domainfavicon.Source, error) {
104+func (f *Fetcher) resolveURL(req appport.FaviconFetchRequest) (string, domainfavicon.Key, domainfavicon.Source, error) {
98105 if req.IconURL != "" {
99106 u, err := url.Parse(req.IconURL)
100107 if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" {
101- return "", "", appport.ErrFaviconMiss
108+ return "", "", "", appport.ErrFaviconMiss
102109 }
103110 if !allowedURLForPage(req.PageURL, u) {
104- return "", "", appport.ErrFaviconMiss
111+ return "", "", "", appport.ErrFaviconMiss
105112 }
106- return u.String(), domainfavicon.SourcePageDiscovery, nil
113+ return u.String(), "", domainfavicon.SourcePageDiscovery, nil
107114 }
108- key, ok := domainfavicon.CanonicalKey(req.PageURL)
115+ key, ok := domainfavicon.CanonicalHostKey(req.PageURL)
109116 if !ok {
110- return "", "", appport.ErrFaviconMiss
117+ return "", "", "", appport.ErrFaviconMiss
111118 }
112- return fmt.Sprintf(f.duckDuckGoURL, url.QueryEscape(string(key))), domainfavicon.SourceDuckDuckGo, nil
119+ return fmt.Sprintf(f.duckDuckGoURL, url.QueryEscape(string(key))), key, domainfavicon.SourceDuckDuckGo, nil
113120 }
114121
115122 func (f *Fetcher) fetchURL(ctx context.Context, raw, pageURL string) ([]byte, string, error) {
@@ -68,7 +68,7 @@ func (f *Fetcher) safeTransport(base http.RoundTripper) http.RoundTripper {
68 }68 }
69 69
70 func (f *Fetcher) Fetch(ctx context.Context, req appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) {70 func (f *Fetcher) Fetch(ctx context.Context, req appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) {
71- fetchURL, source, err := f.resolveURL(req)71+ fetchURL, resolvedKey, source, err := f.resolveURL(req)
72 if err != nil {72 if err != nil {
73 return nil, err73 return nil, err
74 }74 }
@@ -76,7 +76,14 @@ func (f *Fetcher) Fetch(ctx context.Context, req appport.FaviconFetchRequest) (*
76 if err != nil {76 if err != nil {
77 return nil, err77 return nil, err
78 }78 }
79- return &appport.FaviconFetchedIcon{PageURL: req.PageURL, IconURL: fetchURL, Bytes: data, Source: source, ContentType: ct}, nil79+ return &appport.FaviconFetchedIcon{
80+ PageURL: req.PageURL,
81+ IconURL: fetchURL,
82+ ResolvedKey: resolvedKey,
83+ Bytes: data,
84+ Source: source,
85+ ContentType: ct,
86+ }, nil
80 }87 }
81 88
82 // FetchDomain preserves the old DuckDuckGo-only API for legacy callers.89 // FetchDomain preserves the old DuckDuckGo-only API for legacy callers.
@@ -94,22 +101,22 @@ func (f *Fetcher) FetchDomain(ctx context.Context, domain string) ([]byte, error
94 return got.Bytes, nil101 return got.Bytes, nil
95 }102 }
96 103
97-func (f *Fetcher) resolveURL(req appport.FaviconFetchRequest) (string, domainfavicon.Source, error) {104+func (f *Fetcher) resolveURL(req appport.FaviconFetchRequest) (string, domainfavicon.Key, domainfavicon.Source, error) {
98 if req.IconURL != "" {105 if req.IconURL != "" {
99 u, err := url.Parse(req.IconURL)106 u, err := url.Parse(req.IconURL)
100 if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" {107 if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" {
101- return "", "", appport.ErrFaviconMiss108+ return "", "", "", appport.ErrFaviconMiss
102 }109 }
103 if !allowedURLForPage(req.PageURL, u) {110 if !allowedURLForPage(req.PageURL, u) {
104- return "", "", appport.ErrFaviconMiss111+ return "", "", "", appport.ErrFaviconMiss
105 }112 }
106- return u.String(), domainfavicon.SourcePageDiscovery, nil113+ return u.String(), "", domainfavicon.SourcePageDiscovery, nil
107 }114 }
108- key, ok := domainfavicon.CanonicalKey(req.PageURL)115+ key, ok := domainfavicon.CanonicalHostKey(req.PageURL)
109 if !ok {116 if !ok {
110- return "", "", appport.ErrFaviconMiss117+ return "", "", "", appport.ErrFaviconMiss
111 }118 }
112- return fmt.Sprintf(f.duckDuckGoURL, url.QueryEscape(string(key))), domainfavicon.SourceDuckDuckGo, nil119+ return fmt.Sprintf(f.duckDuckGoURL, url.QueryEscape(string(key))), key, domainfavicon.SourceDuckDuckGo, nil
113 }120 }
114 121
115 func (f *Fetcher) fetchURL(ctx context.Context, raw, pageURL string) ([]byte, string, error) {122 func (f *Fetcher) fetchURL(ctx context.Context, raw, pageURL string) ([]byte, string, error) {
M internal/infrastructure/favicon/fetcher_test.go +19 -0
@@ -10,6 +10,7 @@ import (
1010 "testing"
1111
1212 appport "github.com/bnema/dumber/internal/application/port"
13+ domainfavicon "github.com/bnema/dumber/internal/domain/favicon"
1314 )
1415
1516 func TestFetcherExplicitIconURLAndDuckDuckGoFallback(t *testing.T) {
@@ -31,6 +32,24 @@ func TestFetcherExplicitIconURLAndDuckDuckGoFallback(t *testing.T) {
3132 }
3233 }
3334
35+func TestFetcherResolveURLUsesHostKeyForDuckDuckGoFallback(t *testing.T) {
36+ fetcher := NewFetcherWithClient(http.DefaultClient, "https://icons.example/%s.ico")
37+
38+ got, resolvedKey, source, err := fetcher.resolveURL(appport.FaviconFetchRequest{PageURL: "https://github.com/bnema/gordon/pull/123"})
39+ if err != nil {
40+ t.Fatal(err)
41+ }
42+ if source != domainfavicon.SourceDuckDuckGo {
43+ t.Fatalf("source = %q, want %q", source, domainfavicon.SourceDuckDuckGo)
44+ }
45+ if resolvedKey != "github.com" {
46+ t.Fatalf("resolvedKey = %q, want github.com", resolvedKey)
47+ }
48+ if got != "https://icons.example/github.com.ico" {
49+ t.Fatalf("duckduckgo URL = %q, want host-based fallback URL", got)
50+ }
51+}
52+
3453 func TestFetcherMissesAndGuards(t *testing.T) {
3554 fetcher := NewFetcher()
3655 if _, err := fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: "https://example.com", IconURL: "file:///x"}); !errors.Is(err, appport.ErrFaviconMiss) {
@@ -10,6 +10,7 @@ import (
10 "testing"10 "testing"
11 11
12 appport "github.com/bnema/dumber/internal/application/port"12 appport "github.com/bnema/dumber/internal/application/port"
13+ domainfavicon "github.com/bnema/dumber/internal/domain/favicon"
13 )14 )
14 15
15 func TestFetcherExplicitIconURLAndDuckDuckGoFallback(t *testing.T) {16 func TestFetcherExplicitIconURLAndDuckDuckGoFallback(t *testing.T) {
@@ -31,6 +32,24 @@ func TestFetcherExplicitIconURLAndDuckDuckGoFallback(t *testing.T) {
31 }32 }
32 }33 }
33 34
35+func TestFetcherResolveURLUsesHostKeyForDuckDuckGoFallback(t *testing.T) {
36+ fetcher := NewFetcherWithClient(http.DefaultClient, "https://icons.example/%s.ico")
37+
38+ got, resolvedKey, source, err := fetcher.resolveURL(appport.FaviconFetchRequest{PageURL: "https://github.com/bnema/gordon/pull/123"})
39+ if err != nil {
40+ t.Fatal(err)
41+ }
42+ if source != domainfavicon.SourceDuckDuckGo {
43+ t.Fatalf("source = %q, want %q", source, domainfavicon.SourceDuckDuckGo)
44+ }
45+ if resolvedKey != "github.com" {
46+ t.Fatalf("resolvedKey = %q, want github.com", resolvedKey)
47+ }
48+ if got != "https://icons.example/github.com.ico" {
49+ t.Fatalf("duckduckgo URL = %q, want host-based fallback URL", got)
50+ }
51+}
52+
34 func TestFetcherMissesAndGuards(t *testing.T) {53 func TestFetcherMissesAndGuards(t *testing.T) {
35 fetcher := NewFetcher()54 fetcher := NewFetcher()
36 if _, err := fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: "https://example.com", IconURL: "file:///x"}); !errors.Is(err, appport.ErrFaviconMiss) {55 if _, err := fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: "https://example.com", IconURL: "file:///x"}); !errors.Is(err, appport.ErrFaviconMiss) {
M internal/ui/adapter/favicon.go +124 -78
@@ -23,7 +23,6 @@ type FaviconAdapter struct {
2323 resolver port.FaviconResolver
2424 faviconDB port.FaviconDatabase
2525 textureCache map[string]*gdk.Texture
26- aliasesByKey map[favicon.Key]map[string]struct{}
2726 mu sync.RWMutex
2827 warnMu sync.Mutex
2928 warnCounts map[string]int
@@ -75,7 +74,6 @@ func NewFaviconAdapterWithResolver(
7574 resolver: resolver,
7675 faviconDB: faviconDB,
7776 textureCache: make(map[string]*gdk.Texture),
78- aliasesByKey: make(map[favicon.Key]map[string]struct{}),
7977 warnCounts: make(map[string]int),
8078 isInternalURL: cfg.IsInternalURL,
8179 internalDomain: cfg.InternalDomain,
@@ -97,19 +95,41 @@ func (a *FaviconAdapter) GetTexture(domain string) *gdk.Texture {
9795 return texture
9896 }
9997
100-// GetTextureByURL returns a cached texture by extracting domain from URL.
101-// For internal dumb:// URLs, returns the app logo texture.
98+// GetTextureByURL returns a cached texture for the most specific URL key,
99+// falling back toward the domain key. For internal dumb:// URLs, returns the
100+// app logo texture.
102101 func (a *FaviconAdapter) GetTextureByURL(pageURL string) *gdk.Texture {
103- // Handle internal dumb:// scheme URLs
102+ texture, _, _ := a.getTextureForURL(pageURL)
103+ return texture
104+}
105+
106+func (a *FaviconAdapter) getTextureForURL(pageURL string) (*gdk.Texture, favicon.Key, bool) {
104107 if a.isInternalURL != nil && a.isInternalURL(pageURL) {
105- return a.GetTexture(a.internalDomain)
108+ texture := a.GetTexture(a.internalDomain)
109+ return texture, favicon.Key(a.internalDomain), texture != nil
106110 }
107- domain := domainurl.ExtractDomain(pageURL)
108- return a.GetTexture(domain)
111+ exactKey, _ := favicon.CanonicalKey(pageURL)
112+ for _, key := range favicon.Candidates(pageURL) {
113+ if texture := a.GetTexture(string(key)); texture != nil {
114+ return texture, key, exactKey != "" && key == exactKey
115+ }
116+ }
117+ return nil, "", false
118+}
119+
120+func (a *FaviconAdapter) setTextureForURL(pageURL string, texture *gdk.Texture) {
121+ key, ok := favicon.CanonicalKey(pageURL)
122+ if !ok {
123+ return
124+ }
125+ a.setTexture(string(key), texture)
109126 }
110127
111128 // GetOrFetch retrieves a favicon texture, checking caches and fetching if needed.
112-// The callback is invoked on the GTK main thread with the texture (or nil).
129+// The callback is invoked on the GTK main thread with the best currently
130+// available texture (or nil). When a fallback texture is shown first and a more
131+// specific exact engine favicon is discovered asynchronously, the callback may
132+// be invoked a second time with that exact update.
113133 // For internal dumb:// URLs, returns the app logo texture.
114134 func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callback func(*gdk.Texture)) {
115135 if callback == nil {
@@ -137,18 +157,27 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
137157 Str("domain", domain).
138158 Msg("favicon: GetOrFetch begin")
139159
140- // Check texture cache first
141- if texture := a.GetTexture(domain); texture != nil {
142- log.Debug().Str("domain", domain).Msg("favicon: texture cache hit")
143- // Ensure sized PNG exists for CLI tools (async, idempotent)
144- go a.ensureSizedPNG(ctx, domain)
160+ // Exact in-memory hits can short-circuit immediately. Fallback hits still
161+ // consult the resolver first so persisted exact/path-specific favicons win.
162+ if texture, key, exact := a.getTextureForURL(pageURL); texture != nil && exact {
163+ log.Debug().Str("domain", domain).Str("key", string(key)).Msg("favicon: exact texture cache hit")
164+ go a.ensureSizedPNG(ctx, string(key))
165+ callback(texture)
166+ return
167+ }
168+
169+ // Check application resolver before using any cached parent/domain fallback.
170+ if texture, key := a.resolveTexture(ctx, pageURL, true); texture != nil {
145171 callback(texture)
172+ a.probeExactEngineFavicon(ctx, pageURL, domain, key, callback)
146173 return
147174 }
148175
149- // Check application resolver before falling back to the legacy service.
150- if texture := a.resolveTexture(ctx, pageURL, true); texture != nil {
176+ if texture, key, _ := a.getTextureForURL(pageURL); texture != nil {
177+ log.Debug().Str("domain", domain).Str("key", string(key)).Msg("favicon: fallback texture cache hit")
178+ go a.ensureSizedPNG(ctx, string(key))
151179 callback(texture)
180+ a.probeExactEngineFavicon(ctx, pageURL, domain, key, callback)
152181 return
153182 }
154183
@@ -165,6 +194,7 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
165194 // Ensure sized PNG exists for CLI tools (async, idempotent)
166195 go a.ensureSizedPNG(ctx, domain)
167196 callback(texture)
197+ a.probeExactEngineFavicon(ctx, pageURL, domain, favicon.Key(domain), callback)
168198 return
169199 }
170200 }
@@ -172,9 +202,7 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
172202
173203 // Try engine FaviconDatabase if available
174204 if a.faviconDB != nil {
175- a.faviconDB.GetFaviconAsync(pageURL, func(tex port.Texture) {
176- a.handleEngineFaviconDBTexture(ctx, pageURL, domain, callback, tex)
177- })
205+ a.queryEngineFaviconDB(ctx, pageURL, domain, callback, true)
178206 return
179207 }
180208
@@ -222,9 +250,9 @@ func (a *FaviconAdapter) RefreshFromIconURLs(ctx context.Context, pageURL string
222250 }()
223251 }
224252
225-func (a *FaviconAdapter) resolveTexture(ctx context.Context, pageURL string, scheduleRefresh bool) *gdk.Texture {
253+func (a *FaviconAdapter) resolveTexture(ctx context.Context, pageURL string, scheduleRefresh bool) (*gdk.Texture, favicon.Key) {
226254 if a.resolver == nil {
227- return nil
255+ return nil, ""
228256 }
229257 options := port.FaviconResolveOptions{Purpose: port.FaviconResolvePurposeUI}
230258 if scheduleRefresh {
@@ -232,13 +260,13 @@ func (a *FaviconAdapter) resolveTexture(ctx context.Context, pageURL string, sch
232260 }
233261 resolved, err := a.resolver.Resolve(ctx, pageURL, 0, options)
234262 if err != nil || resolved == nil || len(resolved.Bytes) == 0 {
235- return nil
263+ return nil, ""
236264 }
237265 texture := a.textureFromBytesOnGTK(ctx, resolved.Bytes)
238266 if texture != nil {
239267 a.setResolvedTexture(pageURL, resolved.Key, texture)
240268 }
241- return texture
269+ return texture, resolved.Key
242270 }
243271
244272 func (a *FaviconAdapter) fetchViaService(ctx context.Context, domain string, callback func(*gdk.Texture)) {
@@ -276,11 +304,39 @@ func (a *FaviconAdapter) fetchViaService(ctx context.Context, domain string, cal
276304 }()
277305 }
278306
307+func (a *FaviconAdapter) probeExactEngineFavicon(
308+ ctx context.Context,
309+ pageURL, domain string,
310+ resolvedKey favicon.Key,
311+ callback func(*gdk.Texture),
312+) {
313+ if a.faviconDB == nil {
314+ return
315+ }
316+ exactKey, ok := favicon.CanonicalKey(pageURL)
317+ if !ok || exactKey == resolvedKey {
318+ return
319+ }
320+ a.queryEngineFaviconDB(ctx, pageURL, domain, callback, false)
321+}
322+
323+func (a *FaviconAdapter) queryEngineFaviconDB(
324+ ctx context.Context,
325+ pageURL, domain string,
326+ callback func(*gdk.Texture),
327+ fetchViaServiceOnMiss bool,
328+) {
329+ a.faviconDB.GetFaviconAsync(pageURL, func(tex port.Texture) {
330+ a.handleEngineFaviconDBTexture(ctx, pageURL, domain, callback, tex, fetchViaServiceOnMiss)
331+ })
332+}
333+
279334 func (a *FaviconAdapter) handleEngineFaviconDBTexture(
280335 ctx context.Context,
281336 pageURL, domain string,
282337 callback func(*gdk.Texture),
283338 tex port.Texture,
339+ fetchViaServiceOnMiss bool,
284340 ) {
285341 log := logging.FromContext(ctx)
286342 log.Debug().
@@ -288,17 +344,25 @@ func (a *FaviconAdapter) handleEngineFaviconDBTexture(
288344 Bool("texture_nil", tex == nil).
289345 Msg("favicon: engine db callback")
290346 if tex == nil {
291- log.Debug().Str("domain", domain).Msg("favicon not in engine db, fetching via service")
292- a.fetchViaService(ctx, domain, callback)
347+ if fetchViaServiceOnMiss {
348+ log.Debug().Str("domain", domain).Msg("favicon not in engine db, fetching via service")
349+ a.fetchViaService(ctx, domain, callback)
350+ }
293351 return
294352 }
295353 // Convert port.Texture to *gdk.Texture
296354 gdkTex, ok := tex.(*gdk.Texture)
297355 if !ok {
298- a.fetchViaService(ctx, domain, callback)
356+ if fetchViaServiceOnMiss {
357+ a.fetchViaService(ctx, domain, callback)
358+ }
299359 return
300360 }
301- a.setTexture(domain, gdkTex)
361+ if a.resolver != nil {
362+ a.setTextureForURL(pageURL, gdkTex)
363+ } else {
364+ a.setTexture(domain, gdkTex)
365+ }
302366 a.invokeCallbackOnGTK(ctx, domain, callback, gdkTex)
303367 a.storeTextureObservation(ctx, pageURL, gdkTex)
304368 }
@@ -321,15 +385,14 @@ func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, te
321385 return
322386 }
323387
324- // Store in texture cache
325- a.setTexture(domain, texture)
326-
327388 if a.resolver != nil {
389+ a.setTextureForURL(pageURL, texture)
328390 a.storeTextureObservation(ctx, pageURL, texture)
329391 return
330392 }
331393
332394 // Legacy fallback for callers not yet wired to the application resolver.
395+ a.setTexture(domain, texture)
333396 a.saveFaviconToDisk(ctx, domain, texture)
334397 }
335398
@@ -340,18 +403,21 @@ func (a *FaviconAdapter) StoreFromWebKitWithOrigin(
340403 ) {
341404 a.StoreFromWebKit(ctx, currentURL, texture)
342405
343- // Also store under original URL domain if different
344- if originURL != "" && originURL != currentURL {
345- originDomain := domainurl.ExtractDomain(originURL)
346- currentDomain := domainurl.ExtractDomain(currentURL)
347- if originDomain != "" && originDomain != currentDomain {
348- a.setTexture(originDomain, texture)
349- if a.resolver != nil {
350- a.storeTextureObservation(ctx, originURL, texture)
351- } else {
352- a.saveFaviconToDisk(ctx, originDomain, texture)
353- }
354- }
406+ if originURL == "" || originURL == currentURL {
407+ return
408+ }
409+ if a.resolver != nil {
410+ // In path-aware resolver mode, redirect target favicons may carry state.
411+ // Keep them scoped to the current URL only; aliasing them onto the origin
412+ // URL would leak exact/path-specific state across sibling paths or hosts.
413+ return
414+ }
415+
416+ originDomain := domainurl.ExtractDomain(originURL)
417+ currentDomain := domainurl.ExtractDomain(currentURL)
418+ if originDomain != "" && originDomain != currentDomain {
419+ a.setTexture(originDomain, texture)
420+ a.saveFaviconToDisk(ctx, originDomain, texture)
355421 }
356422 }
357423
@@ -471,14 +537,18 @@ func (a *FaviconAdapter) PreloadFromCache(ctx context.Context, pageURL string) *
471537 Str("domain", domain).
472538 Msg("favicon: PreloadFromCache begin")
473539
474- // Check texture cache
475- if texture := a.GetTexture(domain); texture != nil {
476- log.Debug().Str("domain", domain).Msg("favicon: PreloadFromCache texture hit")
540+ if texture, key, exact := a.getTextureForURL(pageURL); texture != nil && exact {
541+ log.Debug().Str("domain", domain).Str("key", string(key)).Msg("favicon: PreloadFromCache exact texture hit")
542+ return texture
543+ }
544+
545+ // Check application resolver before falling back to cached parent/domain entries.
546+ if texture, _ := a.resolveTexture(ctx, pageURL, false); texture != nil {
477547 return texture
478548 }
479549
480- // Check application resolver before falling back to the legacy service.
481- if texture := a.resolveTexture(ctx, pageURL, false); texture != nil {
550+ if texture, key, _ := a.getTextureForURL(pageURL); texture != nil {
551+ log.Debug().Str("domain", domain).Str("key", string(key)).Msg("favicon: PreloadFromCache fallback texture hit")
482552 return texture
483553 }
484554
@@ -514,15 +584,10 @@ func (a *FaviconAdapter) Close() {
514584 }
515585 }
516586
517-// Invalidate clears one resolved-key texture cache entry and all domain aliases
518-// explicitly bound to that canonical favicon key.
587+// Invalidate clears one resolved-key texture cache entry.
519588 func (a *FaviconAdapter) Invalidate(_ context.Context, key favicon.Key) error {
520589 a.mu.Lock()
521590 delete(a.textureCache, string(key))
522- for alias := range a.aliasesByKey[key] {
523- delete(a.textureCache, alias)
524- }
525- delete(a.aliasesByKey, key)
526591 a.mu.Unlock()
527592 return nil
528593 }
@@ -531,7 +596,6 @@ func (a *FaviconAdapter) Invalidate(_ context.Context, key favicon.Key) error {
531596 func (a *FaviconAdapter) Clear() {
532597 a.mu.Lock()
533598 a.textureCache = make(map[string]*gdk.Texture)
534- a.aliasesByKey = make(map[favicon.Key]map[string]struct{})
535599 a.mu.Unlock()
536600 }
537601
@@ -543,35 +607,17 @@ func (a *FaviconAdapter) Size() int {
543607 return size
544608 }
545609
546-// setResolvedTexture stores resolver results under both the resolved favicon key
547-// and the current page domain. Aliases are indexed by canonical key so
548-// invalidation does not depend on pointer equality with the latest texture.
549-func (a *FaviconAdapter) setResolvedTexture(pageURL string, key favicon.Key, texture *gdk.Texture) {
610+// setResolvedTexture stores resolver results only under the resolved favicon
611+// key. Exact/parent/domain fallback ordering is handled by favicon.Candidates
612+// during lookup, so synthetic aliases would only risk masking more specific
613+// entries that arrive later.
614+func (a *FaviconAdapter) setResolvedTexture(_ string, key favicon.Key, texture *gdk.Texture) {
550615 if key == "" || texture == nil {
551616 return
552617 }
553- domain := domainurl.ExtractDomain(pageURL)
554618 a.mu.Lock()
555- defer a.mu.Unlock()
556619 a.textureCache[string(key)] = texture
557- if domain == "" {
558- return
559- }
560- a.removeAliasLocked(domain)
561- a.textureCache[domain] = texture
562- if a.aliasesByKey[key] == nil {
563- a.aliasesByKey[key] = make(map[string]struct{})
564- }
565- a.aliasesByKey[key][domain] = struct{}{}
566-}
567-
568-func (a *FaviconAdapter) removeAliasLocked(alias string) {
569- for key, aliases := range a.aliasesByKey {
570- delete(aliases, alias)
571- if len(aliases) == 0 {
572- delete(a.aliasesByKey, key)
573- }
574- }
620+ a.mu.Unlock()
575621 }
576622
577623 // setTexture stores a texture in the cache.
@@ -23,7 +23,6 @@ type FaviconAdapter struct {
23 resolver port.FaviconResolver23 resolver port.FaviconResolver
24 faviconDB port.FaviconDatabase24 faviconDB port.FaviconDatabase
25 textureCache map[string]*gdk.Texture25 textureCache map[string]*gdk.Texture
26- aliasesByKey map[favicon.Key]map[string]struct{}
27 mu sync.RWMutex26 mu sync.RWMutex
28 warnMu sync.Mutex27 warnMu sync.Mutex
29 warnCounts map[string]int28 warnCounts map[string]int
@@ -75,7 +74,6 @@ func NewFaviconAdapterWithResolver(
75 resolver: resolver,74 resolver: resolver,
76 faviconDB: faviconDB,75 faviconDB: faviconDB,
77 textureCache: make(map[string]*gdk.Texture),76 textureCache: make(map[string]*gdk.Texture),
78- aliasesByKey: make(map[favicon.Key]map[string]struct{}),
79 warnCounts: make(map[string]int),77 warnCounts: make(map[string]int),
80 isInternalURL: cfg.IsInternalURL,78 isInternalURL: cfg.IsInternalURL,
81 internalDomain: cfg.InternalDomain,79 internalDomain: cfg.InternalDomain,
@@ -97,19 +95,41 @@ func (a *FaviconAdapter) GetTexture(domain string) *gdk.Texture {
97 return texture95 return texture
98 }96 }
99 97
100-// GetTextureByURL returns a cached texture by extracting domain from URL.98+// GetTextureByURL returns a cached texture for the most specific URL key,
101-// For internal dumb:// URLs, returns the app logo texture.99+// falling back toward the domain key. For internal dumb:// URLs, returns the
100+// app logo texture.
102 func (a *FaviconAdapter) GetTextureByURL(pageURL string) *gdk.Texture {101 func (a *FaviconAdapter) GetTextureByURL(pageURL string) *gdk.Texture {
103- // Handle internal dumb:// scheme URLs102+ texture, _, _ := a.getTextureForURL(pageURL)
103+ return texture
104+}
105+
106+func (a *FaviconAdapter) getTextureForURL(pageURL string) (*gdk.Texture, favicon.Key, bool) {
104 if a.isInternalURL != nil && a.isInternalURL(pageURL) {107 if a.isInternalURL != nil && a.isInternalURL(pageURL) {
105- return a.GetTexture(a.internalDomain)108+ texture := a.GetTexture(a.internalDomain)
109+ return texture, favicon.Key(a.internalDomain), texture != nil
106 }110 }
107- domain := domainurl.ExtractDomain(pageURL)111+ exactKey, _ := favicon.CanonicalKey(pageURL)
108- return a.GetTexture(domain)112+ for _, key := range favicon.Candidates(pageURL) {
113+ if texture := a.GetTexture(string(key)); texture != nil {
114+ return texture, key, exactKey != "" && key == exactKey
115+ }
116+ }
117+ return nil, "", false
118+}
119+
120+func (a *FaviconAdapter) setTextureForURL(pageURL string, texture *gdk.Texture) {
121+ key, ok := favicon.CanonicalKey(pageURL)
122+ if !ok {
123+ return
124+ }
125+ a.setTexture(string(key), texture)
109 }126 }
110 127
111 // GetOrFetch retrieves a favicon texture, checking caches and fetching if needed.128 // GetOrFetch retrieves a favicon texture, checking caches and fetching if needed.
112-// The callback is invoked on the GTK main thread with the texture (or nil).129+// The callback is invoked on the GTK main thread with the best currently
130+// available texture (or nil). When a fallback texture is shown first and a more
131+// specific exact engine favicon is discovered asynchronously, the callback may
132+// be invoked a second time with that exact update.
113 // For internal dumb:// URLs, returns the app logo texture.133 // For internal dumb:// URLs, returns the app logo texture.
114 func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callback func(*gdk.Texture)) {134 func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callback func(*gdk.Texture)) {
115 if callback == nil {135 if callback == nil {
@@ -137,18 +157,27 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
137 Str("domain", domain).157 Str("domain", domain).
138 Msg("favicon: GetOrFetch begin")158 Msg("favicon: GetOrFetch begin")
139 159
140- // Check texture cache first160+ // Exact in-memory hits can short-circuit immediately. Fallback hits still
141- if texture := a.GetTexture(domain); texture != nil {161+ // consult the resolver first so persisted exact/path-specific favicons win.
142- log.Debug().Str("domain", domain).Msg("favicon: texture cache hit")162+ if texture, key, exact := a.getTextureForURL(pageURL); texture != nil && exact {
143- // Ensure sized PNG exists for CLI tools (async, idempotent)163+ log.Debug().Str("domain", domain).Str("key", string(key)).Msg("favicon: exact texture cache hit")
144- go a.ensureSizedPNG(ctx, domain)164+ go a.ensureSizedPNG(ctx, string(key))
165+ callback(texture)
166+ return
167+ }
168+
169+ // Check application resolver before using any cached parent/domain fallback.
170+ if texture, key := a.resolveTexture(ctx, pageURL, true); texture != nil {
145 callback(texture)171 callback(texture)
172+ a.probeExactEngineFavicon(ctx, pageURL, domain, key, callback)
146 return173 return
147 }174 }
148 175
149- // Check application resolver before falling back to the legacy service.176+ if texture, key, _ := a.getTextureForURL(pageURL); texture != nil {
150- if texture := a.resolveTexture(ctx, pageURL, true); texture != nil {177+ log.Debug().Str("domain", domain).Str("key", string(key)).Msg("favicon: fallback texture cache hit")
178+ go a.ensureSizedPNG(ctx, string(key))
151 callback(texture)179 callback(texture)
180+ a.probeExactEngineFavicon(ctx, pageURL, domain, key, callback)
152 return181 return
153 }182 }
154 183
@@ -165,6 +194,7 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
165 // Ensure sized PNG exists for CLI tools (async, idempotent)194 // Ensure sized PNG exists for CLI tools (async, idempotent)
166 go a.ensureSizedPNG(ctx, domain)195 go a.ensureSizedPNG(ctx, domain)
167 callback(texture)196 callback(texture)
197+ a.probeExactEngineFavicon(ctx, pageURL, domain, favicon.Key(domain), callback)
168 return198 return
169 }199 }
170 }200 }
@@ -172,9 +202,7 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
172 202
173 // Try engine FaviconDatabase if available203 // Try engine FaviconDatabase if available
174 if a.faviconDB != nil {204 if a.faviconDB != nil {
175- a.faviconDB.GetFaviconAsync(pageURL, func(tex port.Texture) {205+ a.queryEngineFaviconDB(ctx, pageURL, domain, callback, true)
176- a.handleEngineFaviconDBTexture(ctx, pageURL, domain, callback, tex)
177- })
178 return206 return
179 }207 }
180 208
@@ -222,9 +250,9 @@ func (a *FaviconAdapter) RefreshFromIconURLs(ctx context.Context, pageURL string
222 }()250 }()
223 }251 }
224 252
225-func (a *FaviconAdapter) resolveTexture(ctx context.Context, pageURL string, scheduleRefresh bool) *gdk.Texture {253+func (a *FaviconAdapter) resolveTexture(ctx context.Context, pageURL string, scheduleRefresh bool) (*gdk.Texture, favicon.Key) {
226 if a.resolver == nil {254 if a.resolver == nil {
227- return nil255+ return nil, ""
228 }256 }
229 options := port.FaviconResolveOptions{Purpose: port.FaviconResolvePurposeUI}257 options := port.FaviconResolveOptions{Purpose: port.FaviconResolvePurposeUI}
230 if scheduleRefresh {258 if scheduleRefresh {
@@ -232,13 +260,13 @@ func (a *FaviconAdapter) resolveTexture(ctx context.Context, pageURL string, sch
232 }260 }
233 resolved, err := a.resolver.Resolve(ctx, pageURL, 0, options)261 resolved, err := a.resolver.Resolve(ctx, pageURL, 0, options)
234 if err != nil || resolved == nil || len(resolved.Bytes) == 0 {262 if err != nil || resolved == nil || len(resolved.Bytes) == 0 {
235- return nil263+ return nil, ""
236 }264 }
237 texture := a.textureFromBytesOnGTK(ctx, resolved.Bytes)265 texture := a.textureFromBytesOnGTK(ctx, resolved.Bytes)
238 if texture != nil {266 if texture != nil {
239 a.setResolvedTexture(pageURL, resolved.Key, texture)267 a.setResolvedTexture(pageURL, resolved.Key, texture)
240 }268 }
241- return texture269+ return texture, resolved.Key
242 }270 }
243 271
244 func (a *FaviconAdapter) fetchViaService(ctx context.Context, domain string, callback func(*gdk.Texture)) {272 func (a *FaviconAdapter) fetchViaService(ctx context.Context, domain string, callback func(*gdk.Texture)) {
@@ -276,11 +304,39 @@ func (a *FaviconAdapter) fetchViaService(ctx context.Context, domain string, cal
276 }()304 }()
277 }305 }
278 306
307+func (a *FaviconAdapter) probeExactEngineFavicon(
308+ ctx context.Context,
309+ pageURL, domain string,
310+ resolvedKey favicon.Key,
311+ callback func(*gdk.Texture),
312+) {
313+ if a.faviconDB == nil {
314+ return
315+ }
316+ exactKey, ok := favicon.CanonicalKey(pageURL)
317+ if !ok || exactKey == resolvedKey {
318+ return
319+ }
320+ a.queryEngineFaviconDB(ctx, pageURL, domain, callback, false)
321+}
322+
323+func (a *FaviconAdapter) queryEngineFaviconDB(
324+ ctx context.Context,
325+ pageURL, domain string,
326+ callback func(*gdk.Texture),
327+ fetchViaServiceOnMiss bool,
328+) {
329+ a.faviconDB.GetFaviconAsync(pageURL, func(tex port.Texture) {
330+ a.handleEngineFaviconDBTexture(ctx, pageURL, domain, callback, tex, fetchViaServiceOnMiss)
331+ })
332+}
333+
279 func (a *FaviconAdapter) handleEngineFaviconDBTexture(334 func (a *FaviconAdapter) handleEngineFaviconDBTexture(
280 ctx context.Context,335 ctx context.Context,
281 pageURL, domain string,336 pageURL, domain string,
282 callback func(*gdk.Texture),337 callback func(*gdk.Texture),
283 tex port.Texture,338 tex port.Texture,
339+ fetchViaServiceOnMiss bool,
284 ) {340 ) {
285 log := logging.FromContext(ctx)341 log := logging.FromContext(ctx)
286 log.Debug().342 log.Debug().
@@ -288,17 +344,25 @@ func (a *FaviconAdapter) handleEngineFaviconDBTexture(
288 Bool("texture_nil", tex == nil).344 Bool("texture_nil", tex == nil).
289 Msg("favicon: engine db callback")345 Msg("favicon: engine db callback")
290 if tex == nil {346 if tex == nil {
291- log.Debug().Str("domain", domain).Msg("favicon not in engine db, fetching via service")347+ if fetchViaServiceOnMiss {
292- a.fetchViaService(ctx, domain, callback)348+ log.Debug().Str("domain", domain).Msg("favicon not in engine db, fetching via service")
349+ a.fetchViaService(ctx, domain, callback)
350+ }
293 return351 return
294 }352 }
295 // Convert port.Texture to *gdk.Texture353 // Convert port.Texture to *gdk.Texture
296 gdkTex, ok := tex.(*gdk.Texture)354 gdkTex, ok := tex.(*gdk.Texture)
297 if !ok {355 if !ok {
298- a.fetchViaService(ctx, domain, callback)356+ if fetchViaServiceOnMiss {
357+ a.fetchViaService(ctx, domain, callback)
358+ }
299 return359 return
300 }360 }
301- a.setTexture(domain, gdkTex)361+ if a.resolver != nil {
362+ a.setTextureForURL(pageURL, gdkTex)
363+ } else {
364+ a.setTexture(domain, gdkTex)
365+ }
302 a.invokeCallbackOnGTK(ctx, domain, callback, gdkTex)366 a.invokeCallbackOnGTK(ctx, domain, callback, gdkTex)
303 a.storeTextureObservation(ctx, pageURL, gdkTex)367 a.storeTextureObservation(ctx, pageURL, gdkTex)
304 }368 }
@@ -321,15 +385,14 @@ func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, te
321 return385 return
322 }386 }
323 387
324- // Store in texture cache
325- a.setTexture(domain, texture)
326-
327 if a.resolver != nil {388 if a.resolver != nil {
389+ a.setTextureForURL(pageURL, texture)
328 a.storeTextureObservation(ctx, pageURL, texture)390 a.storeTextureObservation(ctx, pageURL, texture)
329 return391 return
330 }392 }
331 393
332 // Legacy fallback for callers not yet wired to the application resolver.394 // Legacy fallback for callers not yet wired to the application resolver.
395+ a.setTexture(domain, texture)
333 a.saveFaviconToDisk(ctx, domain, texture)396 a.saveFaviconToDisk(ctx, domain, texture)
334 }397 }
335 398
@@ -340,18 +403,21 @@ func (a *FaviconAdapter) StoreFromWebKitWithOrigin(
340 ) {403 ) {
341 a.StoreFromWebKit(ctx, currentURL, texture)404 a.StoreFromWebKit(ctx, currentURL, texture)
342 405
343- // Also store under original URL domain if different406+ if originURL == "" || originURL == currentURL {
344- if originURL != "" && originURL != currentURL {407+ return
345- originDomain := domainurl.ExtractDomain(originURL)408+ }
346- currentDomain := domainurl.ExtractDomain(currentURL)409+ if a.resolver != nil {
347- if originDomain != "" && originDomain != currentDomain {410+ // In path-aware resolver mode, redirect target favicons may carry state.
348- a.setTexture(originDomain, texture)411+ // Keep them scoped to the current URL only; aliasing them onto the origin
349- if a.resolver != nil {412+ // URL would leak exact/path-specific state across sibling paths or hosts.
350- a.storeTextureObservation(ctx, originURL, texture)413+ return
351- } else {414+ }
352- a.saveFaviconToDisk(ctx, originDomain, texture)415+
353- }416+ originDomain := domainurl.ExtractDomain(originURL)
354- }417+ currentDomain := domainurl.ExtractDomain(currentURL)
418+ if originDomain != "" && originDomain != currentDomain {
419+ a.setTexture(originDomain, texture)
420+ a.saveFaviconToDisk(ctx, originDomain, texture)
355 }421 }
356 }422 }
357 423
@@ -471,14 +537,18 @@ func (a *FaviconAdapter) PreloadFromCache(ctx context.Context, pageURL string) *
471 Str("domain", domain).537 Str("domain", domain).
472 Msg("favicon: PreloadFromCache begin")538 Msg("favicon: PreloadFromCache begin")
473 539
474- // Check texture cache540+ if texture, key, exact := a.getTextureForURL(pageURL); texture != nil && exact {
475- if texture := a.GetTexture(domain); texture != nil {541+ log.Debug().Str("domain", domain).Str("key", string(key)).Msg("favicon: PreloadFromCache exact texture hit")
476- log.Debug().Str("domain", domain).Msg("favicon: PreloadFromCache texture hit")542+ return texture
543+ }
544+
545+ // Check application resolver before falling back to cached parent/domain entries.
546+ if texture, _ := a.resolveTexture(ctx, pageURL, false); texture != nil {
477 return texture547 return texture
478 }548 }
479 549
480- // Check application resolver before falling back to the legacy service.550+ if texture, key, _ := a.getTextureForURL(pageURL); texture != nil {
481- if texture := a.resolveTexture(ctx, pageURL, false); texture != nil {551+ log.Debug().Str("domain", domain).Str("key", string(key)).Msg("favicon: PreloadFromCache fallback texture hit")
482 return texture552 return texture
483 }553 }
484 554
@@ -514,15 +584,10 @@ func (a *FaviconAdapter) Close() {
514 }584 }
515 }585 }
516 586
517-// Invalidate clears one resolved-key texture cache entry and all domain aliases587+// Invalidate clears one resolved-key texture cache entry.
518-// explicitly bound to that canonical favicon key.
519 func (a *FaviconAdapter) Invalidate(_ context.Context, key favicon.Key) error {588 func (a *FaviconAdapter) Invalidate(_ context.Context, key favicon.Key) error {
520 a.mu.Lock()589 a.mu.Lock()
521 delete(a.textureCache, string(key))590 delete(a.textureCache, string(key))
522- for alias := range a.aliasesByKey[key] {
523- delete(a.textureCache, alias)
524- }
525- delete(a.aliasesByKey, key)
526 a.mu.Unlock()591 a.mu.Unlock()
527 return nil592 return nil
528 }593 }
@@ -531,7 +596,6 @@ func (a *FaviconAdapter) Invalidate(_ context.Context, key favicon.Key) error {
531 func (a *FaviconAdapter) Clear() {596 func (a *FaviconAdapter) Clear() {
532 a.mu.Lock()597 a.mu.Lock()
533 a.textureCache = make(map[string]*gdk.Texture)598 a.textureCache = make(map[string]*gdk.Texture)
534- a.aliasesByKey = make(map[favicon.Key]map[string]struct{})
535 a.mu.Unlock()599 a.mu.Unlock()
536 }600 }
537 601
@@ -543,35 +607,17 @@ func (a *FaviconAdapter) Size() int {
543 return size607 return size
544 }608 }
545 609
546-// setResolvedTexture stores resolver results under both the resolved favicon key610+// setResolvedTexture stores resolver results only under the resolved favicon
547-// and the current page domain. Aliases are indexed by canonical key so611+// key. Exact/parent/domain fallback ordering is handled by favicon.Candidates
548-// invalidation does not depend on pointer equality with the latest texture.612+// during lookup, so synthetic aliases would only risk masking more specific
549-func (a *FaviconAdapter) setResolvedTexture(pageURL string, key favicon.Key, texture *gdk.Texture) {613+// entries that arrive later.
614+func (a *FaviconAdapter) setResolvedTexture(_ string, key favicon.Key, texture *gdk.Texture) {
550 if key == "" || texture == nil {615 if key == "" || texture == nil {
551 return616 return
552 }617 }
553- domain := domainurl.ExtractDomain(pageURL)
554 a.mu.Lock()618 a.mu.Lock()
555- defer a.mu.Unlock()
556 a.textureCache[string(key)] = texture619 a.textureCache[string(key)] = texture
557- if domain == "" {620+ a.mu.Unlock()
558- return
559- }
560- a.removeAliasLocked(domain)
561- a.textureCache[domain] = texture
562- if a.aliasesByKey[key] == nil {
563- a.aliasesByKey[key] = make(map[string]struct{})
564- }
565- a.aliasesByKey[key][domain] = struct{}{}
566-}
567-
568-func (a *FaviconAdapter) removeAliasLocked(alias string) {
569- for key, aliases := range a.aliasesByKey {
570- delete(aliases, alias)
571- if len(aliases) == 0 {
572- delete(a.aliasesByKey, key)
573- }
574- }
575 }621 }
576 622
577 // setTexture stores a texture in the cache.623 // setTexture stores a texture in the cache.
M internal/ui/adapter/favicon_test.go +204 -13
@@ -5,37 +5,228 @@ import (
55 "sync/atomic"
66 "testing"
77
8+ "github.com/bnema/dumber/internal/application/port"
9+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
810 "github.com/bnema/dumber/internal/domain/favicon"
911 "github.com/bnema/puregotk/v4/gdk"
1012 "github.com/rs/zerolog"
13+ "github.com/stretchr/testify/mock"
1114 )
1215
13-func TestFaviconAdapterInvalidateRemovesResolvedTextureAliases(t *testing.T) {
16+func TestFaviconAdapterInvalidateRemovesStoredKey(t *testing.T) {
1417 adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})
1518 texture := &gdk.Texture{}
16- adapter.setResolvedTexture("https://docs.example.com/page", favicon.Key("example.com"), texture)
19+ adapter.setTexture("example.com", texture)
1720
18- if adapter.GetTexture("example.com") == nil || adapter.GetTexture("docs.example.com") == nil {
19- t.Fatal("expected both resolved key and domain alias to be cached")
21+ if adapter.GetTexture("example.com") == nil {
22+ t.Fatal("expected key to be cached before invalidation")
2023 }
2124 if err := adapter.Invalidate(context.Background(), "example.com"); err != nil {
2225 t.Fatal(err)
2326 }
24- if adapter.GetTexture("example.com") != nil || adapter.GetTexture("docs.example.com") != nil {
25- t.Fatal("expected invalidation to remove resolved key and alias")
27+ if adapter.GetTexture("example.com") != nil {
28+ t.Fatal("expected invalidation to remove the stored key")
2629 }
2730 }
2831
29-func TestFaviconAdapterInvalidateRemovesOlderAliasesAfterKeyRebind(t *testing.T) {
32+func TestFaviconAdapterGetTextureByURLPrefersExactThenParentThenDomainFallback(t *testing.T) {
3033 adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})
31- adapter.setResolvedTexture("https://docs.example.com/page", favicon.Key("example.com"), &gdk.Texture{})
32- adapter.setResolvedTexture("https://blog.example.com/page", favicon.Key("example.com"), &gdk.Texture{})
34+ domainTexture := &gdk.Texture{}
35+ repoTexture := &gdk.Texture{}
36+ prTexture := &gdk.Texture{}
37+ adapter.setTexture("github.com", domainTexture)
38+ adapter.setTexture("github.com/bnema/gordon", repoTexture)
39+ adapter.setTexture("github.com/bnema/gordon/pull/123", prTexture)
3340
34- if err := adapter.Invalidate(context.Background(), "example.com"); err != nil {
35- t.Fatal(err)
41+ if got := adapter.GetTextureByURL("https://github.com/bnema/gordon/pull/123"); got != prTexture {
42+ t.Fatal("expected exact PR favicon")
43+ }
44+ if got := adapter.GetTextureByURL("https://github.com/bnema/gordon/issues/5"); got != repoTexture {
45+ t.Fatal("expected issue URL to fall back to repository favicon")
46+ }
47+ if got := adapter.GetTextureByURL("https://github.com/notifications"); got != domainTexture {
48+ t.Fatal("expected unrelated URL to fall back to domain favicon")
49+ }
50+}
51+
52+func TestFaviconAdapterSetResolvedTextureCachesOnlyResolvedKey(t *testing.T) {
53+ adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})
54+ fallbackTexture := &gdk.Texture{}
55+ pageURL := "https://github.com/bnema/gordon/pull/123"
56+ pageKey := "github.com/bnema/gordon/pull/123"
57+
58+ adapter.setResolvedTexture(pageURL, favicon.Key("github.com"), fallbackTexture)
59+
60+ if got := adapter.GetTexture("github.com"); got != fallbackTexture {
61+ t.Fatal("expected resolved fallback key to be cached")
62+ }
63+ if got := adapter.GetTexture(pageKey); got != nil {
64+ t.Fatal("fallback result should not be cached under the exact page key")
65+ }
66+ if got := adapter.GetTextureByURL(pageURL); got != fallbackTexture {
67+ t.Fatal("expected exact page lookup to fall back through candidate ordering")
68+ }
69+}
70+
71+func TestFaviconAdapterResolvedPathSpecificTextureDoesNotPolluteDomain(t *testing.T) {
72+ adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})
73+ prTexture := &gdk.Texture{}
74+ adapter.setResolvedTexture("https://github.com/bnema/gordon/pull/123", favicon.Key("github.com/bnema/gordon/pull/123"), prTexture)
75+
76+ if got := adapter.GetTextureByURL("https://github.com/bnema/gordon/pull/123"); got != prTexture {
77+ t.Fatal("expected exact PR favicon")
78+ }
79+ if got := adapter.GetTextureByURL("https://github.com/bnema/gordon"); got != nil {
80+ t.Fatal("path-specific favicon leaked to sibling repository URL")
81+ }
82+ if got := adapter.GetTexture("github.com"); got != nil {
83+ t.Fatal("path-specific favicon was cached under bare domain")
84+ }
85+ if got := adapter.GetTextureByURL("https://gitlab.com/group/project"); got != nil {
86+ t.Fatal("path-specific favicon leaked across domains")
87+ }
88+}
89+
90+func TestFaviconAdapterGetOrFetchSkipsResolverForExactMemoryCacheHit(t *testing.T) {
91+ resolver := portmocks.NewMockFaviconResolver(t)
92+ db := portmocks.NewMockFaviconDatabase(t)
93+ adapter := NewFaviconAdapterWithResolver(nil, resolver, db, FaviconAdapterConfig{})
94+ texture := &gdk.Texture{}
95+ pageURL := "https://github.com/bnema/gordon/pull/123"
96+ adapter.setTexture("github.com/bnema/gordon/pull/123", texture)
97+
98+ var got *gdk.Texture
99+ adapter.GetOrFetch(context.Background(), pageURL, func(received *gdk.Texture) {
100+ got = received
101+ })
102+
103+ if got != texture {
104+ t.Fatal("expected exact cached texture")
105+ }
106+ db.AssertNotCalled(t, "GetFaviconAsync", mock.Anything, mock.Anything)
107+}
108+
109+func TestFaviconAdapterGetOrFetchConsultsResolverBeforeFallbackMemoryCache(t *testing.T) {
110+ resolver := portmocks.NewMockFaviconResolver(t)
111+ adapter := NewFaviconAdapterWithResolver(nil, resolver, nil, FaviconAdapterConfig{})
112+ fallbackTexture := &gdk.Texture{}
113+ pageURL := "https://github.com/bnema/gordon/pull/123"
114+ adapter.setTexture("github.com", fallbackTexture)
115+ resolver.EXPECT().Resolve(
116+ mock.Anything,
117+ pageURL,
118+ 0,
119+ port.FaviconResolveOptions{Purpose: port.FaviconResolvePurposeUI, ScheduleBackgroundRefresh: true},
120+ ).Return((*port.ResolvedFavicon)(nil), nil).Once()
121+
122+ var got *gdk.Texture
123+ adapter.GetOrFetch(context.Background(), pageURL, func(received *gdk.Texture) {
124+ got = received
125+ })
126+
127+ if got != fallbackTexture {
128+ t.Fatal("expected domain fallback texture after resolver miss")
129+ }
130+}
131+
132+func TestFaviconAdapterPreloadFromCacheConsultsResolverBeforeFallbackMemoryCache(t *testing.T) {
133+ resolver := portmocks.NewMockFaviconResolver(t)
134+ adapter := NewFaviconAdapterWithResolver(nil, resolver, nil, FaviconAdapterConfig{})
135+ fallbackTexture := &gdk.Texture{}
136+ pageURL := "https://github.com/bnema/gordon/pull/123"
137+ adapter.setTexture("github.com", fallbackTexture)
138+ resolver.EXPECT().Resolve(
139+ mock.Anything,
140+ pageURL,
141+ 0,
142+ port.FaviconResolveOptions{Purpose: port.FaviconResolvePurposeUI},
143+ ).Return((*port.ResolvedFavicon)(nil), nil).Once()
144+
145+ if got := adapter.PreloadFromCache(context.Background(), pageURL); got != fallbackTexture {
146+ t.Fatal("expected domain fallback texture after resolver preload miss")
147+ }
148+}
149+
150+func TestFaviconAdapterGetOrFetchProbesEngineAfterFallbackMemoryCacheHit(t *testing.T) {
151+ resolver := portmocks.NewMockFaviconResolver(t)
152+ db := portmocks.NewMockFaviconDatabase(t)
153+ adapter := NewFaviconAdapterWithResolver(nil, resolver, db, FaviconAdapterConfig{})
154+ fallbackTexture := &gdk.Texture{}
155+ pageURL := "https://github.com/bnema/gordon/pull/123"
156+ adapter.setTexture("github.com", fallbackTexture)
157+ resolver.EXPECT().Resolve(
158+ mock.Anything,
159+ pageURL,
160+ 0,
161+ port.FaviconResolveOptions{Purpose: port.FaviconResolvePurposeUI, ScheduleBackgroundRefresh: true},
162+ ).Return((*port.ResolvedFavicon)(nil), nil).Once()
163+ db.EXPECT().GetFaviconAsync(pageURL, mock.Anything).Run(func(_ string, callback func(port.Texture)) {
164+ callback(nil)
165+ }).Return().Once()
166+
167+ var (
168+ got *gdk.Texture
169+ calls int
170+ )
171+ adapter.GetOrFetch(context.Background(), pageURL, func(received *gdk.Texture) {
172+ calls++
173+ got = received
174+ })
175+
176+ if got != fallbackTexture {
177+ t.Fatal("expected fallback texture while probing exact engine favicon")
178+ }
179+ if calls != 1 {
180+ t.Fatalf("expected single callback on engine miss, got %d", calls)
181+ }
182+}
183+
184+func TestFaviconAdapterStoreFromWebKitWithOriginDoesNotAliasSameDomainSpecificPaths(t *testing.T) {
185+ resolver := portmocks.NewMockFaviconResolver(t)
186+ resolver.EXPECT().Observe(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
187+ Return((*favicon.Metadata)(nil), nil).
188+ Maybe()
189+ adapter := NewFaviconAdapterWithResolver(nil, resolver, nil, FaviconAdapterConfig{})
190+ texture := &gdk.Texture{}
191+
192+ adapter.StoreFromWebKitWithOrigin(
193+ context.Background(),
194+ "https://github.com/login",
195+ "https://github.com/bnema/gordon/pull/123",
196+ texture,
197+ )
198+
199+ if got := adapter.GetTextureByURL("https://github.com/login"); got != texture {
200+ t.Fatal("expected current URL favicon to be cached")
201+ }
202+ if got := adapter.GetTextureByURL("https://github.com/bnema/gordon/pull/123"); got != nil {
203+ t.Fatal("same-domain redirect aliased favicon onto unrelated exact path")
204+ }
205+ if got := adapter.GetTexture("github.com"); got != nil {
206+ t.Fatal("same-domain redirect promoted favicon to bare domain")
207+ }
208+}
209+
210+func TestFaviconAdapterStoreFromWebKitWithOriginDoesNotAliasCrossDomainRedirectsInResolverMode(t *testing.T) {
211+ resolver := portmocks.NewMockFaviconResolver(t)
212+ resolver.EXPECT().Observe(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
213+ Return((*favicon.Metadata)(nil), nil).
214+ Maybe()
215+ adapter := NewFaviconAdapterWithResolver(nil, resolver, nil, FaviconAdapterConfig{})
216+ texture := &gdk.Texture{}
217+ currentURL := "https://auth.example.net/challenge"
218+ originURL := "https://github.com/bnema/gordon/pull/123"
219+
220+ adapter.StoreFromWebKitWithOrigin(context.Background(), currentURL, originURL, texture)
221+
222+ if got := adapter.GetTextureByURL(currentURL); got != texture {
223+ t.Fatal("expected current redirected URL favicon to be cached")
224+ }
225+ if got := adapter.GetTextureByURL(originURL); got != nil {
226+ t.Fatal("cross-domain redirect aliased favicon onto original exact path")
36227 }
37- if adapter.GetTexture("example.com") != nil || adapter.GetTexture("docs.example.com") != nil || adapter.GetTexture("blog.example.com") != nil {
38- t.Fatal("expected invalidation to remove canonical key and all aliases")
228+ if got := adapter.GetTexture("github.com"); got != nil {
229+ t.Fatal("cross-domain redirect promoted favicon to original domain")
39230 }
40231 }
41232
@@ -5,37 +5,228 @@ import (
5 "sync/atomic"5 "sync/atomic"
6 "testing"6 "testing"
7 7
8+ "github.com/bnema/dumber/internal/application/port"
9+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
8 "github.com/bnema/dumber/internal/domain/favicon"10 "github.com/bnema/dumber/internal/domain/favicon"
9 "github.com/bnema/puregotk/v4/gdk"11 "github.com/bnema/puregotk/v4/gdk"
10 "github.com/rs/zerolog"12 "github.com/rs/zerolog"
13+ "github.com/stretchr/testify/mock"
11 )14 )
12 15
13-func TestFaviconAdapterInvalidateRemovesResolvedTextureAliases(t *testing.T) {16+func TestFaviconAdapterInvalidateRemovesStoredKey(t *testing.T) {
14 adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})17 adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})
15 texture := &gdk.Texture{}18 texture := &gdk.Texture{}
16- adapter.setResolvedTexture("https://docs.example.com/page", favicon.Key("example.com"), texture)19+ adapter.setTexture("example.com", texture)
17 20
18- if adapter.GetTexture("example.com") == nil || adapter.GetTexture("docs.example.com") == nil {21+ if adapter.GetTexture("example.com") == nil {
19- t.Fatal("expected both resolved key and domain alias to be cached")22+ t.Fatal("expected key to be cached before invalidation")
20 }23 }
21 if err := adapter.Invalidate(context.Background(), "example.com"); err != nil {24 if err := adapter.Invalidate(context.Background(), "example.com"); err != nil {
22 t.Fatal(err)25 t.Fatal(err)
23 }26 }
24- if adapter.GetTexture("example.com") != nil || adapter.GetTexture("docs.example.com") != nil {27+ if adapter.GetTexture("example.com") != nil {
25- t.Fatal("expected invalidation to remove resolved key and alias")28+ t.Fatal("expected invalidation to remove the stored key")
26 }29 }
27 }30 }
28 31
29-func TestFaviconAdapterInvalidateRemovesOlderAliasesAfterKeyRebind(t *testing.T) {32+func TestFaviconAdapterGetTextureByURLPrefersExactThenParentThenDomainFallback(t *testing.T) {
30 adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})33 adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})
31- adapter.setResolvedTexture("https://docs.example.com/page", favicon.Key("example.com"), &gdk.Texture{})34+ domainTexture := &gdk.Texture{}
32- adapter.setResolvedTexture("https://blog.example.com/page", favicon.Key("example.com"), &gdk.Texture{})35+ repoTexture := &gdk.Texture{}
36+ prTexture := &gdk.Texture{}
37+ adapter.setTexture("github.com", domainTexture)
38+ adapter.setTexture("github.com/bnema/gordon", repoTexture)
39+ adapter.setTexture("github.com/bnema/gordon/pull/123", prTexture)
33 40
34- if err := adapter.Invalidate(context.Background(), "example.com"); err != nil {41+ if got := adapter.GetTextureByURL("https://github.com/bnema/gordon/pull/123"); got != prTexture {
35- t.Fatal(err)42+ t.Fatal("expected exact PR favicon")
43+ }
44+ if got := adapter.GetTextureByURL("https://github.com/bnema/gordon/issues/5"); got != repoTexture {
45+ t.Fatal("expected issue URL to fall back to repository favicon")
46+ }
47+ if got := adapter.GetTextureByURL("https://github.com/notifications"); got != domainTexture {
48+ t.Fatal("expected unrelated URL to fall back to domain favicon")
49+ }
50+}
51+
52+func TestFaviconAdapterSetResolvedTextureCachesOnlyResolvedKey(t *testing.T) {
53+ adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})
54+ fallbackTexture := &gdk.Texture{}
55+ pageURL := "https://github.com/bnema/gordon/pull/123"
56+ pageKey := "github.com/bnema/gordon/pull/123"
57+
58+ adapter.setResolvedTexture(pageURL, favicon.Key("github.com"), fallbackTexture)
59+
60+ if got := adapter.GetTexture("github.com"); got != fallbackTexture {
61+ t.Fatal("expected resolved fallback key to be cached")
62+ }
63+ if got := adapter.GetTexture(pageKey); got != nil {
64+ t.Fatal("fallback result should not be cached under the exact page key")
65+ }
66+ if got := adapter.GetTextureByURL(pageURL); got != fallbackTexture {
67+ t.Fatal("expected exact page lookup to fall back through candidate ordering")
68+ }
69+}
70+
71+func TestFaviconAdapterResolvedPathSpecificTextureDoesNotPolluteDomain(t *testing.T) {
72+ adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})
73+ prTexture := &gdk.Texture{}
74+ adapter.setResolvedTexture("https://github.com/bnema/gordon/pull/123", favicon.Key("github.com/bnema/gordon/pull/123"), prTexture)
75+
76+ if got := adapter.GetTextureByURL("https://github.com/bnema/gordon/pull/123"); got != prTexture {
77+ t.Fatal("expected exact PR favicon")
78+ }
79+ if got := adapter.GetTextureByURL("https://github.com/bnema/gordon"); got != nil {
80+ t.Fatal("path-specific favicon leaked to sibling repository URL")
81+ }
82+ if got := adapter.GetTexture("github.com"); got != nil {
83+ t.Fatal("path-specific favicon was cached under bare domain")
84+ }
85+ if got := adapter.GetTextureByURL("https://gitlab.com/group/project"); got != nil {
86+ t.Fatal("path-specific favicon leaked across domains")
87+ }
88+}
89+
90+func TestFaviconAdapterGetOrFetchSkipsResolverForExactMemoryCacheHit(t *testing.T) {
91+ resolver := portmocks.NewMockFaviconResolver(t)
92+ db := portmocks.NewMockFaviconDatabase(t)
93+ adapter := NewFaviconAdapterWithResolver(nil, resolver, db, FaviconAdapterConfig{})
94+ texture := &gdk.Texture{}
95+ pageURL := "https://github.com/bnema/gordon/pull/123"
96+ adapter.setTexture("github.com/bnema/gordon/pull/123", texture)
97+
98+ var got *gdk.Texture
99+ adapter.GetOrFetch(context.Background(), pageURL, func(received *gdk.Texture) {
100+ got = received
101+ })
102+
103+ if got != texture {
104+ t.Fatal("expected exact cached texture")
105+ }
106+ db.AssertNotCalled(t, "GetFaviconAsync", mock.Anything, mock.Anything)
107+}
108+
109+func TestFaviconAdapterGetOrFetchConsultsResolverBeforeFallbackMemoryCache(t *testing.T) {
110+ resolver := portmocks.NewMockFaviconResolver(t)
111+ adapter := NewFaviconAdapterWithResolver(nil, resolver, nil, FaviconAdapterConfig{})
112+ fallbackTexture := &gdk.Texture{}
113+ pageURL := "https://github.com/bnema/gordon/pull/123"
114+ adapter.setTexture("github.com", fallbackTexture)
115+ resolver.EXPECT().Resolve(
116+ mock.Anything,
117+ pageURL,
118+ 0,
119+ port.FaviconResolveOptions{Purpose: port.FaviconResolvePurposeUI, ScheduleBackgroundRefresh: true},
120+ ).Return((*port.ResolvedFavicon)(nil), nil).Once()
121+
122+ var got *gdk.Texture
123+ adapter.GetOrFetch(context.Background(), pageURL, func(received *gdk.Texture) {
124+ got = received
125+ })
126+
127+ if got != fallbackTexture {
128+ t.Fatal("expected domain fallback texture after resolver miss")
129+ }
130+}
131+
132+func TestFaviconAdapterPreloadFromCacheConsultsResolverBeforeFallbackMemoryCache(t *testing.T) {
133+ resolver := portmocks.NewMockFaviconResolver(t)
134+ adapter := NewFaviconAdapterWithResolver(nil, resolver, nil, FaviconAdapterConfig{})
135+ fallbackTexture := &gdk.Texture{}
136+ pageURL := "https://github.com/bnema/gordon/pull/123"
137+ adapter.setTexture("github.com", fallbackTexture)
138+ resolver.EXPECT().Resolve(
139+ mock.Anything,
140+ pageURL,
141+ 0,
142+ port.FaviconResolveOptions{Purpose: port.FaviconResolvePurposeUI},
143+ ).Return((*port.ResolvedFavicon)(nil), nil).Once()
144+
145+ if got := adapter.PreloadFromCache(context.Background(), pageURL); got != fallbackTexture {
146+ t.Fatal("expected domain fallback texture after resolver preload miss")
147+ }
148+}
149+
150+func TestFaviconAdapterGetOrFetchProbesEngineAfterFallbackMemoryCacheHit(t *testing.T) {
151+ resolver := portmocks.NewMockFaviconResolver(t)
152+ db := portmocks.NewMockFaviconDatabase(t)
153+ adapter := NewFaviconAdapterWithResolver(nil, resolver, db, FaviconAdapterConfig{})
154+ fallbackTexture := &gdk.Texture{}
155+ pageURL := "https://github.com/bnema/gordon/pull/123"
156+ adapter.setTexture("github.com", fallbackTexture)
157+ resolver.EXPECT().Resolve(
158+ mock.Anything,
159+ pageURL,
160+ 0,
161+ port.FaviconResolveOptions{Purpose: port.FaviconResolvePurposeUI, ScheduleBackgroundRefresh: true},
162+ ).Return((*port.ResolvedFavicon)(nil), nil).Once()
163+ db.EXPECT().GetFaviconAsync(pageURL, mock.Anything).Run(func(_ string, callback func(port.Texture)) {
164+ callback(nil)
165+ }).Return().Once()
166+
167+ var (
168+ got *gdk.Texture
169+ calls int
170+ )
171+ adapter.GetOrFetch(context.Background(), pageURL, func(received *gdk.Texture) {
172+ calls++
173+ got = received
174+ })
175+
176+ if got != fallbackTexture {
177+ t.Fatal("expected fallback texture while probing exact engine favicon")
178+ }
179+ if calls != 1 {
180+ t.Fatalf("expected single callback on engine miss, got %d", calls)
181+ }
182+}
183+
184+func TestFaviconAdapterStoreFromWebKitWithOriginDoesNotAliasSameDomainSpecificPaths(t *testing.T) {
185+ resolver := portmocks.NewMockFaviconResolver(t)
186+ resolver.EXPECT().Observe(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
187+ Return((*favicon.Metadata)(nil), nil).
188+ Maybe()
189+ adapter := NewFaviconAdapterWithResolver(nil, resolver, nil, FaviconAdapterConfig{})
190+ texture := &gdk.Texture{}
191+
192+ adapter.StoreFromWebKitWithOrigin(
193+ context.Background(),
194+ "https://github.com/login",
195+ "https://github.com/bnema/gordon/pull/123",
196+ texture,
197+ )
198+
199+ if got := adapter.GetTextureByURL("https://github.com/login"); got != texture {
200+ t.Fatal("expected current URL favicon to be cached")
201+ }
202+ if got := adapter.GetTextureByURL("https://github.com/bnema/gordon/pull/123"); got != nil {
203+ t.Fatal("same-domain redirect aliased favicon onto unrelated exact path")
204+ }
205+ if got := adapter.GetTexture("github.com"); got != nil {
206+ t.Fatal("same-domain redirect promoted favicon to bare domain")
207+ }
208+}
209+
210+func TestFaviconAdapterStoreFromWebKitWithOriginDoesNotAliasCrossDomainRedirectsInResolverMode(t *testing.T) {
211+ resolver := portmocks.NewMockFaviconResolver(t)
212+ resolver.EXPECT().Observe(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
213+ Return((*favicon.Metadata)(nil), nil).
214+ Maybe()
215+ adapter := NewFaviconAdapterWithResolver(nil, resolver, nil, FaviconAdapterConfig{})
216+ texture := &gdk.Texture{}
217+ currentURL := "https://auth.example.net/challenge"
218+ originURL := "https://github.com/bnema/gordon/pull/123"
219+
220+ adapter.StoreFromWebKitWithOrigin(context.Background(), currentURL, originURL, texture)
221+
222+ if got := adapter.GetTextureByURL(currentURL); got != texture {
223+ t.Fatal("expected current redirected URL favicon to be cached")
224+ }
225+ if got := adapter.GetTextureByURL(originURL); got != nil {
226+ t.Fatal("cross-domain redirect aliased favicon onto original exact path")
36 }227 }
37- if adapter.GetTexture("example.com") != nil || adapter.GetTexture("docs.example.com") != nil || adapter.GetTexture("blog.example.com") != nil {228+ if got := adapter.GetTexture("github.com"); got != nil {
38- t.Fatal("expected invalidation to remove canonical key and all aliases")229+ t.Fatal("cross-domain redirect promoted favicon to original domain")
39 }230 }
40 }231 }
41 232

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