Repository: anchore/syft
Pull request #3796: Perf: Skip license scanner injection
Author: login: adammcclenaghan
Labels: name: bug
description: Something isn't working
Created: 2025-04-12T17:00:18Z | Merged: 2025-04-23T20:01:10Z

# Description
Creating the default license scanner is a little computationally expensive.

I use Syft as a library and would like the option to set the license scanner myself on the context.

This change skips injecting the license scanner within CreateSbom if it is already set on the context.

The licensectx package has been created to expose `GetContextKey` and `NewDefaultLicenseScanner` to Syft library users.

## Type of change

<!-- Delete any that are not relevant -->

- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (please discuss with the team first; Syft is 1.0 software and we won't accept breaking changes without going to 2.0)
- [ ] Documentation (updates the documentation)
- [ ] Chore (improve the developer experience, fix a test flake, etc, without changing the visible behavior of Syft)
- [x] Performance (make Syft run faster or use less memory, without changing visible behavior much)

# Checklist:

- [ ] I have added unit tests that cover changed behavior
- [ ] I have tested my code in common scenarios and confirmed there are no regressions
- [ ] I have added comments to my code, particularly in hard-to-understand sections



Diff:
diff --git a/internal/licenses/context.go b/internal/licenses/context.go
index 9d735a8a3e3..91301cc20b9 100644
--- a/internal/licenses/context.go
+++ b/internal/licenses/context.go
@@ -6,12 +6,19 @@ import (
 
 type licenseScannerKey struct{}
 
+var ctxKey = licenseScannerKey{}
+
 func SetContextLicenseScanner(ctx context.Context, s Scanner) context.Context {
-	return context.WithValue(ctx, licenseScannerKey{}, s)
+	return context.WithValue(ctx, ctxKey, s)
+}
+
+func IsContextLicenseScannerSet(ctx context.Context) bool {
+	_, ok := ctx.Value(ctxKey).(Scanner)
+	return ok
 }
 
 func ContextLicenseScanner(ctx context.Context) (Scanner, error) {
-	if s, ok := ctx.Value(licenseScannerKey{}).(Scanner); ok {
+	if s, ok := ctx.Value(ctxKey).(Scanner); ok {
 		return s, nil
 	}
 	return NewDefaultScanner()
diff --git a/internal/licenses/context_test.go b/internal/licenses/context_test.go
new file mode 100644
index 00000000000..822be2fa48f
--- /dev/null
+++ b/internal/licenses/context_test.go
@@ -0,0 +1,47 @@
+package licenses
+
+import (
+	"context"
+	"github.com/stretchr/testify/require"
+	"testing"
+)
+
+func TestSetContextLicenseScanner(t *testing.T) {
+	scanner := testScanner(true)
+	ctx := context.Background()
+	ctx = SetContextLicenseScanner(ctx, scanner)
+
+	val := ctx.Value(ctxKey)
+	require.NotNil(t, val)
+	s, ok := val.(Scanner)
+	require.True(t, ok)
+	require.Equal(t, scanner, s)
+}
+
+func TestIsContextLicenseScannerSet(t *testing.T) {
+	scanner := testScanner(true)
+	ctx := context.Background()
+	require.False(t, IsContextLicenseScannerSet(ctx))
+
+	ctx = SetContextLicenseScanner(ctx, scanner)
+	require.True(t, IsContextLicenseScannerSet(ctx))
+}
+
+func TestContextLicenseScanner(t *testing.T) {
+	t.Run("with scanner", func(t *testing.T) {
+		scanner := testScanner(true)
+		ctx := SetContextLicenseScanner(context.Background(), scanner)
+		s, err := ContextLicenseScanner(ctx)
+		if err != nil || s != scanner {
+			t.Fatal("expected scanner from context")
+		}
+	})
+
+	t.Run("without scanner", func(t *testing.T) {
+		ctx := context.Background()
+		s, err := ContextLicenseScanner(ctx)
+		if err != nil || s == nil {
+			t.Fatal("expected default scanner")
+		}
+	})
+}
diff --git a/syft/create_sbom.go b/syft/create_sbom.go
index aec9fdcae27..68605d56491 100644
--- a/syft/create_sbom.go
+++ b/syft/create_sbom.go
@@ -96,14 +96,21 @@ func setupContext(ctx context.Context, cfg *CreateSBOMConfig) (context.Context,
 	ctx = setContextExecutors(ctx, cfg)
 
 	// configure license scanner
-	return setContextLicenseScanner(ctx, cfg)
+	// skip injecting a license scanner if one already set on context
+	if licenses.IsContextLicenseScannerSet(ctx) {
+		return ctx, nil
+	}
+
+	return SetContextLicenseScanner(ctx, cfg.Licenses)
 }
 
-func setContextLicenseScanner(ctx context.Context, cfg *CreateSBOMConfig) (context.Context, error) {
+// SetContextLicenseScanner creates and sets a license scanner
+// on the provided context using the provided license config.
+func SetContextLicenseScanner(ctx context.Context, cfg cataloging.LicenseConfig) (context.Context, error) {
 	// inject a single license scanner and content config for all package cataloging tasks into context
 	licenseScanner, err := licenses.NewDefaultScanner(
-		licenses.WithIncludeLicenseContent(cfg.Licenses.IncludeUnkownLicenseContent),
-		licenses.WithCoverage(cfg.Licenses.Coverage),
+		licenses.WithIncludeLicenseContent(cfg.IncludeUnkownLicenseContent),
+		licenses.WithCoverage(cfg.Coverage),
 	)
 	if err != nil {
 		return nil, fmt.Errorf("could not build licenseScanner for cataloging: %w", err)


Reviews:
user: login: kzantow
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T13:30:50Z
user: login: adammcclenaghan
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T13:44:47Z
user: login: kzantow
body: I think there may be an easier solution to export fewer things without the need to provide context keys and such
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T14:36:01Z
user: login: adammcclenaghan
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T10:52:17Z
user: login: spiffcs
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T20:00:45Z

Review comments:
diff_hunk: @@ -0,0 +1,13 @@
+package licensectx
path: syft/licensectx/context.go
user: login: kzantow
body: We have some exported license-related things here: https://github.com/anchore/syft/blob/main/syft/cataloging/license.go, maybe instead of making a new package this would be a better spot to include the exported context key and creation function? These seem like configuration items, even though they are using context to provide them.
created_at: 2025-04-14T13:30:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,13 @@
+package licensectx
path: syft/licensectx/context.go
user: login: adammcclenaghan
body: Thanks for the suggestion -  that's a good place for these! Have moved them :) 
created_at: 2025-04-14T13:44:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -4,14 +4,16 @@ import (
 	"context"
 )
 
-type licenseScannerKey struct{}
+type LicenseScannerKey struct{}
+
+var CtxKey = LicenseScannerKey{}
path: internal/licenses/context.go
user: login: kzantow
body: nit: `ContextKey` and don't need to export `LicenseScannerKey`
created_at: 2025-04-14T13:55:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -13,3 +13,15 @@ func DefaultLicenseConfig() LicenseConfig {
 		Coverage:                    licenses.DefaultCoverageThreshold,
 	}
 }
+
+// GetContextKey allows callers to access the key used by
+// syft to store the license scanner in the context
+func GetContextKey() licenses.LicenseScannerKey {
+	return licenses.CtxKey
+}
+
+// NewDefaultLicenseScanner creates a default license scanner and exists
+// to export NewDefaultScanner to those who use Syft as a library.
+func NewDefaultLicenseScanner(o ...licenses.Option) (licenses.Scanner, error) {
path: syft/cataloging/license.go
user: login: kzantow
body: A user would not be able to use this due to the internal options. Also, it would not be ideal to return the internal `licenses.Scanner`. However, I would like to avoid exporting anything we don't need to, as it will become part of the Syft 1.x API henceforth.
created_at: 2025-04-14T14:27:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -100,6 +100,11 @@ func setupContext(ctx context.Context, cfg *CreateSBOMConfig) (context.Context,
 }
 
 func setContextLicenseScanner(ctx context.Context, cfg *CreateSBOMConfig) (context.Context, error) {
+	// skip injecting a license scanner if one already set on context
+	if _, ok := ctx.Value(licenses.CtxKey).(licenses.Scanner); ok {
+		return ctx, nil
+	}
path: syft/create_sbom.go
user: login: kzantow
body: What if we simply exported this function and changed the configuration from `*CreateSBOMConfig` to `cataloging.LicensesConfig`, which it looks like the only thing that is needed, below. You would be able to call this once and wouldn't need any further exported context management keys / etc.. I would then move this check above to: `setupContext`
created_at: 2025-04-14T14:29:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -100,6 +100,11 @@ func setupContext(ctx context.Context, cfg *CreateSBOMConfig) (context.Context,
 }
 
 func setContextLicenseScanner(ctx context.Context, cfg *CreateSBOMConfig) (context.Context, error) {
+	// skip injecting a license scanner if one already set on context
+	if _, ok := ctx.Value(licenses.CtxKey).(licenses.Scanner); ok {
+		return ctx, nil
+	}
path: syft/create_sbom.go
user: login: adammcclenaghan
body: @kzantow Thank you - I like this idea too.

I've made the necessary changes, hopefully its in line with what you envisioned :) 
created_at: 2025-04-21T10:52:17Z
author_association: CONTRIBUTOR

PR comments:
user: login: adammcclenaghan
created_at: 2025-04-14T10:01:08Z
body: I've just seen https://github.com/anchore/syft/pull/3721

Which is a similar effort - sorry I missed that. The main difference with this PR is that it goes slightly further and allows library users to set the value on the context & access `NewDefaultScanner` 

Be good to get your thoughts too @danielpacack 
author_association: CONTRIBUTOR
user: login: spiffcs
created_at: 2025-04-23T20:01:06Z
body: TY @adammcclenaghan - this change makes sense!
author_association: CONTRIBUTOR