diff --git a/cmd/syft/internal/commands/scan.go b/cmd/syft/internal/commands/scan.go index 7f92491ca..4572f6558 100644 --- a/cmd/syft/internal/commands/scan.go +++ b/cmd/syft/internal/commands/scan.go @@ -260,6 +260,11 @@ func getSource(ctx context.Context, opts *options.Catalog, userInput string, sou cfg = cfg.WithDigestAlgorithms(hashers...) } + if source.ParseScope(opts.Scope) != source.SquashedScope { + // every other scope reads each layer's squash, which is cheaper to build while the image is read + ctx = internal.WithEagerLayerSquashes(ctx) + } + src, err := syft.GetSource(ctx, userInput, cfg) if err != nil { return nil, fmt.Errorf("could not determine source: %w", err) diff --git a/internal/layer_squashes.go b/internal/layer_squashes.go new file mode 100644 index 000000000..ad3b24f2a --- /dev/null +++ b/internal/layer_squashes.go @@ -0,0 +1,17 @@ +package internal + +import "context" + +type eagerLayerSquashesKey struct{} + +// WithEagerLayerSquashes has the image source provider build every layer's squashed tree while it reads the +// image, instead of the first time each one is used. Set it when the scan will read lower layers (any scope but +// squashed): eager squashing overlaps with fetching and indexing the layers, so those scans finish sooner. +func WithEagerLayerSquashes(ctx context.Context) context.Context { + return context.WithValue(ctx, eagerLayerSquashesKey{}, true) +} + +func EagerLayerSquashes(ctx context.Context) bool { + v, _ := ctx.Value(eagerLayerSquashesKey{}).(bool) + return v +} diff --git a/syft/source/stereoscopesource/image_source_provider.go b/syft/source/stereoscopesource/image_source_provider.go index 1b1f78e4e..5d89124cb 100644 --- a/syft/source/stereoscopesource/image_source_provider.go +++ b/syft/source/stereoscopesource/image_source_provider.go @@ -6,6 +6,7 @@ import ( "github.com/anchore/go-collections" "github.com/anchore/stereoscope" "github.com/anchore/stereoscope/pkg/image" + "github.com/anchore/syft/internal" "github.com/anchore/syft/syft/source" ) @@ -29,6 +30,10 @@ func (l stereoscopeImageSourceProvider) Name() string { } func (l stereoscopeImageSourceProvider) Provide(ctx context.Context) (source.Source, error) { + if !internal.EagerLayerSquashes(ctx) { + // lower layer squashes are only built if something reads them, so a squashed scan never pays for them + ctx = image.WithLazyLayerSquashes(ctx) + } img, err := l.stereoscopeProvider.Provide(ctx) if err != nil { return nil, err diff --git a/syft/source/stereoscopesource/image_source_provider_test.go b/syft/source/stereoscopesource/image_source_provider_test.go new file mode 100644 index 000000000..90fb8d519 --- /dev/null +++ b/syft/source/stereoscopesource/image_source_provider_test.go @@ -0,0 +1,76 @@ +package stereoscopesource + +import ( + "context" + "path/filepath" + "testing" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/random" + "github.com/google/go-containerregistry/pkg/v1/tarball" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anchore/stereoscope" + "github.com/anchore/stereoscope/pkg/filetree" + "github.com/anchore/stereoscope/pkg/image" + "github.com/anchore/syft/internal" + "github.com/anchore/syft/syft/source" +) + +func Test_stereoscopeImageSourceProvider_layerSquashes(t *testing.T) { + img, err := random.Image(1024, 3) + require.NoError(t, err) + tag, err := name.NewTag("localhost/layer-squashes:latest") + require.NoError(t, err) + archive := filepath.Join(t.TempDir(), "image.tar") + require.NoError(t, tarball.WriteToFile(archive, tag, img)) + + provide := func(t *testing.T, ctx context.Context) *stereoscopeImageSource { + providers := Providers(ProviderConfig{ + StereoscopeImageProviderConfig: stereoscope.ImageProviderConfig{UserInput: archive}, + }) + for _, p := range providers { + if p.Value.Name() != image.DockerTarballSource { + continue + } + src, err := p.Value.Provide(ctx) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, src.Close()) }) + return src.(*stereoscopeImageSource) + } + t.Fatalf("no %s provider", image.DockerTarballSource) + return nil + } + + lazy := provide(t, context.Background()) + eager := provide(t, internal.WithEagerLayerSquashes(context.Background())) + + // lower layers are built on first use unless eager was asked for, the top is the image squash either way + require.Len(t, lazy.image.Layers, 3) + for idx := range lazy.image.Layers { + _, isTree := lazy.image.Layers[idx].SquashedTree.(*filetree.FileTree) + assert.Equalf(t, idx == 2, isTree, "lazy layer %d", idx) + assert.IsTypef(t, &filetree.FileTree{}, eager.image.Layers[idx].SquashedTree, "eager layer %d", idx) + } + + // every scope sees the same files either way + for _, scope := range []source.Scope{source.SquashedScope, source.AllLayersScope, source.DeepSquashedScope} { + t.Run(string(scope), func(t *testing.T) { + files := func(src *stereoscopeImageSource) []string { + res, err := src.FileResolver(scope) + require.NoError(t, err) + locs, err := res.FilesByGlob("**/*") + require.NoError(t, err) + var out []string + for _, l := range locs { + out = append(out, l.RealPath+"@"+l.FileSystemID) + } + return out + } + want := files(eager) + require.NotEmpty(t, want) + assert.ElementsMatch(t, want, files(lazy)) + }) + } +}