From 1998095808aad98fb3ec6e9a9ba55c170f126ec1 Mon Sep 17 00:00:00 2001 From: Alex Goodman Date: Thu, 29 Oct 2026 12:07:01 -0400 Subject: [PATCH] fix: hash each file once in the file digest cataloger (#5491) Symlinks resolve to their target, so the same coordinates could show up once per path that reaches them and get hashed again each time. The results map hid this but the work was repeated. Drop repeated coordinates before digesting. Signed-off-by: Alex Goodman --- syft/file/cataloger/filedigest/cataloger.go | 12 ++++++ .../cataloger/filedigest/cataloger_test.go | 40 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/syft/file/cataloger/filedigest/cataloger.go b/syft/file/cataloger/filedigest/cataloger.go index 3340cc5cb..bee56a9e1 100644 --- a/syft/file/cataloger/filedigest/cataloger.go +++ b/syft/file/cataloger/filedigest/cataloger.go @@ -50,6 +50,18 @@ func (i *Cataloger) Catalog(ctx context.Context, resolver file.Resolver, coordin } } + // symlinks resolve to the same file, so drop repeated coordinates to avoid hashing the same contents more than once + seen := make(map[file.Coordinates]struct{}, len(locations)) + unique := locations[:0] + for _, l := range locations { + if _, ok := seen[l.Coordinates]; ok { + continue + } + seen[l.Coordinates] = struct{}{} + unique = append(unique, l) + } + locations = unique + prog := catalogingProgress(int64(len(locations))) err := sync.Collect(&ctx, cataloging.ExecutorFile, sync.ToSeq(locations), func(location file.Location) ([]file.Digest, error) { diff --git a/syft/file/cataloger/filedigest/cataloger_test.go b/syft/file/cataloger/filedigest/cataloger_test.go index 2c63d3bdb..b839b50cc 100644 --- a/syft/file/cataloger/filedigest/cataloger_test.go +++ b/syft/file/cataloger/filedigest/cataloger_test.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "sync" "testing" "github.com/stretchr/testify/assert" @@ -207,6 +208,45 @@ func TestFileDigestCataloger_GivenCoordinates(t *testing.T) { } +// countingResolver records how many times each coordinate's contents are opened +type countingResolver struct { + file.Resolver + lock sync.Mutex + counts map[file.Coordinates]int +} + +func (r *countingResolver) FileContentsByLocation(l file.Location) (io.ReadCloser, error) { + r.lock.Lock() + r.counts[l.Coordinates]++ + r.lock.Unlock() + return r.Resolver.FileContentsByLocation(l) +} + +func TestDigestsCataloger_hashesEachCoordinateOnce(t *testing.T) { + testImage := "image-file-type-mix" + + testutils.SkipWithoutLinuxContainers(t) + img := imagetest.GetFixtureImage(t, "docker-archive", testImage) + + src := stereoscopesource.New(img, stereoscopesource.ImageConfig{ + Reference: testImage, + }) + + resolver, err := src.FileResolver(source.SquashedScope) + require.NoError(t, err) + + // /symlink-1 and /file-1.txt both resolve to the same coordinates + counter := &countingResolver{Resolver: resolver, counts: make(map[file.Coordinates]int)} + + actual, err := NewCataloger([]crypto.Hash{crypto.MD5}).Catalog(context.Background(), counter) + require.NoError(t, err) + require.NotEmpty(t, actual) + + for c, n := range counter.counts { + assert.Equal(t, 1, n, "contents of %+v opened %d times", c, n) + } +} + type panicReader struct{} func (panicReader) Read([]byte) (int, error) { panic("boom") }