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/go.mod b/go.mod index cfb556be1..ee1ce5c1e 100644 --- a/go.mod +++ b/go.mod @@ -39,7 +39,6 @@ require ( github.com/dustin/go-humanize v1.1.0 github.com/elliotchance/phpserialize v1.4.0 github.com/facebookincubator/nvdtools v0.1.5 - github.com/github/go-spdx/v2 v2.8.0 github.com/gkampitakis/go-snaps v0.5.24 github.com/go-git/go-billy/v5 v5.9.2 github.com/go-git/go-git/v5 v5.19.3 @@ -69,6 +68,7 @@ require ( github.com/nix-community/go-nix v0.0.0-20250101154619-4bdde671e0a1 github.com/olekukonko/tablewriter v1.1.5 github.com/opencontainers/go-digest v1.0.0 + github.com/pb33f/ordered-map/v2 v2.3.2 github.com/pelletier/go-toml v1.9.5 github.com/quasilyte/go-ruleguard/dsl v0.3.23 github.com/rust-secure-code/go-rustaudit v0.0.0-20250226111315-e20ec32e963c @@ -83,6 +83,7 @@ require ( github.com/spf13/afero v1.15.0 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.12.1 + github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd github.com/ulikunitz/xz v0.5.17 github.com/vbatts/go-mtree v0.7.0 github.com/vifraa/gopom v1.0.0 @@ -95,17 +96,14 @@ require ( golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f golang.org/x/mod v0.42.0 golang.org/x/net v0.61.0 + golang.org/x/sys v0.49.0 golang.org/x/time v0.16.0 golang.org/x/tools v0.52.0 + howett.net/plist v1.0.1 modernc.org/sqlite v1.61.0 ) -require ( - github.com/pb33f/ordered-map/v2 v2.3.2 - github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd - golang.org/x/sys v0.49.0 - howett.net/plist v1.0.1 -) +require github.com/github/go-spdx/v2 v2.8.0 require ( cel.dev/expr v0.25.2 // indirect 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)) + }) + } +}