From 49f180abd80f2c8ae642f85bdf442df44caaf229 Mon Sep 17 00:00:00 2001 From: Sefi Tufan Date: Fri, 27 Feb 2026 14:21:27 +0000 Subject: [PATCH] feat: share BPF inner maps across containers with same image digest Introduce digest-based reference counting for inner BPF maps in the IMA operator. Containers running the same image now share a single inner map instead of each getting a separate copy, reducing memory usage. Key changes: - Add digestTracker with ref-counted entries keyed by image digest - Add ResolveDigest() to resolve image refs via lightweight HEAD request - Replace sync.Map with LRU cache (bounded to 128 entries) in SBOM Fetcher - Add FetchForDigest() to skip redundant HEAD when digest is pre-resolved - Fix container lifecycle event: REMOVED -> DELETED to match actual events - Add unit tests for digestTracker ref counting and LRU cache bounds Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- go.mod | 10 +- internal/operators/operators.go | 172 ++++++++++++++++++++++----- internal/operators/operators_test.go | 68 +++++++++++ internal/sbom/sbom.go | 101 ++++++++++++---- internal/sbom/sbom_test.go | 28 ++++- 5 files changed, 313 insertions(+), 66 deletions(-) create mode 100644 internal/operators/operators_test.go diff --git a/go.mod b/go.mod index 7fad951..d180101 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,15 @@ module github.com/micromize-dev/micromize go 1.25.5 require ( + github.com/cilium/ebpf v0.20.0 + github.com/docker/cli v29.2.0+incompatible + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 github.com/inspektor-gadget/inspektor-gadget v0.49.1 + github.com/opencontainers/image-spec v1.1.1 github.com/quay/claircore v1.5.45 github.com/sirupsen/logrus v1.9.4 github.com/spf13/cobra v1.10.2 + golang.org/x/sync v0.19.0 oras.land/oras-go/v2 v2.6.0 ) @@ -21,7 +26,6 @@ require ( github.com/blang/semver v3.5.1+incompatible // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cilium/ebpf v0.20.0 // indirect github.com/cloudflare/cbpfc v0.0.0-20240920015331-ff978e94500b // indirect github.com/containerd/cgroups/v3 v3.0.5 // indirect github.com/containerd/containerd v1.7.30 // indirect @@ -38,7 +42,6 @@ require ( github.com/cyphar/filepath-securejoin v0.5.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/cli v29.2.0+incompatible // indirect github.com/docker/docker v28.5.2+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/docker/go-connections v0.6.0 // indirect @@ -62,7 +65,6 @@ require ( github.com/gofrs/flock v0.13.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect - github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect @@ -98,7 +100,6 @@ require ( github.com/notaryproject/notation-plugin-framework-go v1.0.0 // indirect github.com/notaryproject/tspclient-go v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/runtime-spec v1.2.1 // indirect github.com/opencontainers/selinux v1.13.1 // indirect github.com/packetcap/go-pcap v0.0.0-20250723190045-d00b185f30b7 // indirect @@ -139,7 +140,6 @@ require ( golang.org/x/mod v0.31.0 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/oauth2 v0.33.0 // indirect - golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/term v0.39.0 // indirect golang.org/x/text v0.33.0 // indirect diff --git a/internal/operators/operators.go b/internal/operators/operators.go index 6c58c95..307b3e9 100644 --- a/internal/operators/operators.go +++ b/internal/operators/operators.go @@ -70,7 +70,7 @@ func NewImaOperator() igoperators.DataOperator { slog.Debug("Creating IMA operator") opPriority := math.MaxInt sbomFetcher := sbom.NewFetcher() - innerMaps := &sync.Map{} // mntns_id -> *ebpf.Map (for cleanup) + digestState := newDigestState() operatorOptions := []simple.Option{ simple.WithPriority(opPriority), @@ -103,9 +103,9 @@ func NewImaOperator() igoperators.DataOperator { } switch eventType { case "CREATED": - handleContainerCreated(ctx, gadgetCtx, sbomFetcher, innerMaps, containerConfigField, containerIDField, mntnsIDField, data) - case "REMOVED": - handleContainerRemoved(gadgetCtx, innerMaps, mntnsIDField, data) + handleContainerCreated(ctx, gadgetCtx, sbomFetcher, digestState, containerConfigField, containerIDField, mntnsIDField, data) + case "DELETED": + handleContainerRemoved(gadgetCtx, digestState, mntnsIDField, data) } return nil }, opPriority); err != nil { @@ -117,7 +117,28 @@ func NewImaOperator() igoperators.DataOperator { return simple.New("imaOperator", operatorOptions...) } -func handleContainerCreated(ctx context.Context, gadgetCtx igoperators.GadgetContext, fetcher *sbom.Fetcher, innerMaps *sync.Map, configField datasource.FieldAccessor, containerIDField datasource.FieldAccessor, mntnsIDField datasource.FieldAccessor, data datasource.Data) { +// digestMapEntry tracks a shared inner BPF map for a given image digest. +type digestMapEntry struct { + innerMap *ebpf.Map + refCount int +} + +// digestTracker manages the mapping between containers (mntns_id) and their +// image digests, with reference counting for shared inner BPF maps. +type digestTracker struct { + mu sync.Mutex + digestEntries map[string]*digestMapEntry // digest string → entry + containerDigests map[uint64]string // mntns_id → digest string +} + +func newDigestState() *digestTracker { + return &digestTracker{ + digestEntries: make(map[string]*digestMapEntry), + containerDigests: make(map[uint64]string), + } +} + +func handleContainerCreated(ctx context.Context, gadgetCtx igoperators.GadgetContext, fetcher *sbom.Fetcher, dt *digestTracker, configField datasource.FieldAccessor, containerIDField datasource.FieldAccessor, mntnsIDField datasource.FieldAccessor, data datasource.Data) { ociConfig, err := configField.String(data) if err != nil { slog.Debug("Failed to read container_config field", "error", err) @@ -139,29 +160,74 @@ func handleContainerCreated(ctx context.Context, gadgetCtx igoperators.GadgetCon imageRef = sbom.NormalizeImageRef(imageRef) + if mntnsIDField == nil { + slog.Debug("mntns_id field not available, cannot populate BPF maps") + return + } + + mntnsID, err := mntnsIDField.Uint64(data) + if err != nil { + slog.Error("Failed to read mntns_id field", "error", err) + return + } + + // Resolve image digest (lightweight HEAD request). fetchCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - sbomData, err := fetcher.FetchForImage(fetchCtx, imageRef) + digest, err := sbom.ResolveDigest(fetchCtx, imageRef) + if err != nil { + slog.Error("Failed to resolve image digest", "image", imageRef, "error", err) + return + } + + // Check if we already have an inner map for this digest (another + // container with the same image is already running). + dt.mu.Lock() + if entry, ok := dt.digestEntries[digest]; ok { + entry.refCount++ + dt.containerDigests[mntnsID] = digest + innerMap := entry.innerMap + dt.mu.Unlock() + + // Reuse the same inner map FD in expected_hashes for this mntns_id. + insertSharedInnerMap(gadgetCtx, mntnsID, innerMap) + slog.Debug("Reusing existing inner map", "image", imageRef, "digest", digest, "mntns_id", mntnsID, "refCount", entry.refCount) + return + } + dt.mu.Unlock() + + // First container with this digest — fetch and parse the SBOM. + // Use FetchForDigest to avoid a redundant HEAD request. + files, err := fetcher.FetchForDigest(fetchCtx, imageRef, digest) if err != nil { slog.Error("Failed to fetch SBOM", "error", err) return } - if sbomData != nil { - slog.Debug("SBOM fetched for container image", "image", imageRef, "size", len(sbomData)) - files, err := sbom.ParseFiles(sbomData) - if err != nil { - slog.Error("Failed to parse SBOM files", "error", err) - return - } + if len(files) > 0 { + slog.Debug("SBOM fetched for container image", "image", imageRef, "digest", digest, "files", len(files)) for _, f := range files { slog.Debug("SBOM binary file", "image", imageRef, "file", f.FileName, "sha256", f.SHA256) } - if mntnsIDField != nil && len(files) > 0 { - populateExpectedHashes(gadgetCtx, innerMaps, mntnsIDField, data, files) + // Re-check under lock: another goroutine may have created the + // entry while we were fetching. + dt.mu.Lock() + if entry, ok := dt.digestEntries[digest]; ok { + entry.refCount++ + dt.containerDigests[mntnsID] = digest + innerMap := entry.innerMap + dt.mu.Unlock() + + insertSharedInnerMap(gadgetCtx, mntnsID, innerMap) + slog.Debug("Reusing inner map (created during fetch)", "image", imageRef, "digest", digest, "mntns_id", mntnsID, "refCount", entry.refCount) + return } + // Hold the lock through populateExpectedHashes to prevent + // two goroutines from both creating inner maps for the same digest. + populateExpectedHashes(gadgetCtx, dt, mntnsID, digest, files) + dt.mu.Unlock() } } @@ -173,7 +239,7 @@ const ( maxFilepathLen = 64 ) -func populateExpectedHashes(gadgetCtx igoperators.GadgetContext, innerMaps *sync.Map, mntnsIDField datasource.FieldAccessor, data datasource.Data, files []sbom.FileInfo) { +func populateExpectedHashes(gadgetCtx igoperators.GadgetContext, dt *digestTracker, mntnsID uint64, digest string, files []sbom.FileInfo) { outerMapVar, ok := gadgetCtx.GetVar(expectedHashesMapName) if !ok { slog.Debug("expected_hashes map not available in gadget context, skipping map population") @@ -186,13 +252,7 @@ func populateExpectedHashes(gadgetCtx igoperators.GadgetContext, innerMaps *sync return } - mntnsID, err := mntnsIDField.Uint64(data) - if err != nil { - slog.Error("Failed to read mntns_id field", "error", err) - return - } - - // Create a new inner map for this mount namespace + // Create a new inner map for this digest innerMapSpec := &ebpf.MapSpec{ Type: ebpf.Hash, KeySize: uint32(maxFilepathLen), @@ -241,20 +301,41 @@ func populateExpectedHashes(gadgetCtx igoperators.GadgetContext, innerMaps *sync } } - // Insert the inner map into the outer map keyed by mntns_id + // Insert the inner map into expected_hashes keyed by mntns_id if err := outerMap.Put(mntnsID, uint32(innerMap.FD())); err != nil { slog.Error("Failed to insert inner map into expected_hashes", "mntns_id", mntnsID, "error", err) innerMap.Close() return } - // Track the inner map for cleanup on container removal - innerMaps.Store(mntnsID, innerMap) + // Track the inner map by digest for sharing with future containers. + // Caller holds dt.mu. + dt.digestEntries[digest] = &digestMapEntry{innerMap: innerMap, refCount: 1} + dt.containerDigests[mntnsID] = digest - slog.Debug("Populated expected_hashes map", "mntns_id", mntnsID, "entries", len(files)) + slog.Debug("Populated expected_hashes map", "digest", digest, "mntns_id", mntnsID, "entries", len(files)) } -func handleContainerRemoved(gadgetCtx igoperators.GadgetContext, innerMaps *sync.Map, mntnsIDField datasource.FieldAccessor, data datasource.Data) { +// insertSharedInnerMap inserts an existing inner map into expected_hashes +// for a new container that shares the same image digest. +func insertSharedInnerMap(gadgetCtx igoperators.GadgetContext, mntnsID uint64, innerMap *ebpf.Map) { + outerMapVar, ok := gadgetCtx.GetVar(expectedHashesMapName) + if !ok { + slog.Debug("expected_hashes map not available in gadget context") + return + } + outerMap, ok := outerMapVar.(*ebpf.Map) + if !ok || outerMap == nil { + slog.Debug("expected_hashes map is not a valid *ebpf.Map") + return + } + + if err := outerMap.Put(mntnsID, uint32(innerMap.FD())); err != nil { + slog.Error("Failed to insert shared inner map into expected_hashes", "mntns_id", mntnsID, "error", err) + } +} + +func handleContainerRemoved(gadgetCtx igoperators.GadgetContext, dt *digestTracker, mntnsIDField datasource.FieldAccessor, data datasource.Data) { if mntnsIDField == nil { slog.Debug("mntns_id field not available, cannot clean up expected_hashes for removed container") return @@ -266,6 +347,7 @@ func handleContainerRemoved(gadgetCtx igoperators.GadgetContext, innerMaps *sync return } + // Remove mntns_id from expected_hashes BPF map outerMapVar, ok := gadgetCtx.GetVar(expectedHashesMapName) if !ok { return @@ -279,13 +361,37 @@ func handleContainerRemoved(gadgetCtx igoperators.GadgetContext, innerMaps *sync slog.Debug("Failed to delete entry from expected_hashes", "mntns_id", mntnsID, "error", err) } - if val, loaded := innerMaps.LoadAndDelete(mntnsID); loaded { - if m, ok := val.(*ebpf.Map); ok && m != nil { - m.Close() - } + // Decrement ref count; close inner map when last container is removed + dt.mu.Lock() + digest, ok := dt.containerDigests[mntnsID] + if !ok { + dt.mu.Unlock() + return + } + delete(dt.containerDigests, mntnsID) + + entry, exists := dt.digestEntries[digest] + if !exists { + dt.mu.Unlock() + return + } + + entry.refCount-- + if entry.refCount > 0 { + dt.mu.Unlock() + slog.Debug("Decremented digest refCount", "digest", digest, "mntns_id", mntnsID, "refCount", entry.refCount) + return + } + + // Last container using this digest — clean up the inner map. + delete(dt.digestEntries, digest) + dt.mu.Unlock() + + if entry.innerMap != nil { + entry.innerMap.Close() } - slog.Info("Cleaned up expected_hashes for removed container", "mntns_id", mntnsID) + slog.Info("Cleaned up shared inner map for last container", "digest", digest, "mntns_id", mntnsID) } // Event type constants matching include/micromize/event_types.h diff --git a/internal/operators/operators_test.go b/internal/operators/operators_test.go new file mode 100644 index 0000000..60fe3a1 --- /dev/null +++ b/internal/operators/operators_test.go @@ -0,0 +1,68 @@ +// Copyright The micromize authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package operators + +import ( + "testing" +) + +func TestDigestTracker_RefCounting(t *testing.T) { + dt := newDigestState() + digest := "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + + // Simulate first container + dt.mu.Lock() + dt.digestEntries[digest] = &digestMapEntry{innerMap: nil, refCount: 1} + dt.containerDigests[1000] = digest + dt.mu.Unlock() + + // Simulate second container with same digest + dt.mu.Lock() + entry := dt.digestEntries[digest] + entry.refCount++ + dt.containerDigests[2000] = digest + dt.mu.Unlock() + + if entry.refCount != 2 { + t.Fatalf("expected refCount 2, got %d", entry.refCount) + } + + // Remove first container + dt.mu.Lock() + d, ok := dt.containerDigests[1000] + if !ok || d != digest { + t.Fatal("container 1000 not found in containerDigests") + } + delete(dt.containerDigests, 1000) + dt.digestEntries[digest].refCount-- + dt.mu.Unlock() + + if dt.digestEntries[digest].refCount != 1 { + t.Fatalf("expected refCount 1, got %d", dt.digestEntries[digest].refCount) + } + + // Remove second container + dt.mu.Lock() + delete(dt.containerDigests, 2000) + dt.digestEntries[digest].refCount-- + if dt.digestEntries[digest].refCount == 0 { + delete(dt.digestEntries, digest) + } + dt.mu.Unlock() + + if _, exists := dt.digestEntries[digest]; exists { + t.Fatal("expected digest entry to be removed after last container") + } +} diff --git a/internal/sbom/sbom.go b/internal/sbom/sbom.go index b884402..dd4216b 100644 --- a/internal/sbom/sbom.go +++ b/internal/sbom/sbom.go @@ -30,6 +30,7 @@ import ( dockerconfig "github.com/docker/cli/cli/config" "github.com/docker/cli/cli/config/configfile" + "github.com/golang/groupcache/lru" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "golang.org/x/sync/singleflight" "oras.land/oras-go/v2/errdef" @@ -43,53 +44,100 @@ var imageAnnotationKeys = []string{ "io.containerd.image.name", } -type result struct { +const defaultMaxCacheEntries = 128 + +type cacheEntry struct { + Files []FileInfo HasSBOM bool - SBOM []byte } type Fetcher struct { - cache sync.Map + mu sync.Mutex + cache *lru.Cache // keyed by digest string, value is *cacheEntry group singleflight.Group } func NewFetcher() *Fetcher { - return &Fetcher{} + return &Fetcher{ + cache: lru.New(defaultMaxCacheEntries), + } +} + +// ResolveDigest resolves an image reference to its digest via a lightweight +// registry HEAD request. Returns a digest string like "sha256:abc123...". +func ResolveDigest(ctx context.Context, imageRef string) (string, error) { + repo, err := newAuthenticatedRepo(imageRef) + if err != nil { + return "", fmt.Errorf("creating repository client: %w", err) + } + + desc, err := repo.Resolve(ctx, repo.Reference.Reference) + if err != nil { + return "", fmt.Errorf("resolving image %s: %w", imageRef, err) + } + + return desc.Digest.String(), nil +} + +// FetchForImage fetches and parses the SPDX SBOM for the given image reference. +// Results are cached by image digest using an LRU cache. +func (f *Fetcher) FetchForImage(ctx context.Context, imageRef string) ([]FileInfo, error) { + if imageRef == "" { + return nil, nil + } + + digest, err := ResolveDigest(ctx, imageRef) + if err != nil { + return nil, err + } + + return f.FetchForDigest(ctx, imageRef, digest) } -// FetchForImage fetches the SPDX SBOM for the given image reference. -// Repeated calls for the same image are served from cache. -func (f *Fetcher) FetchForImage(ctx context.Context, imageRef string) ([]byte, error) { +// FetchForDigest fetches and parses the SPDX SBOM using a pre-resolved digest. +// Use this when the digest has already been resolved to avoid a redundant HEAD request. +func (f *Fetcher) FetchForDigest(ctx context.Context, imageRef string, digest string) ([]FileInfo, error) { if imageRef == "" { return nil, nil } - if cached, ok := f.cache.Load(imageRef); ok { - r := cached.(*result) - if !r.HasSBOM { - slog.Debug("Image previously checked, no SBOM available", "image", imageRef) + f.mu.Lock() + if cached, ok := f.cache.Get(digest); ok { + entry := cached.(*cacheEntry) + f.mu.Unlock() + if !entry.HasSBOM { + slog.Debug("Image previously checked, no SBOM available", "image", imageRef, "digest", digest) return nil, nil } - slog.Debug("Returning cached SBOM", "image", imageRef) - return r.SBOM, nil + slog.Debug("Returning cached SBOM", "image", imageRef, "digest", digest) + return entry.Files, nil } + f.mu.Unlock() - v, err, _ := f.group.Do(imageRef, func() (any, error) { - sbomBytes, err := fetchSPDXSBOM(ctx, imageRef) + v, err, _ := f.group.Do(digest, func() (any, error) { + sbomBytes, err := fetchSBOMByDigest(ctx, imageRef, digest) if err != nil { - // Don't cache errors — they may be transient (auth, network). return nil, fmt.Errorf("fetching SBOM for image %s: %w", imageRef, err) } if sbomBytes == nil { - slog.Debug("No SPDX SBOM attached to image", "image", imageRef) - f.cache.Store(imageRef, &result{HasSBOM: false}) + slog.Debug("No SPDX SBOM attached to image", "image", imageRef, "digest", digest) + f.mu.Lock() + f.cache.Add(digest, &cacheEntry{HasSBOM: false}) + f.mu.Unlock() return nil, nil } - slog.Debug("Fetched SPDX SBOM for image", "image", imageRef, "size", len(sbomBytes)) - f.cache.Store(imageRef, &result{HasSBOM: true, SBOM: sbomBytes}) - return sbomBytes, nil + files, err := ParseFiles(sbomBytes) + if err != nil { + return nil, fmt.Errorf("parsing SBOM for image %s: %w", imageRef, err) + } + + slog.Debug("Fetched and parsed SPDX SBOM for image", "image", imageRef, "digest", digest, "files", len(files)) + f.mu.Lock() + f.cache.Add(digest, &cacheEntry{HasSBOM: true, Files: files}) + f.mu.Unlock() + return files, nil }) if err != nil { return nil, err @@ -97,7 +145,7 @@ func (f *Fetcher) FetchForImage(ctx context.Context, imageRef string) ([]byte, e if v == nil { return nil, nil } - return v.([]byte), nil + return v.([]FileInfo), nil } // ImageRefFromOCIConfig parses the OCI runtime spec config JSON and extracts @@ -291,15 +339,18 @@ func isUnderBinOrLib(name string) bool { return false } -func fetchSPDXSBOM(ctx context.Context, imageRef string) ([]byte, error) { +// fetchSBOMByDigest fetches the SPDX SBOM for an image using its digest +// to locate the cosign attestation tag. +func fetchSBOMByDigest(ctx context.Context, imageRef string, digest string) ([]byte, error) { repo, err := newAuthenticatedRepo(imageRef) if err != nil { return nil, fmt.Errorf("creating repository client: %w", err) } - desc, err := repo.Resolve(ctx, repo.Reference.Reference) + // Resolve by digest to get the full descriptor for cosign attestation lookup. + desc, err := repo.Resolve(ctx, digest) if err != nil { - return nil, fmt.Errorf("resolving image %s: %w", imageRef, err) + return nil, fmt.Errorf("resolving image %s by digest: %w", imageRef, err) } return fetchCosignAttestation(ctx, repo, desc) diff --git a/internal/sbom/sbom_test.go b/internal/sbom/sbom_test.go index 1ac6267..dc814ca 100644 --- a/internal/sbom/sbom_test.go +++ b/internal/sbom/sbom_test.go @@ -85,12 +85,12 @@ func TestImageRefFromOCIConfig(t *testing.T) { func TestFetchForImage_EmptyRef(t *testing.T) { f := NewFetcher() - data, err := f.FetchForImage(t.Context(), "") + files, err := f.FetchForImage(t.Context(), "") if err != nil { t.Fatalf("unexpected error: %v", err) } - if data != nil { - t.Errorf("expected nil for empty ref, got %d bytes", len(data)) + if files != nil { + t.Errorf("expected nil for empty ref, got %d files", len(files)) } } @@ -288,3 +288,25 @@ func TestParseFiles_RejectsRelativePaths(t *testing.T) { }) } } + +func TestLRUCacheBounded(t *testing.T) { + f := NewFetcher() + + // The cache should accept entries and evict old ones. + // We test the internal cache directly since FetchForImage requires + // network access. + for i := 0; i < defaultMaxCacheEntries+10; i++ { + digest := "sha256:" + "a" + string(rune('0'+i%10)) + "b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b85" + string(rune('0'+i/10)) + f.mu.Lock() + f.cache.Add(digest, &cacheEntry{HasSBOM: false}) + f.mu.Unlock() + } + + f.mu.Lock() + cacheLen := f.cache.Len() + f.mu.Unlock() + + if cacheLen > defaultMaxCacheEntries { + t.Errorf("cache size %d exceeds max %d", cacheLen, defaultMaxCacheEntries) + } +}