Merge pull request #283 from bnema/fix/favicon-specificity
fix(favicon): scope cached icons to exact page paths
12 files · +1231 -162
M internal/application/port/favicon_fetcher.go +4 -1
| @@ -14,10 +14,13 @@ type FaviconFetchRequest struct { | |||
| 14 | 14 | } | |
| 15 | 15 | ||
| 16 | 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 | 20 | type FaviconFetchedIcon struct { | |
| 19 | 21 | PageURL string | |
| 20 | 22 | IconURL string | |
| 23 | + | ResolvedKey favicon.Key | |
| 21 | 24 | Bytes []byte | |
| 22 | 25 | Source favicon.Source | |
| 23 | 26 | 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 string | 21 | PageURL string | ||
| 20 | IconURL string | 22 | IconURL string | ||
| 23 | + | ResolvedKey favicon.Key | |||
| 21 | Bytes []byte | 24 | Bytes []byte | ||
| 22 | Source favicon.Source | 25 | Source favicon.Source | ||
| 23 | ContentType string | 26 | 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( | |||
| 230 | 230 | if !ok || len(bytes) == 0 { | |
| 231 | 231 | 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 | 247 | old, err := uc.repo.Get(ctx, key) | |
| 234 | 248 | if err != nil { | |
| 235 | 249 | return nil, err | |
| @@ -310,7 +324,7 @@ func (uc *FaviconUseCase) RefreshFromIconURLs(ctx context.Context, pageURL strin | |||
| 310 | 324 | if err != nil { | |
| 311 | 325 | 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 | 328 | if errors.Is(err, ErrFaviconMiss) { | |
| 315 | 329 | lastMiss = err | |
| 316 | 330 | continue | |
| @@ -372,7 +386,29 @@ func (uc *FaviconUseCase) fetchAndObserve(ctx context.Context, pageURL string) e | |||
| 372 | 386 | if err != nil { | |
| 373 | 387 | 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 | 412 | return err | |
| 377 | 413 | } | |
| 378 | 414 | ||
| @@ -230,6 +230,20 @@ func (uc *FaviconUseCase) Observe( | |||||
| 230 | if !ok || len(bytes) == 0 { | 230 | if !ok || len(bytes) == 0 { | ||
| 231 | return nil, ErrFaviconMiss | 231 | 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, err | 249 | 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 err | 325 | 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 = err | 329 | lastMiss = err | ||
| 316 | continue | 330 | 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 err | 387 | 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 err | 412 | return err | ||
| 377 | } | 413 | } | ||
| 378 | 414 | ||||
M internal/application/usecase/favicon_test.go +132 -10
| @@ -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 | 93 | func TestFaviconResolveRepairsOrphanCanonicalBlobMetadata(t *testing.T) { | |
| 38 | 94 | fx := newFaviconFixture() | |
| 39 | 95 | fx.blobs.png["example.com"] = []byte("png") | |
| @@ -112,7 +168,7 @@ func TestFaviconResolveStaleExactRefreshIgnoresFreshParent(t *testing.T) { | |||
| 112 | 168 | func TestFaviconObserveSameHashUpdatesMetadataOnly(t *testing.T) { | |
| 113 | 169 | fx := newFaviconFixture() | |
| 114 | 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 | 172 | t.Fatal(err) | |
| 117 | 173 | } | |
| 118 | 174 | if fx.blobs.writeOriginals != 0 || fx.converter.calls != 0 || fx.invalidators.calls != 0 { | |
| @@ -126,7 +182,7 @@ func TestFaviconObserveSameHashUpdatesMetadataOnly(t *testing.T) { | |||
| 126 | 182 | func TestFaviconObserveChangedHashRegeneratesAndInvalidates(t *testing.T) { | |
| 127 | 183 | fx := newFaviconFixture() | |
| 128 | 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 | 186 | t.Fatal(err) | |
| 131 | 187 | } | |
| 132 | 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 | 199 | func TestFaviconObserveWithoutConverterStoresOriginalOnly(t *testing.T) { | |
| 144 | 200 | fx := newFaviconFixture() | |
| 145 | 201 | 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 | 203 | t.Fatal(err) | |
| 148 | 204 | } | |
| 149 | 205 | if string(fx.blobs.original["example.com"]) != "raw" { | |
| @@ -230,11 +286,71 @@ func TestFaviconRefreshFromIconURLsUsesPageURLKey(t *testing.T) { | |||
| 230 | 286 | if err := fx.uc.RefreshFromIconURLs(context.Background(), "https://docs.example.com/page", []string{"https://cdn.example.net/icon.png"}); err != nil { | |
| 231 | 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 | 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 { | |
| 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) | |
| 238 | 354 | } | |
| 239 | 355 | } | |
| 240 | 356 | ||
| @@ -256,15 +372,15 @@ func TestFaviconRefreshFromIconURLsSkipsUnsupportedCandidates(t *testing.T) { | |||
| 256 | 372 | if fx.fetcher.calls != 2 { | |
| 257 | 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 | 376 | if meta == nil { | |
| 261 | 377 | t.Fatalf("expected page URL key to be stored; keys=%v", fx.repo.byKey) | |
| 262 | 378 | } | |
| 263 | 379 | if meta.SourceURL != "https://cdn.example.net/icon.png" || meta.ContentType != "image/png" { | |
| 264 | 380 | t.Fatalf("stored meta = %+v, want png candidate", meta) | |
| 265 | 381 | } | |
| 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"]) | |
| 268 | 384 | } | |
| 269 | 385 | } | |
| 270 | 386 | ||
| @@ -551,6 +667,8 @@ func (i *fakeInvalidators) InvalidateAll(_ context.Context, _ favicon.Key) error | |||
| 551 | 667 | type fakeFetchedIcon struct { | |
| 552 | 668 | bytes []byte | |
| 553 | 669 | contentType string | |
| 670 | + | pageURL string | |
| 671 | + | resolvedKey favicon.Key | |
| 554 | 672 | } | |
| 555 | 673 | ||
| 556 | 674 | type fakeFetcher struct { | |
| @@ -572,5 +690,9 @@ func (f *fakeFetcher) Fetch(_ context.Context, req appport.FaviconFetchRequest) | |||
| 572 | 690 | if response.bytes == nil { | |
| 573 | 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}, 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 | |
| 576 | 698 | } | |
| @@ -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 = nil | 201 | 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 []byte | 668 | bytes []byte | ||
| 553 | contentType string | 669 | 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}, 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 | |||
| 576 | } | 698 | } | ||
M internal/domain/favicon/key.go +115 -22
| @@ -3,6 +3,7 @@ package favicon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "net" | |
| 5 | 5 | "net/url" | |
| 6 | + | "path" | |
| 6 | 7 | "slices" | |
| 7 | 8 | "strings" | |
| 8 | 9 | ||
| @@ -20,20 +21,40 @@ func CanonicalKey(raw string) (Key, bool) { | |||
| 20 | 21 | 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 | 32 | func Candidates(raw string) []Key { | |
| 24 | - | host, port, hasScheme, scheme, ok := parseHost(raw) | |
| 25 | - | if !ok { | |
| 33 | + | parsed := parseHost(raw) | |
| 34 | + | if !parsed.ok { | |
| 26 | 35 | 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 | 50 | if host == "" { | |
| 31 | 51 | return nil | |
| 32 | 52 | } | |
| 33 | 53 | ||
| 54 | + | var candidates []Key | |
| 34 | 55 | 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))} | |
| 37 | 58 | } | |
| 38 | 59 | return []Key{Key(host)} | |
| 39 | 60 | } | |
| @@ -45,9 +66,9 @@ func Candidates(raw string) []Key { | |||
| 45 | 66 | host = strings.ToLower(strings.TrimSuffix(ascii, ".")) | |
| 46 | 67 | host = stripWWW(host) | |
| 47 | 68 | ||
| 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))} | |
| 51 | 72 | } | |
| 52 | 73 | } | |
| 53 | 74 | ||
| @@ -61,55 +82,127 @@ func Candidates(raw string) []Key { | |||
| 61 | 82 | return []Key{Key(host)} | |
| 62 | 83 | } | |
| 63 | 84 | ||
| 64 | - | var out []Key | |
| 65 | 85 | for i := range len(labels) { | |
| 66 | 86 | candidate := strings.Join(labels[i:], ".") | |
| 67 | - | out = append(out, Key(candidate)) | |
| 87 | + | candidates = append(candidates, Key(candidate)) | |
| 68 | 88 | if candidate == registrable { | |
| 69 | 89 | 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 | 110 | 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 | 161 | raw = strings.TrimSpace(raw) | |
| 77 | 162 | if raw == "" { | |
| 78 | - | return "", "", false, "", false | |
| 163 | + | return parsedHost{} | |
| 79 | 164 | } | |
| 80 | 165 | ||
| 81 | 166 | if strings.Contains(raw, "://") { | |
| 82 | 167 | u, err := url.Parse(raw) | |
| 83 | 168 | if err != nil || u.Scheme == "" { | |
| 84 | - | return "", "", false, "", false | |
| 169 | + | return parsedHost{} | |
| 85 | 170 | } | |
| 86 | - | scheme = strings.ToLower(u.Scheme) | |
| 171 | + | scheme := strings.ToLower(u.Scheme) | |
| 87 | 172 | 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 != "", | |
| 89 | 183 | } | |
| 90 | - | host = u.Hostname() | |
| 91 | - | port = u.Port() | |
| 92 | - | return host, port, true, scheme, host != "" | |
| 93 | 184 | } | |
| 94 | 185 | if strings.HasPrefix(strings.ToLower(raw), "about:") { | |
| 95 | - | return "", "", false, "", false | |
| 186 | + | return parsedHost{} | |
| 96 | 187 | } | |
| 97 | 188 | ||
| 98 | 189 | hostport := raw | |
| 190 | + | rawPath := "" | |
| 99 | 191 | if cut := strings.IndexAny(hostport, "/?#"); cut >= 0 { | |
| 192 | + | rawPath = hostport[cut:] | |
| 100 | 193 | hostport = hostport[:cut] | |
| 101 | 194 | } | |
| 102 | 195 | hostport = strings.TrimSuffix(hostport, ".") | |
| 103 | 196 | ||
| 104 | 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 | 200 | if strings.Count(hostport, ":") == 1 { | |
| 108 | 201 | 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} | |
| 110 | 203 | } | |
| 111 | 204 | } | |
| 112 | - | return hostport, "", false, "", hostport != "" | |
| 205 | + | return parsedHost{host: hostport, rawPath: rawPath, ok: hostport != ""} | |
| 113 | 206 | } | |
| 114 | 207 | ||
| 115 | 208 | 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], true | 21 | 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 nil | 35 | 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 nil | 51 | 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 | break | 89 | 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 out | 110 | 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, "", false | 163 | + | 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, "", false | 169 | + | 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, 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 != "", | |||
| 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, "", false | 186 | + | return parsedHost{} |
| 96 | } | 187 | } | ||
| 97 | 188 | ||||
| 98 | hostport := raw | 189 | 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, "", true | 202 | + | 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) { | |||
| 8 | 8 | raw string | |
| 9 | 9 | 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 | 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"}}, | |
| 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"}}, | |
| 17 | 21 | ||
| 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"}}, | |
| 20 | 24 | {name: "host-like string lowercases and trims trailing dot", raw: "Docs.Example.COM.", want: []Key{"docs.example.com", "example.com"}}, | |
| 21 | 25 | {name: "strips www before preserving non default port", raw: "https://www.example.com:8443", want: []Key{"example.com:8443"}}, | |
| 22 | 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"}}, | |
| 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"}}, | |
| 25 | 29 | {name: "empty input has no candidates", raw: "", want: nil}, | |
| 26 | 30 | {name: "about url has no candidates", raw: "about:blank", want: nil}, | |
| 27 | 31 | {name: "unsupported scheme has no candidates", raw: "file:///tmp/icon.html", want: nil}, | |
| @@ -47,11 +51,25 @@ func TestCanonicalKey(t *testing.T) { | |||
| 47 | 51 | if !ok { | |
| 48 | 52 | t.Fatal("CanonicalKey returned false") | |
| 49 | 53 | } | |
| 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) | |
| 52 | 56 | } | |
| 53 | 57 | ||
| 54 | 58 | if key, ok := CanonicalKey("about:blank"); ok || key != "" { | |
| 55 | 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 | + | } | |
| @@ -8,20 +8,24 @@ func TestKeyCandidates(t *testing.T) { | |||||
| 8 | raw string | 8 | raw string | ||
| 9 | want []Key | 9 | 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 @@ | |||
| 2 | 2 | package url | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | + | "crypto/sha256" | |
| 6 | + | "encoding/hex" | |
| 5 | 7 | "fmt" | |
| 6 | 8 | "net" | |
| 7 | 9 | "net/url" | |
| @@ -207,39 +209,54 @@ func DisplayDomain(raw string) string { | |||
| 207 | 209 | return domain | |
| 208 | 210 | } | |
| 209 | 211 | ||
| 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. | |
| 212 | 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 | 222 | // Used for favicon export for tools like rofi/fuzzel that require PNG format. | |
| 218 | 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 | 230 | func sanitizeDomain(domain string) string { | |
| 224 | 231 | 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", | |
| 234 | 242 | ) | |
| 235 | 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 | 255 | // SanitizeDomainForPNGSized converts a domain to a safe filename with size suffix. | |
| 239 | 256 | // Example: "google.com" with size 32 -> "google.com.32.png" | |
| 240 | 257 | // Used for normalized favicon export for tools like rofi/fuzzel. | |
| 241 | 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 | 262 | // TrimLeadingSpacesIfURL removes leading whitespace from input if the trimmed | |
| @@ -2,6 +2,8 @@ | |||||
| 2 | package url | 2 | 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 domain | 209 | 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 trimmed | 262 | // TrimLeadingSpacesIfURL removes leading whitespace from input if the trimmed | ||
M internal/domain/url/normalize_test.go +48 -0
| @@ -3,6 +3,7 @@ package url | |||
| 3 | 3 | import ( | |
| 4 | 4 | "os" | |
| 5 | 5 | "path/filepath" | |
| 6 | + | "strings" | |
| 6 | 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 | 529 | func TestIsExternalScheme(t *testing.T) { | |
| 482 | 530 | tests := []struct { | |
| 483 | 531 | 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 string | 531 | name string | ||
M internal/infrastructure/favicon/fetcher.go +16 -9
| @@ -68,7 +68,7 @@ func (f *Fetcher) safeTransport(base http.RoundTripper) http.RoundTripper { | |||
| 68 | 68 | } | |
| 69 | 69 | ||
| 70 | 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 | 72 | if err != nil { | |
| 73 | 73 | return nil, err | |
| 74 | 74 | } | |
| @@ -76,7 +76,14 @@ func (f *Fetcher) Fetch(ctx context.Context, req appport.FaviconFetchRequest) (* | |||
| 76 | 76 | if err != nil { | |
| 77 | 77 | return nil, err | |
| 78 | 78 | } | |
| 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 | |
| 80 | 87 | } | |
| 81 | 88 | ||
| 82 | 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 | 101 | 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 | 105 | if req.IconURL != "" { | |
| 99 | 106 | u, err := url.Parse(req.IconURL) | |
| 100 | 107 | if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" { | |
| 101 | - | return "", "", appport.ErrFaviconMiss | |
| 108 | + | return "", "", "", appport.ErrFaviconMiss | |
| 102 | 109 | } | |
| 103 | 110 | if !allowedURLForPage(req.PageURL, u) { | |
| 104 | - | return "", "", appport.ErrFaviconMiss | |
| 111 | + | return "", "", "", appport.ErrFaviconMiss | |
| 105 | 112 | } | |
| 106 | - | return u.String(), domainfavicon.SourcePageDiscovery, nil | |
| 113 | + | return u.String(), "", domainfavicon.SourcePageDiscovery, nil | |
| 107 | 114 | } | |
| 108 | - | key, ok := domainfavicon.CanonicalKey(req.PageURL) | |
| 115 | + | key, ok := domainfavicon.CanonicalHostKey(req.PageURL) | |
| 109 | 116 | if !ok { | |
| 110 | - | return "", "", appport.ErrFaviconMiss | |
| 117 | + | return "", "", "", appport.ErrFaviconMiss | |
| 111 | 118 | } | |
| 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 | |
| 113 | 120 | } | |
| 114 | 121 | ||
| 115 | 122 | 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, err | 73 | 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, err | 77 | return nil, err | ||
| 78 | } | 78 | } | ||
| 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 | |||
| 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, nil | 101 | 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.ErrFaviconMiss | 108 | + | return "", "", "", appport.ErrFaviconMiss |
| 102 | } | 109 | } | ||
| 103 | if !allowedURLForPage(req.PageURL, u) { | 110 | if !allowedURLForPage(req.PageURL, u) { | ||
| 104 | - | return "", "", appport.ErrFaviconMiss | 111 | + | return "", "", "", appport.ErrFaviconMiss |
| 105 | } | 112 | } | ||
| 106 | - | return u.String(), domainfavicon.SourcePageDiscovery, nil | 113 | + | 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.ErrFaviconMiss | 117 | + | return "", "", "", appport.ErrFaviconMiss |
| 111 | } | 118 | } | ||
| 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 |
| 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 ( | |||
| 10 | 10 | "testing" | |
| 11 | 11 | ||
| 12 | 12 | appport "github.com/bnema/dumber/internal/application/port" | |
| 13 | + | domainfavicon "github.com/bnema/dumber/internal/domain/favicon" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 15 | 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 | 53 | func TestFetcherMissesAndGuards(t *testing.T) { | |
| 35 | 54 | fetcher := NewFetcher() | |
| 36 | 55 | 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 { | |||
| 23 | 23 | resolver port.FaviconResolver | |
| 24 | 24 | faviconDB port.FaviconDatabase | |
| 25 | 25 | textureCache map[string]*gdk.Texture | |
| 26 | - | aliasesByKey map[favicon.Key]map[string]struct{} | |
| 27 | 26 | mu sync.RWMutex | |
| 28 | 27 | warnMu sync.Mutex | |
| 29 | 28 | warnCounts map[string]int | |
| @@ -75,7 +74,6 @@ func NewFaviconAdapterWithResolver( | |||
| 75 | 74 | resolver: resolver, | |
| 76 | 75 | faviconDB: faviconDB, | |
| 77 | 76 | textureCache: make(map[string]*gdk.Texture), | |
| 78 | - | aliasesByKey: make(map[favicon.Key]map[string]struct{}), | |
| 79 | 77 | warnCounts: make(map[string]int), | |
| 80 | 78 | isInternalURL: cfg.IsInternalURL, | |
| 81 | 79 | internalDomain: cfg.InternalDomain, | |
| @@ -97,19 +95,41 @@ func (a *FaviconAdapter) GetTexture(domain string) *gdk.Texture { | |||
| 97 | 95 | return texture | |
| 98 | 96 | } | |
| 99 | 97 | ||
| 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. | |
| 102 | 101 | 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) { | |
| 104 | 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) | |
| 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) | |
| 109 | 126 | } | |
| 110 | 127 | ||
| 111 | 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 | 133 | // For internal dumb:// URLs, returns the app logo texture. | |
| 114 | 134 | func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callback func(*gdk.Texture)) { | |
| 115 | 135 | if callback == nil { | |
| @@ -137,18 +157,27 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac | |||
| 137 | 157 | Str("domain", domain). | |
| 138 | 158 | Msg("favicon: GetOrFetch begin") | |
| 139 | 159 | ||
| 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 { | |
| 145 | 171 | callback(texture) | |
| 172 | + | a.probeExactEngineFavicon(ctx, pageURL, domain, key, callback) | |
| 146 | 173 | return | |
| 147 | 174 | } | |
| 148 | 175 | ||
| 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)) | |
| 151 | 179 | callback(texture) | |
| 180 | + | a.probeExactEngineFavicon(ctx, pageURL, domain, key, callback) | |
| 152 | 181 | return | |
| 153 | 182 | } | |
| 154 | 183 | ||
| @@ -165,6 +194,7 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac | |||
| 165 | 194 | // Ensure sized PNG exists for CLI tools (async, idempotent) | |
| 166 | 195 | go a.ensureSizedPNG(ctx, domain) | |
| 167 | 196 | callback(texture) | |
| 197 | + | a.probeExactEngineFavicon(ctx, pageURL, domain, favicon.Key(domain), callback) | |
| 168 | 198 | return | |
| 169 | 199 | } | |
| 170 | 200 | } | |
| @@ -172,9 +202,7 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac | |||
| 172 | 202 | ||
| 173 | 203 | // Try engine FaviconDatabase if available | |
| 174 | 204 | 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) | |
| 178 | 206 | 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 | 254 | if a.resolver == nil { | |
| 227 | - | return nil | |
| 255 | + | return nil, "" | |
| 228 | 256 | } | |
| 229 | 257 | options := port.FaviconResolveOptions{Purpose: port.FaviconResolvePurposeUI} | |
| 230 | 258 | if scheduleRefresh { | |
| @@ -232,13 +260,13 @@ func (a *FaviconAdapter) resolveTexture(ctx context.Context, pageURL string, sch | |||
| 232 | 260 | } | |
| 233 | 261 | resolved, err := a.resolver.Resolve(ctx, pageURL, 0, options) | |
| 234 | 262 | if err != nil || resolved == nil || len(resolved.Bytes) == 0 { | |
| 235 | - | return nil | |
| 263 | + | return nil, "" | |
| 236 | 264 | } | |
| 237 | 265 | texture := a.textureFromBytesOnGTK(ctx, resolved.Bytes) | |
| 238 | 266 | if texture != nil { | |
| 239 | 267 | a.setResolvedTexture(pageURL, resolved.Key, texture) | |
| 240 | 268 | } | |
| 241 | - | return texture | |
| 269 | + | return texture, resolved.Key | |
| 242 | 270 | } | |
| 243 | 271 | ||
| 244 | 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 | 334 | func (a *FaviconAdapter) handleEngineFaviconDBTexture( | |
| 280 | 335 | ctx context.Context, | |
| 281 | 336 | pageURL, domain string, | |
| 282 | 337 | callback func(*gdk.Texture), | |
| 283 | 338 | tex port.Texture, | |
| 339 | + | fetchViaServiceOnMiss bool, | |
| 284 | 340 | ) { | |
| 285 | 341 | log := logging.FromContext(ctx) | |
| 286 | 342 | log.Debug(). | |
| @@ -288,17 +344,25 @@ func (a *FaviconAdapter) handleEngineFaviconDBTexture( | |||
| 288 | 344 | Bool("texture_nil", tex == nil). | |
| 289 | 345 | Msg("favicon: engine db callback") | |
| 290 | 346 | 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 | + | } | |
| 293 | 351 | return | |
| 294 | 352 | } | |
| 295 | 353 | // Convert port.Texture to *gdk.Texture | |
| 296 | 354 | gdkTex, ok := tex.(*gdk.Texture) | |
| 297 | 355 | if !ok { | |
| 298 | - | a.fetchViaService(ctx, domain, callback) | |
| 356 | + | if fetchViaServiceOnMiss { | |
| 357 | + | a.fetchViaService(ctx, domain, callback) | |
| 358 | + | } | |
| 299 | 359 | 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 | 366 | a.invokeCallbackOnGTK(ctx, domain, callback, gdkTex) | |
| 303 | 367 | a.storeTextureObservation(ctx, pageURL, gdkTex) | |
| 304 | 368 | } | |
| @@ -321,15 +385,14 @@ func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, te | |||
| 321 | 385 | return | |
| 322 | 386 | } | |
| 323 | 387 | ||
| 324 | - | // Store in texture cache | |
| 325 | - | a.setTexture(domain, texture) | |
| 326 | - | ||
| 327 | 388 | if a.resolver != nil { | |
| 389 | + | a.setTextureForURL(pageURL, texture) | |
| 328 | 390 | a.storeTextureObservation(ctx, pageURL, texture) | |
| 329 | 391 | return | |
| 330 | 392 | } | |
| 331 | 393 | ||
| 332 | 394 | // Legacy fallback for callers not yet wired to the application resolver. | |
| 395 | + | a.setTexture(domain, texture) | |
| 333 | 396 | a.saveFaviconToDisk(ctx, domain, texture) | |
| 334 | 397 | } | |
| 335 | 398 | ||
| @@ -340,18 +403,21 @@ func (a *FaviconAdapter) StoreFromWebKitWithOrigin( | |||
| 340 | 403 | ) { | |
| 341 | 404 | a.StoreFromWebKit(ctx, currentURL, texture) | |
| 342 | 405 | ||
| 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) | |
| 355 | 421 | } | |
| 356 | 422 | } | |
| 357 | 423 | ||
| @@ -471,14 +537,18 @@ func (a *FaviconAdapter) PreloadFromCache(ctx context.Context, pageURL string) * | |||
| 471 | 537 | Str("domain", domain). | |
| 472 | 538 | Msg("favicon: PreloadFromCache begin") | |
| 473 | 539 | ||
| 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 { | |
| 477 | 547 | return texture | |
| 478 | 548 | } | |
| 479 | 549 | ||
| 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") | |
| 482 | 552 | 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 aliases | |
| 518 | - | // explicitly bound to that canonical favicon key. | |
| 587 | + | // Invalidate clears one resolved-key texture cache entry. | |
| 519 | 588 | func (a *FaviconAdapter) Invalidate(_ context.Context, key favicon.Key) error { | |
| 520 | 589 | a.mu.Lock() | |
| 521 | 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 | 591 | a.mu.Unlock() | |
| 527 | 592 | return nil | |
| 528 | 593 | } | |
| @@ -531,7 +596,6 @@ func (a *FaviconAdapter) Invalidate(_ context.Context, key favicon.Key) error { | |||
| 531 | 596 | func (a *FaviconAdapter) Clear() { | |
| 532 | 597 | a.mu.Lock() | |
| 533 | 598 | a.textureCache = make(map[string]*gdk.Texture) | |
| 534 | - | a.aliasesByKey = make(map[favicon.Key]map[string]struct{}) | |
| 535 | 599 | a.mu.Unlock() | |
| 536 | 600 | } | |
| 537 | 601 | ||
| @@ -543,35 +607,17 @@ func (a *FaviconAdapter) Size() int { | |||
| 543 | 607 | return size | |
| 544 | 608 | } | |
| 545 | 609 | ||
| 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) { | |
| 550 | 615 | if key == "" || texture == nil { | |
| 551 | 616 | return | |
| 552 | 617 | } | |
| 553 | - | domain := domainurl.ExtractDomain(pageURL) | |
| 554 | 618 | a.mu.Lock() | |
| 555 | - | defer a.mu.Unlock() | |
| 556 | 619 | 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() | |
| 575 | 621 | } | |
| 576 | 622 | ||
| 577 | 623 | // setTexture stores a texture in the cache. | |
| @@ -23,7 +23,6 @@ type FaviconAdapter struct { | |||||
| 23 | resolver port.FaviconResolver | 23 | resolver port.FaviconResolver | ||
| 24 | faviconDB port.FaviconDatabase | 24 | faviconDB port.FaviconDatabase | ||
| 25 | textureCache map[string]*gdk.Texture | 25 | textureCache map[string]*gdk.Texture | ||
| 26 | - | aliasesByKey map[favicon.Key]map[string]struct{} | |||
| 27 | mu sync.RWMutex | 26 | mu sync.RWMutex | ||
| 28 | warnMu sync.Mutex | 27 | warnMu sync.Mutex | ||
| 29 | warnCounts map[string]int | 28 | 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 texture | 95 | 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 URLs | 102 | + | 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 first | 160 | + | // 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 | return | 173 | 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 | return | 181 | 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 | return | 198 | 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 available | 203 | // 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 | return | 206 | 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 nil | 255 | + | 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 nil | 263 | + | 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 texture | 269 | + | 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 | return | 351 | return | ||
| 294 | } | 352 | } | ||
| 295 | // Convert port.Texture to *gdk.Texture | 353 | // 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 | return | 359 | 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 | return | 385 | 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 | return | 391 | 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 different | 406 | + | 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 cache | 540 | + | 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 texture | 547 | 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 texture | 552 | 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 aliases | 587 | + | // 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 nil | 592 | 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 size | 607 | return size | ||
| 544 | } | 608 | } | ||
| 545 | 609 | ||||
| 546 | - | // setResolvedTexture stores resolver results under both the resolved favicon key | 610 | + | // setResolvedTexture stores resolver results only under the resolved favicon |
| 547 | - | // and the current page domain. Aliases are indexed by canonical key so | 611 | + | // 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 | return | 616 | 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)] = texture | 619 | 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 ( | |||
| 5 | 5 | "sync/atomic" | |
| 6 | 6 | "testing" | |
| 7 | 7 | ||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |
| 9 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |
| 8 | 10 | "github.com/bnema/dumber/internal/domain/favicon" | |
| 9 | 11 | "github.com/bnema/puregotk/v4/gdk" | |
| 10 | 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 | 17 | adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{}) | |
| 15 | 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 { | |
| 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") | |
| 20 | 23 | } | |
| 21 | 24 | if err := adapter.Invalidate(context.Background(), "example.com"); err != nil { | |
| 22 | 25 | t.Fatal(err) | |
| 23 | 26 | } | |
| 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") | |
| 26 | 29 | } | |
| 27 | 30 | } | |
| 28 | 31 | ||
| 29 | - | func TestFaviconAdapterInvalidateRemovesOlderAliasesAfterKeyRebind(t *testing.T) { | |
| 32 | + | func TestFaviconAdapterGetTextureByURLPrefersExactThenParentThenDomainFallback(t *testing.T) { | |
| 30 | 33 | 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) | |
| 33 | 40 | ||
| 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") | |
| 36 | 227 | } | |
| 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") | |
| 39 | 230 | } | |
| 40 | 231 | } | |
| 41 | 232 | ||
| @@ -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 | ||||