package gc import ( "context" "errors" "sync" "testing" ) // fakeDB implements gc.DB for tests. type fakeDB struct { mu sync.Mutex unreferenced []UnreferencedBlob blobsExist map[string]bool } func newFakeDB() *fakeDB { return &fakeDB{ blobsExist: make(map[string]bool), } } func (f *fakeDB) FindAndDeleteUnreferencedBlobs() ([]UnreferencedBlob, error) { f.mu.Lock() defer f.mu.Unlock() result := make([]UnreferencedBlob, len(f.unreferenced)) copy(result, f.unreferenced) // Simulate deletion by removing from blobsExist. for _, b := range f.unreferenced { delete(f.blobsExist, b.Digest) } f.unreferenced = nil return result, nil } func (f *fakeDB) BlobExistsByDigest(digest string) (bool, error) { f.mu.Lock() defer f.mu.Unlock() return f.blobsExist[digest], nil } // fakeStorage implements gc.Storage for tests. type fakeStorage struct { mu sync.Mutex blobs map[string]int64 // digest -> size deleted []string } func newFakeStorage() *fakeStorage { return &fakeStorage{ blobs: make(map[string]int64), } } func (f *fakeStorage) Delete(digest string) error { f.mu.Lock() defer f.mu.Unlock() if _, ok := f.blobs[digest]; !ok { return errors.New("not found") } delete(f.blobs, digest) f.deleted = append(f.deleted, digest) return nil } func (f *fakeStorage) ListBlobDigests() ([]string, error) { f.mu.Lock() defer f.mu.Unlock() var digests []string for d := range f.blobs { digests = append(digests, d) } return digests, nil } func TestGCRemovesUnreferencedBlobs(t *testing.T) { db := newFakeDB() db.unreferenced = []UnreferencedBlob{ {Digest: "sha256:dead1", Size: 100}, {Digest: "sha256:dead2", Size: 200}, } db.blobsExist["sha256:dead1"] = true db.blobsExist["sha256:dead2"] = true db.blobsExist["sha256:alive"] = true // referenced, not in unreferenced list store := newFakeStorage() store.blobs["sha256:dead1"] = 100 store.blobs["sha256:dead2"] = 200 store.blobs["sha256:alive"] = 300 c := New(db, store) result, err := c.Run(context.Background()) if err != nil { t.Fatalf("Run: %v", err) } if result.BlobsRemoved != 2 { t.Fatalf("BlobsRemoved: got %d, want 2", result.BlobsRemoved) } if result.BytesFreed != 300 { t.Fatalf("BytesFreed: got %d, want 300", result.BytesFreed) } // Dead blobs should be deleted from storage. if _, ok := store.blobs["sha256:dead1"]; ok { t.Fatal("sha256:dead1 should have been deleted from storage") } if _, ok := store.blobs["sha256:dead2"]; ok { t.Fatal("sha256:dead2 should have been deleted from storage") } // Alive blob should still exist. if _, ok := store.blobs["sha256:alive"]; !ok { t.Fatal("sha256:alive should still exist in storage") } } func TestGCDoesNotRemoveReferencedBlobs(t *testing.T) { db := newFakeDB() // No unreferenced blobs. db.blobsExist["sha256:alive"] = true store := newFakeStorage() store.blobs["sha256:alive"] = 500 c := New(db, store) result, err := c.Run(context.Background()) if err != nil { t.Fatalf("Run: %v", err) } if result.BlobsRemoved != 0 { t.Fatalf("BlobsRemoved: got %d, want 0", result.BlobsRemoved) } if _, ok := store.blobs["sha256:alive"]; !ok { t.Fatal("referenced blob should not be deleted") } } func TestGCConcurrentRejected(t *testing.T) { db := newFakeDB() store := newFakeStorage() c := New(db, store) // Acquire the lock manually. c.mu.Lock() // Try to run GC — should fail. _, err := c.Run(context.Background()) if !errors.Is(err, ErrGCRunning) { t.Fatalf("expected ErrGCRunning, got %v", err) } c.mu.Unlock() // Now it should work. result, err := c.Run(context.Background()) if err != nil { t.Fatalf("Run after unlock: %v", err) } if result.BlobsRemoved != 0 { t.Fatalf("BlobsRemoved: got %d, want 0", result.BlobsRemoved) } } func TestGCEmptyRegistry(t *testing.T) { db := newFakeDB() store := newFakeStorage() c := New(db, store) result, err := c.Run(context.Background()) if err != nil { t.Fatalf("Run: %v", err) } if result.BlobsRemoved != 0 { t.Fatalf("BlobsRemoved: got %d, want 0", result.BlobsRemoved) } if result.Duration <= 0 { t.Fatal("Duration should be positive") } } func TestReconcileCleansOrphanedFiles(t *testing.T) { db := newFakeDB() // Only sha256:alive has a DB row. db.blobsExist["sha256:alive"] = true store := newFakeStorage() store.blobs["sha256:alive"] = 100 store.blobs["sha256:orphan1"] = 200 store.blobs["sha256:orphan2"] = 300 c := New(db, store) result, err := c.Reconcile(context.Background()) if err != nil { t.Fatalf("Reconcile: %v", err) } if result.BlobsRemoved != 2 { t.Fatalf("BlobsRemoved: got %d, want 2", result.BlobsRemoved) } // Alive blob should still exist. if _, ok := store.blobs["sha256:alive"]; !ok { t.Fatal("sha256:alive should still exist") } // Orphans should be gone. if _, ok := store.blobs["sha256:orphan1"]; ok { t.Fatal("sha256:orphan1 should have been deleted") } if _, ok := store.blobs["sha256:orphan2"]; ok { t.Fatal("sha256:orphan2 should have been deleted") } } func TestReconcileEmptyStorage(t *testing.T) { db := newFakeDB() store := newFakeStorage() c := New(db, store) result, err := c.Reconcile(context.Background()) if err != nil { t.Fatalf("Reconcile: %v", err) } if result.BlobsRemoved != 0 { t.Fatalf("BlobsRemoved: got %d, want 0", result.BlobsRemoved) } }