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") }