| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/sha256" |
| 7 | "encoding/hex" |
| 8 | "errors" |
| 9 | "fmt" |
| 10 | "io" |
| 11 | "os" |
| 12 | "path/filepath" |
| 13 | "strings" |
| 14 | "sync" |
| 15 | "testing" |
| 16 | "time" |
| 17 | |
| 18 | "github.com/carlosframework/platform/internal/geoip" |
| 19 | "github.com/carlosframework/platform/internal/release" |
| 20 | ) |
| 21 | |
| 22 | // The repo's fixtures hold none of the production probe addresses |
| 23 | // (FACTS.md), so tests swap in addresses the fixtures do resolve. |
| 24 | func withFixtureProbes(t *testing.T) { |
| 25 | prev := geoipProbeAddrs |
| 26 | geoipProbeAddrs = []string{"81.2.69.142", "216.160.83.56", "89.160.20.112", "2001:218::1", "67.43.156.1"} |
| 27 | t.Cleanup(func() { geoipProbeAddrs = prev }) |
| 28 | } |
| 29 | |
| 30 | func fixture(t *testing.T, name string) []byte { |
| 31 | b, err := os.ReadFile("../geo/testdata/" + name) |
| 32 | if err != nil { |
| 33 | t.Fatal(err) |
| 34 | } |
| 35 | return b |
| 36 | } |
| 37 | |
| 38 | func newTestPublisher(t *testing.T) (*geoipPublisher, release.Store) { |
| 39 | s := release.NewDirStore(t.TempDir()) |
| 40 | return newGeoipPublisher(s, t.TempDir()), s |
| 41 | } |
| 42 | |
| 43 | func blobKeys(t *testing.T, s release.Store) []string { |
| 44 | keys, err := s.List(context.Background(), geoip.Prefix) |
| 45 | if err != nil { |
| 46 | t.Fatal(err) |
| 47 | } |
| 48 | var out []string |
| 49 | for _, k := range keys { |
| 50 | if strings.HasSuffix(k, ".mmdb") { |
| 51 | out = append(out, k) |
| 52 | } |
| 53 | } |
| 54 | return out |
| 55 | } |
| 56 | |
| 57 | func TestPublishWritesBlobThenPointer(t *testing.T) { |
| 58 | withFixtureProbes(t) |
| 59 | p, s := newTestPublisher(t) |
| 60 | ptr, err := p.Publish(context.Background(), bytes.NewReader(fixture(t, "GeoIP2-City-Test.mmdb")), geoipPublishOpts{Origin: "manual"}) |
| 61 | if err != nil { |
| 62 | t.Fatal(err) |
| 63 | } |
| 64 | if ptr.DatabaseType != "GeoIP2-City" || ptr.NodeCount != 1547 || ptr.Origin != "manual" || ptr.Key != geoip.BlobKey(ptr.SHA256) { |
| 65 | t.Fatalf("pointer = %+v", ptr) |
| 66 | } |
| 67 | raw, _, err := s.Get(context.Background(), geoip.PointerKey) |
| 68 | if err != nil { |
| 69 | t.Fatal(err) |
| 70 | } |
| 71 | if got, err := geoip.ParsePointer(raw); err != nil || got.SHA256 != ptr.SHA256 { |
| 72 | t.Fatalf("stored pointer = (%+v, %v)", got, err) |
| 73 | } |
| 74 | if keys := blobKeys(t, s); len(keys) != 1 || keys[0] != ptr.Key { |
| 75 | t.Fatalf("blobs = %v", keys) |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | func TestPublishRefusals(t *testing.T) { |
| 80 | withFixtureProbes(t) |
| 81 | ctx := context.Background() |
| 82 | p, _ := newTestPublisher(t) |
| 83 | for name, body := range map[string][]byte{ |
| 84 | "not a database": []byte("junk"), |
| 85 | "no country data": fixture(t, "MaxMind-DB-test-ipv4-24.mmdb"), |
| 86 | } { |
| 87 | if _, err := p.Publish(ctx, bytes.NewReader(body), geoipPublishOpts{Origin: "manual"}); !errors.Is(err, errGeoipRefused) { |
| 88 | t.Errorf("%s: err = %v, want errGeoipRefused", name, err) |
| 89 | } |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | func TestPublishShrinkCheckOnlyForTheSameType(t *testing.T) { |
| 94 | withFixtureProbes(t) |
| 95 | ctx := context.Background() |
| 96 | p, s := newTestPublisher(t) |
| 97 | if _, err := p.Publish(ctx, bytes.NewReader(fixture(t, "GeoIP2-Country-Test.mmdb")), geoipPublishOpts{Origin: "manual"}); err != nil { |
| 98 | t.Fatal(err) |
| 99 | } |
| 100 | // Inflate the recorded node_count so a same-type republish looks like a |
| 101 | // truncation (fewer than half). |
| 102 | raw, etag, _ := s.Get(ctx, geoip.PointerKey) |
| 103 | inflated := bytes.Replace(raw, []byte(`"node_count": 1704`), []byte(`"node_count": 99999`), 1) |
| 104 | if bytes.Equal(inflated, raw) { |
| 105 | t.Fatalf("pointer JSON shape changed; adjust the test: %s", raw) |
| 106 | } |
| 107 | if _, err := s.Put(ctx, geoip.PointerKey, inflated, etag); err != nil { |
| 108 | t.Fatal(err) |
| 109 | } |
| 110 | same := fixture(t, "GeoIP2-Country-Test.mmdb") |
| 111 | if _, err := p.Publish(ctx, bytes.NewReader(same), geoipPublishOpts{Origin: "manual"}); !errors.Is(err, errGeoipRefused) { |
| 112 | t.Fatalf("same-type shrink = %v, want refused", err) |
| 113 | } |
| 114 | if _, err := p.Publish(ctx, bytes.NewReader(same), geoipPublishOpts{Origin: "manual", Force: true}); err != nil { |
| 115 | t.Fatalf("forced = %v", err) |
| 116 | } |
| 117 | // A different type skips the check entirely. Re-inflate first, or the |
| 118 | // City fixture (1547 nodes) would pass against Country's real 1704 even |
| 119 | // if the check were wrongly applied across types (plan review R1-20). |
| 120 | raw, etag, _ = s.Get(ctx, geoip.PointerKey) |
| 121 | inflated = bytes.Replace(raw, []byte(`"node_count": 1704`), []byte(`"node_count": 99999`), 1) |
| 122 | if _, err := s.Put(ctx, geoip.PointerKey, inflated, etag); err != nil { |
| 123 | t.Fatal(err) |
| 124 | } |
| 125 | if _, err := p.Publish(ctx, bytes.NewReader(fixture(t, "GeoIP2-City-Test.mmdb")), geoipPublishOpts{Origin: "manual"}); err != nil { |
| 126 | t.Fatalf("different type = %v", err) |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | func TestPublishSameFileTwiceAndXYXKeepsTheCurrentBlob(t *testing.T) { |
| 131 | withFixtureProbes(t) |
| 132 | ctx := context.Background() |
| 133 | p, s := newTestPublisher(t) |
| 134 | x, y := fixture(t, "GeoIP2-City-Test.mmdb"), fixture(t, "GeoIP2-Country-Test.mmdb") |
| 135 | for i, body := range [][]byte{x, x, y, x} { |
| 136 | ptr, err := p.Publish(ctx, bytes.NewReader(body), geoipPublishOpts{Origin: "manual", Force: true}) |
| 137 | if err != nil { |
| 138 | t.Fatalf("publish %d: %v", i, err) |
| 139 | } |
| 140 | keys := blobKeys(t, s) |
| 141 | if len(keys) != 1 || keys[0] != ptr.Key { |
| 142 | t.Fatalf("after publish %d blobs = %v, want only %s", i, keys, ptr.Key) |
| 143 | } |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | func TestPublishIsOneAtATime(t *testing.T) { |
| 148 | withFixtureProbes(t) |
| 149 | p, _ := newTestPublisher(t) |
| 150 | p.mu.Lock() // a publish in progress |
| 151 | _, err := p.Publish(context.Background(), bytes.NewReader(fixture(t, "GeoIP2-City-Test.mmdb")), geoipPublishOpts{Origin: "manual"}) |
| 152 | p.mu.Unlock() |
| 153 | if !errors.Is(err, errGeoipBusy) { |
| 154 | t.Fatalf("concurrent publish = %v, want errGeoipBusy", err) |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | func TestPublishRefusesOversizeWhileCopying(t *testing.T) { |
| 159 | withFixtureProbes(t) |
| 160 | p, _ := newTestPublisher(t) |
| 161 | p.maxBytes = 1024 |
| 162 | _, err := p.Publish(context.Background(), bytes.NewReader(fixture(t, "GeoIP2-City-Test.mmdb")), geoipPublishOpts{Origin: "manual"}) |
| 163 | if !errors.Is(err, errGeoipRefused) { |
| 164 | t.Fatalf("oversize = %v", err) |
| 165 | } |
| 166 | if entries, _ := os.ReadDir(p.tmpRoot); len(entries) != 0 { |
| 167 | t.Fatalf("temp left behind: %v", entries) |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | func TestOffAndSweep(t *testing.T) { |
| 172 | withFixtureProbes(t) |
| 173 | ctx := context.Background() |
| 174 | p, s := newTestPublisher(t) |
| 175 | ptr, err := p.Publish(ctx, bytes.NewReader(fixture(t, "GeoIP2-City-Test.mmdb")), geoipPublishOpts{Origin: "manual"}) |
| 176 | if err != nil { |
| 177 | t.Fatal(err) |
| 178 | } |
| 179 | // An orphan from a crashed publish (spec §3 orphan sweep). |
| 180 | orphan := geoip.BlobKey(strings.Repeat("a", 64)) |
| 181 | if _, err := s.Put(ctx, orphan, []byte("x"), ""); err != nil { |
| 182 | t.Fatal(err) |
| 183 | } |
| 184 | if err := p.Sweep(ctx); err != nil { |
| 185 | t.Fatal(err) |
| 186 | } |
| 187 | if keys := blobKeys(t, s); len(keys) != 1 || keys[0] != ptr.Key { |
| 188 | t.Fatalf("after sweep = %v", keys) |
| 189 | } |
| 190 | if err := p.Off(ctx); err != nil { |
| 191 | t.Fatal(err) |
| 192 | } |
| 193 | if _, _, err := s.Get(ctx, geoip.PointerKey); !errors.Is(err, os.ErrNotExist) { |
| 194 | t.Fatalf("pointer after off: %v", err) |
| 195 | } |
| 196 | if keys := blobKeys(t, s); len(keys) != 0 { |
| 197 | t.Fatalf("blobs after off = %v", keys) |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | func TestExpireIfDueMarksGeoLiteExpired(t *testing.T) { |
| 202 | ctx := context.Background() |
| 203 | p, s := newTestPublisher(t) |
| 204 | sha := strings.Repeat("b", 64) |
| 205 | ptr := geoip.Pointer{V: 1, SHA256: sha, Key: geoip.BlobKey(sha), Size: 1, DatabaseType: "GeoLite2-City", |
| 206 | BuildEpoch: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC).Unix(), Origin: "manual"} |
| 207 | putPointer(t, s, ptr) |
| 208 | if _, err := s.Put(ctx, ptr.Key, []byte("x"), ""); err != nil { |
| 209 | t.Fatal(err) |
| 210 | } |
| 211 | if did, err := p.ExpireIfDue(ctx, time.Date(2026, 9, 20, 0, 0, 0, 0, time.UTC)); did || err != nil { |
| 212 | t.Fatalf("early = (%v, %v)", did, err) |
| 213 | } |
| 214 | if did, err := p.ExpireIfDue(ctx, time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)); !did || err != nil { |
| 215 | t.Fatalf("due = (%v, %v)", did, err) |
| 216 | } |
| 217 | got, _, present, err := p.Current(ctx) |
| 218 | if err != nil || !present || !got.Expired { |
| 219 | t.Fatalf("pointer = (%+v, %v, %v)", got, present, err) |
| 220 | } |
| 221 | if keys := blobKeys(t, s); len(keys) != 0 { |
| 222 | t.Fatalf("blob not destroyed: %v", keys) |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | func putPointer(t *testing.T, s release.Store, p geoip.Pointer) { |
| 227 | t.Helper() |
| 228 | b, err := marshalPointer(p) |
| 229 | if err != nil { |
| 230 | t.Fatal(err) |
| 231 | } |
| 232 | if _, err := s.Put(context.Background(), geoip.PointerKey, b, ""); err != nil { |
| 233 | t.Fatal(err) |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | var _ = sync.Mutex{} |
| 238 | |
| 239 | func TestPublisherStartupClearsAbandonedStaging(t *testing.T) { |
| 240 | withFixtureProbes(t) |
| 241 | root := filepath.Join(t.TempDir(), "geoip-staging") |
| 242 | abandoned := filepath.Join(root, "geoip-publish-123") |
| 243 | if err := os.MkdirAll(abandoned, 0o700); err != nil { |
| 244 | t.Fatal(err) |
| 245 | } |
| 246 | if err := os.WriteFile(filepath.Join(abandoned, "db.mmdb"), []byte("half an upload"), 0o600); err != nil { |
| 247 | t.Fatal(err) |
| 248 | } |
| 249 | p := newGeoipPublisher(release.NewDirStore(t.TempDir()), root) |
| 250 | if entries, _ := os.ReadDir(root); len(entries) != 0 { |
| 251 | t.Fatalf("staging not cleared at startup: %v", entries) |
| 252 | } |
| 253 | if _, err := p.Publish(context.Background(), bytes.NewReader(fixture(t, "GeoIP2-City-Test.mmdb")), geoipPublishOpts{Origin: "manual"}); err != nil { |
| 254 | t.Fatalf("publish after restart: %v", err) |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | func TestPublishRefusesAWrongObjectAtTheBlobKey(t *testing.T) { |
| 259 | withFixtureProbes(t) |
| 260 | ctx := context.Background() |
| 261 | p, s := newTestPublisher(t) |
| 262 | body := fixture(t, "GeoIP2-City-Test.mmdb") |
| 263 | sum := sha256.Sum256(body) |
| 264 | key := geoip.BlobKey(hex.EncodeToString(sum[:])) |
| 265 | if _, err := s.Put(ctx, key, body[:len(body)/2], ""); err != nil { // a truncated object squatting the name |
| 266 | t.Fatal(err) |
| 267 | } |
| 268 | if _, err := p.Publish(ctx, bytes.NewReader(body), geoipPublishOpts{Origin: "manual"}); !errors.Is(err, errGeoipRefused) { |
| 269 | t.Fatalf("publish over a wrong object = %v, want refused", err) |
| 270 | } |
| 271 | if _, _, err := s.Get(ctx, geoip.PointerKey); !errors.Is(err, os.ErrNotExist) { |
| 272 | t.Fatal("the pointer moved onto a wrong blob") |
| 273 | } |
| 274 | } |
| 275 | |
| 276 | func TestPublishRefusesAnExpiredDatabaseEvenWithForce(t *testing.T) { |
| 277 | withFixtureProbes(t) |
| 278 | ctx := context.Background() |
| 279 | p, s := newTestPublisher(t) |
| 280 | good, err := p.Publish(ctx, bytes.NewReader(fixture(t, "GeoIP2-City-Test.mmdb")), geoipPublishOpts{Origin: "manual"}) |
| 281 | if err != nil { |
| 282 | t.Fatal(err) |
| 283 | } |
| 284 | p.expired = func(string, time.Time, time.Time) bool { return true } |
| 285 | _, err = p.Publish(ctx, bytes.NewReader(fixture(t, "GeoIP2-Country-Test.mmdb")), geoipPublishOpts{Origin: "manual", Force: true}) |
| 286 | if !errors.Is(err, errGeoipRefused) { |
| 287 | t.Fatalf("expired candidate with force = %v, want refused (codex R7-1)", err) |
| 288 | } |
| 289 | cur, _, _, _ := p.Current(ctx) |
| 290 | if cur.SHA256 != good.SHA256 { |
| 291 | t.Fatal("a refused expired candidate moved the pointer") |
| 292 | } |
| 293 | if keys := blobKeys(t, s); len(keys) != 1 || keys[0] != good.Key { |
| 294 | t.Fatalf("blobs after refusal = %v, want only the previous good one", keys) |
| 295 | } |
| 296 | } |
| 297 | |
| 298 | func TestCommitRefusesAForeignPointerWrite(t *testing.T) { |
| 299 | withFixtureProbes(t) |
| 300 | ctx := context.Background() |
| 301 | p, s := newTestPublisher(t) |
| 302 | first, err := p.Publish(ctx, bytes.NewReader(fixture(t, "GeoIP2-City-Test.mmdb")), geoipPublishOpts{Origin: "manual"}) |
| 303 | if err != nil { |
| 304 | t.Fatal(err) |
| 305 | } |
| 306 | st, err := p.Stage(ctx, bytes.NewReader(fixture(t, "GeoIP2-Country-Test.mmdb"))) |
| 307 | if err != nil { |
| 308 | t.Fatal(err) |
| 309 | } |
| 310 | // Something outside this console rewrites the pointer between staging |
| 311 | // and commit (spec §3 step 3). |
| 312 | raw, etag, _ := s.Get(ctx, geoip.PointerKey) |
| 313 | if _, err := s.Put(ctx, geoip.PointerKey, append(raw, ' '), etag); err != nil { |
| 314 | t.Fatal(err) |
| 315 | } |
| 316 | if _, err := p.Commit(ctx, st, geoipPublishOpts{Origin: "manual", Force: true}); err == nil { |
| 317 | t.Fatal("commit over a foreign pointer write succeeded") |
| 318 | } |
| 319 | cur, _, _, _ := p.Current(ctx) |
| 320 | if cur.SHA256 != first.SHA256 { |
| 321 | t.Fatal("the foreign pointer was overwritten") |
| 322 | } |
| 323 | if !p.mu.TryLock() { |
| 324 | t.Fatal("a failed commit must release the publish slot") |
| 325 | } |
| 326 | p.mu.Unlock() |
| 327 | } |
| 328 | |
| 329 | func TestOffRefusesDuringAPublishThenSucceeds(t *testing.T) { |
| 330 | withFixtureProbes(t) |
| 331 | ctx := context.Background() |
| 332 | p, s := newTestPublisher(t) |
| 333 | st, err := p.Stage(ctx, bytes.NewReader(fixture(t, "GeoIP2-City-Test.mmdb"))) |
| 334 | if err != nil { |
| 335 | t.Fatal(err) |
| 336 | } |
| 337 | // Off refuses rather than waits while the slot is held (codex R9-2)... |
| 338 | if err := p.Off(ctx); !errors.Is(err, errGeoipBusy) { |
| 339 | t.Fatalf("off during a publish = %v, want errGeoipBusy", err) |
| 340 | } |
| 341 | if _, err := p.Commit(ctx, st, geoipPublishOpts{Origin: "manual"}); err != nil { |
| 342 | t.Fatal(err) |
| 343 | } |
| 344 | // ...and succeeds once the publish has finished. |
| 345 | if err := p.Off(ctx); err != nil { |
| 346 | t.Fatal(err) |
| 347 | } |
| 348 | if _, _, err := s.Get(ctx, geoip.PointerKey); !errors.Is(err, os.ErrNotExist) { |
| 349 | t.Fatal("off, serialised after the publish, must leave no pointer") |
| 350 | } |
| 351 | if keys := blobKeys(t, s); len(keys) != 0 { |
| 352 | t.Fatalf("blobs after off = %v", keys) |
| 353 | } |
| 354 | } |
| 355 | |
| 356 | // Code review R1-3: an oversized object at the pointer key is never read |
| 357 | // whole, and is reported as present-but-not-a-pointer. |
| 358 | func TestCurrentRefusesAnOversizedPointer(t *testing.T) { |
| 359 | ctx := context.Background() |
| 360 | p, s := newTestPublisher(t) |
| 361 | if _, err := s.Put(ctx, geoip.PointerKey, bytes.Repeat([]byte("x"), geoip.MaxPointerBytes+1), ""); err != nil { |
| 362 | t.Fatal(err) |
| 363 | } |
| 364 | _, etag, present, err := p.Current(ctx) |
| 365 | if !errors.Is(err, errPointerTooLarge) || !present { |
| 366 | t.Fatalf("Current = (%v, %v), want errPointerTooLarge, present", present, err) |
| 367 | } |
| 368 | _ = etag |
| 369 | } |
| 370 | |
| 371 | type panicReader struct{} |
| 372 | |
| 373 | func (panicReader) Read([]byte) (int, error) { panic("a busy Stage read its body") } |
| 374 | |
| 375 | // Code review R1-5: a Stage refused for a held slot never touches its body. |
| 376 | func TestStageNeverReadsWhenTheSlotIsHeld(t *testing.T) { |
| 377 | p, _ := newTestPublisher(t) |
| 378 | p.mu.Lock() |
| 379 | defer p.mu.Unlock() |
| 380 | if _, err := p.Stage(context.Background(), panicReader{}); !errors.Is(err, errGeoipBusy) { |
| 381 | t.Fatalf("Stage = %v, want errGeoipBusy", err) |
| 382 | } |
| 383 | } |
| 384 | |
| 385 | // Code review R1-6: the other row sorts last whatever its size. |
| 386 | func TestGeoCountriesKeepsOtherLast(t *testing.T) { |
| 387 | m := map[string]int64{} |
| 388 | for i := 0; i < 15; i++ { |
| 389 | m[fmt.Sprintf("C%02d", i)] = 10 |
| 390 | } |
| 391 | m["GB"] = 11 |
| 392 | rows := sortedKV(geoCountries(m)) |
| 393 | if rows[len(rows)-1].Key != "_other" || geoLabel(rows[len(rows)-1].Key) != "other" { |
| 394 | t.Fatalf("last row = %+v, want _other rendered as other", rows[len(rows)-1]) |
| 395 | } |
| 396 | } |
| 397 | |
| 398 | func TestFeedPublishRules(t *testing.T) { |
| 399 | withFixtureProbes(t) |
| 400 | ctx := context.Background() |
| 401 | p, _ := newTestPublisher(t) |
| 402 | city := fixture(t, "GeoIP2-City-Test.mmdb") |
| 403 | if _, err := p.Publish(ctx, bytes.NewReader(city), geoipPublishOpts{Origin: "feed:x", RequireType: "GeoIP2-Country", Monotonic: true}); !errors.Is(err, errGeoipRefused) || !strings.Contains(err.Error(), "not GeoIP2-Country") { |
| 404 | t.Fatalf("wrong type on first publish = %v", err) |
| 405 | } |
| 406 | first, err := p.Publish(ctx, bytes.NewReader(city), geoipPublishOpts{Origin: "feed:x", RequireType: "GeoIP2-City", Monotonic: true}) |
| 407 | if err != nil { |
| 408 | t.Fatal(err) |
| 409 | } |
| 410 | if _, err := p.Publish(ctx, bytes.NewReader(city), geoipPublishOpts{Origin: "feed:x", RequireType: "GeoIP2-City", Monotonic: true}); !errors.Is(err, errGeoipRefused) || !strings.Contains(err.Error(), "not newer") { |
| 411 | t.Fatalf("same edition again = %v", err) |
| 412 | } |
| 413 | if cur, _, _, _ := p.Current(ctx); cur.SHA256 != first.SHA256 { |
| 414 | t.Fatal("a refused feed publish moved the pointer") |
| 415 | } |
| 416 | // A manual pointer does not hold the feed back. |
| 417 | if _, err := p.Publish(ctx, bytes.NewReader(fixture(t, "GeoIP2-Country-Test.mmdb")), geoipPublishOpts{Origin: "manual", Force: true}); err != nil { |
| 418 | t.Fatal(err) |
| 419 | } |
| 420 | if _, err := p.Publish(ctx, bytes.NewReader(city), geoipPublishOpts{Origin: "feed:x", RequireType: "GeoIP2-City", Monotonic: true}); err != nil { |
| 421 | t.Fatalf("feed over a manual pointer = %v", err) |
| 422 | } |
| 423 | } |
| 424 | |
| 425 | type stallingStore struct{ release.Store } |
| 426 | |
| 427 | func (stallingStore) Open(ctx context.Context, key string) (io.ReadCloser, string, error) { |
| 428 | <-ctx.Done() |
| 429 | return nil, "", ctx.Err() |
| 430 | } |
| 431 | |
| 432 | func (s stallingStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 433 | <-ctx.Done() |
| 434 | return nil, "", ctx.Err() |
| 435 | } |
| 436 | |
| 437 | // Code review R4-1: a stalled bucket cannot hold the publish slot forever. |
| 438 | func TestStageGivesUpOnAStalledStoreAndFreesTheSlot(t *testing.T) { |
| 439 | p, s := newTestPublisher(t) |
| 440 | p.store = stallingStore{s} |
| 441 | p.storeTimeout = 50 * time.Millisecond |
| 442 | start := time.Now() |
| 443 | if _, err := p.Stage(context.Background(), bytes.NewReader([]byte("x"))); err == nil { |
| 444 | t.Fatal("Stage succeeded against a stalled store") |
| 445 | } |
| 446 | if time.Since(start) > 5*time.Second { |
| 447 | t.Fatal("Stage waited on the stalled store") |
| 448 | } |
| 449 | if !p.mu.TryLock() { |
| 450 | t.Fatal("the slot stayed held after giving up") |
| 451 | } |
| 452 | p.mu.Unlock() |
| 453 | } |
| 454 | |
| 455 | // Code review R8-1: a crafted tree is refused in time, the slot is freed, |
| 456 | // staging is cleaned and the previous pointer survives. |
| 457 | func TestPublishRefusesAPathologicalTreeInTime(t *testing.T) { |
| 458 | withFixtureProbes(t) |
| 459 | ctx := context.Background() |
| 460 | p, _ := newTestPublisher(t) |
| 461 | good, err := p.Publish(ctx, bytes.NewReader(fixture(t, "GeoIP2-City-Test.mmdb")), geoipPublishOpts{Origin: "manual"}) |
| 462 | if err != nil { |
| 463 | t.Fatal(err) |
| 464 | } |
| 465 | start := time.Now() |
| 466 | _, err = p.Publish(ctx, bytes.NewReader(fixture(t, "pathological-dag.mmdb")), geoipPublishOpts{Origin: "manual", Force: true}) |
| 467 | if !errors.Is(err, errGeoipRefused) || !strings.Contains(err.Error(), "search tree") { |
| 468 | t.Fatalf("err = %v", err) |
| 469 | } |
| 470 | if time.Since(start) > 5*time.Second { |
| 471 | t.Fatal("took too long") |
| 472 | } |
| 473 | if cur, _, _, _ := p.Current(ctx); cur.SHA256 != good.SHA256 { |
| 474 | t.Fatal("the previous pointer was replaced") |
| 475 | } |
| 476 | if !p.mu.TryLock() { |
| 477 | t.Fatal("slot still held") |
| 478 | } |
| 479 | p.mu.Unlock() |
| 480 | if entries, _ := os.ReadDir(p.tmpRoot); len(entries) != 0 { |
| 481 | t.Fatalf("staging left behind: %v", entries) |
| 482 | } |
| 483 | } |
| 484 | |
| 485 | // Code review R9: databases that hang or panic the parser are refused in |
| 486 | // time, by the child, and the console survives with its slot and pointer. |
| 487 | func TestPublishSurvivesDatabasesThatHangOrPanicTheParser(t *testing.T) { |
| 488 | withFixtureProbes(t) |
| 489 | prev := geoipVerifyTimeout |
| 490 | geoipVerifyTimeout = 2 * time.Second |
| 491 | t.Cleanup(func() { geoipVerifyTimeout = prev }) |
| 492 | ctx := context.Background() |
| 493 | p, _ := newTestPublisher(t) |
| 494 | good, err := p.Publish(ctx, bytes.NewReader(fixture(t, "GeoIP2-City-Test.mmdb")), geoipPublishOpts{Origin: "manual"}) |
| 495 | if err != nil { |
| 496 | t.Fatal(err) |
| 497 | } |
| 498 | for _, f := range []string{"pathological-empty-dag.mmdb", "pathological-metadata.mmdb", "pathological-bigmap.mmdb"} { |
| 499 | start := time.Now() |
| 500 | if _, err := p.Publish(ctx, bytes.NewReader(fixture(t, f)), geoipPublishOpts{Origin: "manual", Force: true}); !errors.Is(err, errGeoipRefused) { |
| 501 | t.Fatalf("%s: err = %v", f, err) |
| 502 | } |
| 503 | if d := time.Since(start); d > 15*time.Second { |
| 504 | t.Fatalf("%s took %s", f, d) |
| 505 | } |
| 506 | if cur, _, _, _ := p.Current(ctx); cur.SHA256 != good.SHA256 { |
| 507 | t.Fatalf("%s replaced the pointer", f) |
| 508 | } |
| 509 | if !p.mu.TryLock() { |
| 510 | t.Fatalf("%s left the slot held", f) |
| 511 | } |
| 512 | p.mu.Unlock() |
| 513 | if entries, _ := os.ReadDir(p.tmpRoot); len(entries) != 0 { |
| 514 | t.Fatalf("%s left staging: %v", f, entries) |
| 515 | } |
| 516 | } |
| 517 | } |
| 518 | |
| 519 | // junkRecordMMDB writes an IPv4 database whose lower half answers GB and |
| 520 | // whose upper half carries a 6M-element ignored array beside its country |
| 521 | // (code review R11-1): generic verification walks the array, but the |
| 522 | // production schema's skip recurses once per element. |
| 523 | func junkRecordMMDB(t *testing.T) string { |
| 524 | t.Helper() |
| 525 | var buf bytes.Buffer |
| 526 | str := func(s string) { buf.WriteByte(0x40 | byte(len(s))); buf.WriteString(s) } |
| 527 | u16 := func(v uint16) { buf.WriteByte(0xA2); buf.WriteByte(byte(v >> 8)); buf.WriteByte(byte(v)) } |
| 528 | // One node: left -> data at 0 (A), right -> data at offsetB (B). |
| 529 | recA := []byte{0xE1, 0x47, 'c', 'o', 'u', 'n', 't', 'r', 'y', 0xE1, 0x48, 'i', 's', 'o', '_', 'c', 'o', 'd', 'e', 0x42, 'G', 'B'} |
| 530 | offsetB := len(recA) |
| 531 | ptr := func(dataOff int) []byte { v := 1 + 16 + dataOff; return []byte{byte(v >> 16), byte(v >> 8), byte(v)} } |
| 532 | buf.Write(ptr(0)) |
| 533 | buf.Write(ptr(offsetB)) |
| 534 | buf.Write(make([]byte, 16)) |
| 535 | buf.Write(recA) |
| 536 | // B: {country: {iso_code: FR}, junk: [16,000,000 x false]} |
| 537 | buf.Write([]byte{0xE2, 0x47, 'c', 'o', 'u', 'n', 't', 'r', 'y', 0xE1, 0x48, 'i', 's', 'o', '_', 'c', 'o', 'd', 'e', 0x42, 'F', 'R', 0x44, 'j', 'u', 'n', 'k'}) |
| 538 | const n = 6_000_000 |
| 539 | v := n - 65821 |
| 540 | buf.Write([]byte{0x1F, 0x04, byte(v >> 16), byte(v >> 8), byte(v)}) // array, size 31 form |
| 541 | falseBool := []byte{0x00, 0x07} |
| 542 | for i := 0; i < n; i++ { |
| 543 | buf.Write(falseBool) |
| 544 | } |
| 545 | buf.WriteString("\xAB\xCD\xEFMaxMind.com") |
| 546 | buf.WriteByte(0xE0 | 9) |
| 547 | str("node_count") |
| 548 | buf.Write([]byte{0xC1, 1}) |
| 549 | str("record_size") |
| 550 | u16(24) |
| 551 | str("ip_version") |
| 552 | u16(4) |
| 553 | str("database_type") |
| 554 | str("Junk-Record") |
| 555 | str("languages") |
| 556 | buf.Write([]byte{0x01, 0x04}) |
| 557 | str("en") |
| 558 | str("binary_format_major_version") |
| 559 | u16(2) |
| 560 | str("binary_format_minor_version") |
| 561 | u16(0) |
| 562 | str("build_epoch") |
| 563 | buf.Write([]byte{0x04, 0x02, 0x6a, 0xbc, 0x00, 0x00}) |
| 564 | str("description") |
| 565 | buf.WriteByte(0xE1) |
| 566 | str("en") |
| 567 | str("junk") |
| 568 | p := filepath.Join(t.TempDir(), "junk.mmdb") |
| 569 | if err := os.WriteFile(p, buf.Bytes(), 0o644); err != nil { |
| 570 | t.Fatal(err) |
| 571 | } |
| 572 | return p |
| 573 | } |
| 574 | |
| 575 | // Code review R11-1: a record that would overflow a serving lookup's stack |
| 576 | // is refused by the verifier child, even when every probe address is fine. |
| 577 | func TestPublishRefusesARecordThatWouldCrashALookup(t *testing.T) { |
| 578 | prevProbes := geoipProbeAddrs |
| 579 | geoipProbeAddrs = []string{"1.1.1.1", "8.8.8.8", "9.9.9.9"} // all in the clean half |
| 580 | t.Cleanup(func() { geoipProbeAddrs = prevProbes }) |
| 581 | prev := geoipVerifyTimeout |
| 582 | geoipVerifyTimeout = 90 * time.Second |
| 583 | t.Cleanup(func() { geoipVerifyTimeout = prev }) |
| 584 | ctx := context.Background() |
| 585 | p, _ := newTestPublisher(t) |
| 586 | f, err := os.Open(junkRecordMMDB(t)) |
| 587 | if err != nil { |
| 588 | t.Fatal(err) |
| 589 | } |
| 590 | defer f.Close() |
| 591 | if _, err := p.Publish(ctx, f, geoipPublishOpts{Origin: "manual"}); !errors.Is(err, errGeoipRefused) { |
| 592 | t.Fatalf("a crash-on-lookup database = %v, want refused", err) |
| 593 | } |
| 594 | if _, _, present, _ := p.Current(ctx); present { |
| 595 | t.Fatal("it was published") |
| 596 | } |
| 597 | } |
| 598 | |