From 4d9c452292db45ab5e1ca14e4b9851d1d83f1098 Mon Sep 17 00:00:00 2001 From: Adrien CABARBAYE Date: Fri, 13 Mar 2026 12:28:02 +0000 Subject: [PATCH 1/2] :sparkles: Introduced package `[licensing]` in order to provide helpers regarding licence management --- changes/20260313122127.feature | 1 + utils/go.mod | 4 +- utils/go.sum | 6 +- utils/licensing/spdx.go | 204 ++++++++++++++++++++++++++++++++ utils/licensing/spdx_test.go | 206 +++++++++++++++++++++++++++++++++ 5 files changed, 418 insertions(+), 3 deletions(-) create mode 100644 changes/20260313122127.feature create mode 100644 utils/licensing/spdx.go create mode 100644 utils/licensing/spdx_test.go diff --git a/changes/20260313122127.feature b/changes/20260313122127.feature new file mode 100644 index 0000000000..2f1bcbe5a3 --- /dev/null +++ b/changes/20260313122127.feature @@ -0,0 +1 @@ +:sparkles: Introduced package `[licensing]` in order to provide helpers regarding licence management diff --git a/utils/go.mod b/utils/go.mod index 71ec9cb26e..4510318b58 100644 --- a/utils/go.mod +++ b/utils/go.mod @@ -1,6 +1,6 @@ module github.com/ARM-software/golang-utils/utils -go 1.24.1 +go 1.25.6 require ( github.com/DeRuina/timberjack v1.3.9 @@ -12,6 +12,7 @@ require ( github.com/djherbis/times v1.6.0 github.com/dolmen-go/contextio v1.0.0 github.com/evanphx/hclogr v0.2.0 + github.com/git-pkgs/spdx v0.1.1 github.com/go-faker/faker/v4 v4.7.0 github.com/go-git/go-git/v5 v5.17.0 github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a @@ -70,6 +71,7 @@ require ( github.com/emirpasic/gods v1.18.1 // indirect github.com/fatih/color v1.16.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/github/go-spdx/v2 v2.4.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.8.0 // indirect github.com/go-ole/go-ole v1.2.6 // indirect diff --git a/utils/go.sum b/utils/go.sum index 1354dc097b..4ddde413fa 100644 --- a/utils/go.sum +++ b/utils/go.sum @@ -43,8 +43,6 @@ github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+Zlfu github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= -github.com/dmarkham/enumer v1.5.11 h1:quorLCaEfzjJ23Pf7PB9lyyaHseh91YfTM/sAD/4Mbo= -github.com/dmarkham/enumer v1.5.11/go.mod h1:yixql+kDDQRYqcuBM2n9Vlt7NoT9ixgXhaXry8vmRg8= github.com/dmarkham/enumer v1.6.3 h1:B4aV4OsfzbrS5rvjILt4mMjiWBA//cKxJUMsvHZ8mEI= github.com/dmarkham/enumer v1.6.3/go.mod h1:DyjXaqCglj4GhELF73oWiparNkYkXvmOBLza/o4kO74= github.com/dolmen-go/contextio v1.0.0 h1:bNfCo4gsRIhMeo6Z1ImXzkxZG81B6I5t2fUFJjphdAU= @@ -68,6 +66,10 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/git-pkgs/spdx v0.1.1 h1:jjchxLhvTnTR7fLcdXdNVDh/tLq6B2S6LnaKEzBjhRQ= +github.com/git-pkgs/spdx v0.1.1/go.mod h1:nbZdJ09OuZg9/bgRnnyEM5F5uR8K7Iwf5oDHQvK3WcE= +github.com/github/go-spdx/v2 v2.4.0 h1:+4IwVwJJbm3rzvrQ6P1nI9BDMcy3la4RchRy5uehV/M= +github.com/github/go-spdx/v2 v2.4.0/go.mod h1:/5rwgS0txhGtRdUZwc02bTglzg6HK3FfuEbECKlK2Sg= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-faker/faker/v4 v4.7.0 h1:VboC02cXHl/NuQh5lM2W8b87yp4iFXIu59x4w0RZi4E= diff --git a/utils/licensing/spdx.go b/utils/licensing/spdx.go new file mode 100644 index 0000000000..6d446c5c23 --- /dev/null +++ b/utils/licensing/spdx.go @@ -0,0 +1,204 @@ +// Package licensing provides helpers for validating, normalising, and working +// with SPDX licence identifiers and expressions. +// +// The package builds on and is inspired by +// https://github.com/git-pkgs/spdx, which provides parsing, +// normalisation, and validation of SPDX licence expressions. +// +// It adds higher-level utilities commonly needed when handling licensing +// metadata in applications +package licensing + +import ( + "fmt" + "iter" + "net/url" + "slices" + + "github.com/git-pkgs/spdx" + validation "github.com/go-ozzo/ozzo-validation/v4" + + "github.com/ARM-software/golang-utils/utils/collection" + "github.com/ARM-software/golang-utils/utils/commonerrors" + "github.com/ARM-software/golang-utils/utils/field" + "github.com/ARM-software/golang-utils/utils/reflection" +) + +var ( + // errSPDXInvalid is returned when a string is not a valid SPDX licence + errSPDXInvalid = validation.NewError("validation_is_spdx_licence", "must be a valid SPDX licence") + + // IsSPDXLicence defines an ozzo-validation rule that ensures a string + // contains a valid SPDX licence expression. + // + // This rule can be used with github.com/go-ozzo/ozzo-validation to validate + // fields containing licence identifiers or expressions such as: + // + // MIT + // Apache-2.0 + // MIT OR Apache-2.0 + // GPL-3.0-only WITH Classpath-exception-2.0 + // + // Example: + // + // validation.Field(&pkg.Licence, licensing.IsSPDXLicence) + // + // Validation internally relies on ValidateSPDXLicence. + IsSPDXLicence = validation.NewStringRuleWithError( + func(l string) bool { return ValidateSPDXLicence(l) == nil }, + errSPDXInvalid, + ) +) + +// ValidateSPDXLicence validates that the provided string is a valid SPDX +// licence expression. +// +// The expression is parsed using an lenient SPDX parser which will try to identify licences even if they are not in their canonical form. +// +// Returns an error if: +// - the expression is empty +// - the expression cannot be parsed as a valid SPDX licence expression +// +// Example valid expressions: +// +// MIT +// Apache-2.0 +// MIT OR Apache-2.0 +// GPL-2.0-or-later +func ValidateSPDXLicence(licence string) error { + if reflection.IsEmpty(licence) { + return commonerrors.UndefinedVariable("licence expression") + } + _, err := spdx.Parse(licence) + if err != nil { + err = commonerrors.WrapError(commonerrors.ErrInvalid, err, "failed normalising SPDX expression") + } + return err +} + +// NormaliseSPDXLicence converts an SPDX licence expression into its canonical +// SPDX representation. +// +// This function performs a lax normalisation using the SPDX library, allowing +// minor variations in input formatting while still producing a valid canonical +// SPDX expression. +// +// For example: +// +// "apache 2" → "Apache-2.0" +// "mit or apache2" → "MIT OR Apache-2.0" +// +// Returns the canonical SPDX expression or an error if the expression cannot +// be parsed or normalised. +func NormaliseSPDXLicence(expression string) (canonical string, err error) { + if reflection.IsEmpty(expression) { + err = commonerrors.UndefinedVariable("licence expression") + return + } + canonical, err = spdx.NormalizeExpressionLax(expression) //nolint:misspell + if err != nil { + err = commonerrors.WrapError(commonerrors.ErrInvalid, err, "failed normalising SPDX expression") + } + return +} + +// SatisfiesLicensingConstraints determines whether a licence expression is +// compatible with a list of allowed licences. +// +// Note: The input licence expression and all entries in the allowed list are first +// normalised to their canonical SPDX form before evaluation. +// +// Behaviour: +// - The expression may contain SPDX operators such as AND / OR. +// - The function returns true if the licence expression satisfies at least +// one licence in the allowed list according to SPDX semantics. +// +// Example: +// +// licence = "MIT OR Apache-2.0" +// allowedList = ["MIT"] +// +// Result: +// +// true +// +// This behaviour is similar to: +// https://pkg.go.dev/github.com/github/go-spdx/v2/spdxexp#Satisfies +func SatisfiesLicensingConstraints(licence string, allowedList []string) (pass bool, err error) { + norm, err := NormaliseSPDXLicence(licence) + if err != nil { + return + } + allowed, err := collection.MapWithError[string, string](allowedList, NormaliseSPDXLicence) + if err != nil { + return + } + pass, err = spdx.Satisfies(norm, allowed) + if err != nil { + err = commonerrors.WrapError(commonerrors.ErrUnexpected, err, "failed checking licence constraints") + } + return +} + +// FetchLicenceURL returns the SPDX website URL corresponding to the provided +// SPDX licence identifier. +// +// The input licence is normalised before constructing the URL. +// +// Example: +// +// MIT → https://spdx.org/licenses/MIT.html +// Apache-2.0 → https://spdx.org/licenses/Apache-2.0.html +// +// The input must represent a single licence identifier rather than a compound +// SPDX expression. +func FetchLicenceURL(spdxLicence *string) (licenceURL *url.URL, err error) { + if reflection.IsEmpty(spdxLicence) { + err = commonerrors.UndefinedVariable("licence") + return + } + lStr := field.OptionalString(spdxLicence, "") + l, err := NormaliseSPDXLicence(lStr) + if err != nil { + err = commonerrors.WrapErrorf(commonerrors.ErrInvalid, err, "failed identifying SPDX licence [%v]", lStr) + return + } + if !spdx.ValidLicense(l) { + err = commonerrors.WrapErrorf(commonerrors.ErrInvalid, err, "not a valid SPDX licence [%v]", lStr) + return + } + licenceURL, err = url.Parse(fmt.Sprintf("https://spdx.org/licenses/%v.html", l)) + if err != nil { + err = commonerrors.WrapErrorf(commonerrors.ErrInvalid, err, "failed determining the licence's URL [%v]", lStr) + return + } + return +} + +// FetchLicenceURLs extracts all licences referenced in an SPDX licence +// expression and returns the SPDX reference URLs for each licence. +// +// Note: The expression is first normalised before extracting the licences. +// Moreover, operators such as AND, OR, and WITH are ignored when extracting licences +// +// Example: +// +// expression: "MIT OR Apache-2.0" +// +// returns: +// +// https://spdx.org/licenses/MIT.html +// https://spdx.org/licenses/Apache-2.0.html +func FetchLicenceURLs(expression string) (urls iter.Seq[url.URL], err error) { + l, err := NormaliseSPDXLicence(expression) + if err != nil { + return + } + licences, err := spdx.ExtractLicenses(l) + if err != nil { + err = commonerrors.WrapError(commonerrors.ErrInvalid, err, "failed extracting the licences from the expression") + return + } + urls = collection.MapSequenceRefWithError[string, url.URL](slices.Values(licences), FetchLicenceURL) + return +} diff --git a/utils/licensing/spdx_test.go b/utils/licensing/spdx_test.go new file mode 100644 index 0000000000..e35f6c196c --- /dev/null +++ b/utils/licensing/spdx_test.go @@ -0,0 +1,206 @@ +package licensing + +import ( + "net/url" + "slices" + "testing" + + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/stretchr/testify/require" + + "github.com/ARM-software/golang-utils/utils/collection" + "github.com/ARM-software/golang-utils/utils/commonerrors" + "github.com/ARM-software/golang-utils/utils/commonerrors/errortest" + "github.com/ARM-software/golang-utils/utils/field" +) + +func TestValidateSPDXLicence(t *testing.T) { + t.Parallel() + + tests := []struct { + licence string + expectedError bool + }{ + {"MIT", false}, + {"MIT OR Apache-2.0", false}, + {"GPL-2.0-or-later", false}, + {"", true}, + {"MIT OR", true}, + {"definitely-not-a-real-licence", true}, + } + + for i := range tests { + test := tests[i] + t.Run(test.licence, func(t *testing.T) { + err := ValidateSPDXLicence(test.licence) + + if test.expectedError { + errortest.AssertError(t, err, commonerrors.ErrInvalid, commonerrors.ErrUndefined) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestIsSPDXLicence(t *testing.T) { + t.Parallel() + + t.Run("MIT", func(t *testing.T) { + err := validation.Validate("MIT", IsSPDXLicence) + require.NoError(t, err) + }) + + t.Run("MIT OR", func(t *testing.T) { + err := validation.Validate("MIT OR", IsSPDXLicence) + require.Error(t, err) + }) +} + +func TestNormaliseSPDXLicence(t *testing.T) { + t.Parallel() + + tests := []struct { + expression string + expectedResult string + expectedError bool + }{ + {"MIT", "MIT", false}, + {"MIT OR Apache-2.0", "MIT OR Apache-2.0", false}, + {"mit or apache 2", "MIT OR Apache-2.0", false}, + {"", "", true}, + {"MIT OR", "", true}, + } + + for i := range tests { + test := tests[i] + t.Run(test.expression, func(t *testing.T) { + res, err := NormaliseSPDXLicence(test.expression) + + if test.expectedError { + errortest.AssertError(t, err, commonerrors.ErrInvalid, commonerrors.ErrUndefined) + require.Empty(t, res) + } else { + require.NoError(t, err) + require.Equal(t, test.expectedResult, res) + } + }) + } +} + +func TestSatisfiesLicensingConstraints(t *testing.T) { + t.Parallel() + + tests := []struct { + licence string + allowedList []string + expectedPass bool + expectedError bool + }{ + {"MIT OR Apache-2.0", []string{"MIT"}, true, false}, + {"Apache-2.0", []string{"MIT"}, false, false}, + {"mit or apache 2", []string{"apache 2"}, true, false}, + {"MIT OR", []string{"MIT"}, false, true}, + {"MIT", []string{"Apache-2.0", "not-a-licence"}, false, true}, + } + + for i := range tests { + test := tests[i] + t.Run(test.licence, func(t *testing.T) { + pass, err := SatisfiesLicensingConstraints(test.licence, test.allowedList) + + if test.expectedError { + errortest.AssertError(t, err, commonerrors.ErrInvalid, commonerrors.ErrUndefined) + require.False(t, pass) + } else { + require.NoError(t, err) + require.Equal(t, test.expectedPass, pass) + } + }) + } +} + +func TestFetchLicenceURL(t *testing.T) { + t.Parallel() + + tests := []struct { + licence string + expectedURL string + expectedError bool + }{ + {"MIT", "https://spdx.org/licenses/MIT.html", false}, + {"apache 2", "https://spdx.org/licenses/Apache-2.0.html", false}, + {"", "", true}, + {"", "", true}, + {"MIT OR Apache-2.0", "", true}, + {"not-a-licence", "", true}, + } + + for i := range tests { + test := tests[i] + + t.Run(test.licence, func(t *testing.T) { + u, err := FetchLicenceURL(field.ToOptionalStringOrNilIfEmpty(test.licence)) + if test.expectedError { + errortest.AssertError(t, err, commonerrors.ErrInvalid, commonerrors.ErrUndefined) + require.Nil(t, u) + } else { + require.NoError(t, err) + require.NotNil(t, u) + require.Equal(t, test.expectedURL, u.String()) + } + }) + } +} + +func TestFetchLicenceURLs(t *testing.T) { + t.Parallel() + + tests := []struct { + expression string + expectedURLs []string + expectedError bool + }{ + { + "MIT", + []string{"https://spdx.org/licenses/MIT.html"}, + false, + }, + { + "MIT OR Apache-2.0", + []string{ + "https://spdx.org/licenses/MIT.html", + "https://spdx.org/licenses/Apache-2.0.html", + }, + false, + }, + { + "mit or apache 2", + []string{ + "https://spdx.org/licenses/MIT.html", + "https://spdx.org/licenses/Apache-2.0.html", + }, + false, + }, + {"", nil, true}, + {"MIT OR", nil, true}, + } + + for i := range tests { + test := tests[i] + + t.Run(test.expression, func(t *testing.T) { + seq, err := FetchLicenceURLs(test.expression) + + if test.expectedError { + errortest.AssertError(t, err, commonerrors.ErrInvalid, commonerrors.ErrUndefined) + require.Empty(t, seq) + return + } + + require.NoError(t, err) + require.NotEmpty(t, seq) + require.ElementsMatch(t, test.expectedURLs, slices.Collect(collection.MapSequence[url.URL, string](seq, func(u url.URL) string { return u.String() }))) + }) + } +} From 1e3fe0300d01ffc59890f1d40dd7601bee43adb7 Mon Sep 17 00:00:00 2001 From: Adrien CABARBAYE Date: Fri, 13 Mar 2026 15:08:24 +0000 Subject: [PATCH 2/2] :camel: bump --- utils/go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/go.mod b/utils/go.mod index e81cc5e52b..754326d7a9 100644 --- a/utils/go.mod +++ b/utils/go.mod @@ -1,6 +1,6 @@ module github.com/ARM-software/golang-utils/utils -go 1.25.0 +go 1.25.6 require ( github.com/DeRuina/timberjack v1.4.0