diff --git a/grant/license.go b/grant/license.go index b4dd49b..3889af0 100644 --- a/grant/license.go +++ b/grant/license.go @@ -112,6 +112,15 @@ func handleSPDXLicense(license syftPkg.License, licenses []License, licenseLocat // we have what seems to be a valid SPDX license ID, let's try and get more info about it spdxLicense, err := spdxlicense.GetLicenseByID(extractedLicense) + if err != nil { + // The parser returns " WITH " as a single atom, but the + // SPDX license list is indexed by license ID alone, so the lookup above can + // only fail for these. Retry with the license the exception applies to; the + // full expression is still what ends up on the resulting License. + if base, ok := licenseFromExceptionExpression(extractedLicense); ok { + spdxLicense, err = spdxlicense.GetLicenseByID(base) + } + } if err != nil { log.Errorf("unable to get license by ID: %s; no matching spdx id found", extractedLicense) // if we can't find a matching SPDX license, just add the license as-is @@ -136,6 +145,22 @@ func handleSPDXLicense(license syftPkg.License, licenses []License, licenseLocat return licenses } +// licenseFromExceptionExpression returns the license an SPDX "WITH" expression +// applies to, for example "Apache-2.0" from "Apache-2.0 WITH LLVM-exception". +func licenseFromExceptionExpression(expression string) (string, bool) { + parts := strings.SplitN(expression, " WITH ", 2) + if len(parts) != 2 { + return "", false + } + + license := strings.TrimRight(strings.TrimSpace(parts[0]), "+") + if license == "" || strings.TrimSpace(parts[1]) == "" { + return "", false + } + + return license, true +} + func addNonSPDXLicense(licenses []License, license syftPkg.License, locations []string) []License { // Filter out sha256: licenses - these are content hashes from Syft when license detection fails if strings.HasPrefix(license.Value, "sha256:") { diff --git a/grant/license_test.go b/grant/license_test.go index 949f6d5..585346f 100644 --- a/grant/license_test.go +++ b/grant/license_test.go @@ -42,3 +42,54 @@ func TestConvertSyftLicenses_MalformedSPDXExpressionDoesNotPanic(t *testing.T) { }) } } + +// TestConvertSyftLicenses_SPDXExpressionWithException covers SPDX expressions +// that use the WITH operator. The upstream parser hands these back as a single +// atom ("Apache-2.0 WITH LLVM-exception"), which is not a key in the SPDX +// license list, so grant used to log "unable to get license by ID" and fall back +// to an unresolved license. +func TestConvertSyftLicenses_SPDXExpressionWithException(t *testing.T) { + tests := []struct { + expression string + wantSPDX []string + wantLicense []string + }{ + { + expression: "Apache-2.0 WITH LLVM-exception", + wantSPDX: []string{"Apache-2.0 WITH LLVM-exception"}, + wantLicense: []string{"Apache-2.0"}, + }, + { + expression: "GPL-2.0-only WITH Classpath-exception-2.0", + wantSPDX: []string{"GPL-2.0-only WITH Classpath-exception-2.0"}, + wantLicense: []string{"GPL-2.0-only"}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.expression, func(t *testing.T) { + set := syftPkg.NewLicenseSet(syftPkg.License{Value: tt.expression, SPDXExpression: tt.expression}) + + got := ConvertSyftLicenses(set) + + if len(got) != len(tt.wantSPDX) { + t.Fatalf("expected %d licenses, got %d", len(tt.wantSPDX), len(got)) + } + for i, want := range tt.wantSPDX { + if !got[i].IsSPDX() { + t.Fatalf("expected %q to resolve to an SPDX license, got name %q", tt.expression, got[i].Name) + } + if got[i].SPDXExpression != want { + t.Errorf("got SPDX expression %q, want %q", got[i].SPDXExpression, want) + } + if got[i].LicenseID != tt.wantLicense[i] { + t.Errorf("got license id %q, want %q", got[i].LicenseID, tt.wantLicense[i]) + } + if got[i].Name == "" { + t.Errorf("expected the license name to be populated from the SPDX list") + } + } + }) + } +}