Skip to content

Commit a3023a8

Browse files
authored
✨ Introduced package [licensing] in order to provide helpers regarding licence management (#816)
<!-- Copyright (C) 2020-2022 Arm Limited or its affiliates and Contributors. All rights reserved. SPDX-License-Identifier: Apache-2.0 --> ### Description various helpers to help understanding licences ⚠️ This will require go 1.25 ### Test Coverage <!-- Please put an `x` in the correct box e.g. `[x]` to indicate the testing coverage of this change. --> - [x] This change is covered by existing or additional automated tests. - [ ] Manual testing has been performed (and evidence provided) as automated testing was not feasible. - [ ] Additional tests are not required for this change (e.g. documentation update).
1 parent ce6f861 commit a3023a8

5 files changed

Lines changed: 418 additions & 7 deletions

File tree

changes/20260313122127.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
:sparkles: Introduced package `[licensing]` in order to provide helpers regarding licence management

utils/go.mod

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module github.com/ARM-software/golang-utils/utils
22

3-
go 1.25.0
3+
go 1.25.6
44

55
require (
66
github.com/DeRuina/timberjack v1.4.0
@@ -12,6 +12,7 @@ require (
1212
github.com/djherbis/times v1.6.0
1313
github.com/dolmen-go/contextio v1.0.0
1414
github.com/evanphx/hclogr v0.2.0
15+
github.com/git-pkgs/spdx v0.1.1
1516
github.com/go-faker/faker/v4 v4.7.0
1617
github.com/go-git/go-git/v5 v5.17.0
1718
github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a
@@ -70,6 +71,7 @@ require (
7071
github.com/emirpasic/gods v1.18.1 // indirect
7172
github.com/fatih/color v1.16.0 // indirect
7273
github.com/fsnotify/fsnotify v1.9.0 // indirect
74+
github.com/github/go-spdx/v2 v2.4.0 // indirect
7375
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
7476
github.com/go-git/go-billy/v5 v5.8.0 // indirect
7577
github.com/go-ole/go-ole v1.2.6 // indirect

utils/go.sum

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo
6666
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
6767
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
6868
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
69+
github.com/git-pkgs/spdx v0.1.1 h1:jjchxLhvTnTR7fLcdXdNVDh/tLq6B2S6LnaKEzBjhRQ=
70+
github.com/git-pkgs/spdx v0.1.1/go.mod h1:nbZdJ09OuZg9/bgRnnyEM5F5uR8K7Iwf5oDHQvK3WcE=
71+
github.com/github/go-spdx/v2 v2.4.0 h1:+4IwVwJJbm3rzvrQ6P1nI9BDMcy3la4RchRy5uehV/M=
72+
github.com/github/go-spdx/v2 v2.4.0/go.mod h1:/5rwgS0txhGtRdUZwc02bTglzg6HK3FfuEbECKlK2Sg=
6973
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
7074
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
7175
github.com/go-faker/faker/v4 v4.7.0 h1:VboC02cXHl/NuQh5lM2W8b87yp4iFXIu59x4w0RZi4E=
@@ -287,8 +291,6 @@ golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGc
287291
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc=
288292
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
289293
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
290-
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
291-
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
292294
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
293295
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
294296
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
@@ -297,10 +299,6 @@ golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qx
297299
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
298300
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
299301
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
300-
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
301-
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
302-
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
303-
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
304302
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
305303
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
306304
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=

utils/licensing/spdx.go

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
// Package licensing provides helpers for validating, normalising, and working
2+
// with SPDX licence identifiers and expressions.
3+
//
4+
// The package builds on and is inspired by
5+
// https://github.com/git-pkgs/spdx, which provides parsing,
6+
// normalisation, and validation of SPDX licence expressions.
7+
//
8+
// It adds higher-level utilities commonly needed when handling licensing
9+
// metadata in applications
10+
package licensing
11+
12+
import (
13+
"fmt"
14+
"iter"
15+
"net/url"
16+
"slices"
17+
18+
"github.com/git-pkgs/spdx"
19+
validation "github.com/go-ozzo/ozzo-validation/v4"
20+
21+
"github.com/ARM-software/golang-utils/utils/collection"
22+
"github.com/ARM-software/golang-utils/utils/commonerrors"
23+
"github.com/ARM-software/golang-utils/utils/field"
24+
"github.com/ARM-software/golang-utils/utils/reflection"
25+
)
26+
27+
var (
28+
// errSPDXInvalid is returned when a string is not a valid SPDX licence
29+
errSPDXInvalid = validation.NewError("validation_is_spdx_licence", "must be a valid SPDX licence")
30+
31+
// IsSPDXLicence defines an ozzo-validation rule that ensures a string
32+
// contains a valid SPDX licence expression.
33+
//
34+
// This rule can be used with github.com/go-ozzo/ozzo-validation to validate
35+
// fields containing licence identifiers or expressions such as:
36+
//
37+
// MIT
38+
// Apache-2.0
39+
// MIT OR Apache-2.0
40+
// GPL-3.0-only WITH Classpath-exception-2.0
41+
//
42+
// Example:
43+
//
44+
// validation.Field(&pkg.Licence, licensing.IsSPDXLicence)
45+
//
46+
// Validation internally relies on ValidateSPDXLicence.
47+
IsSPDXLicence = validation.NewStringRuleWithError(
48+
func(l string) bool { return ValidateSPDXLicence(l) == nil },
49+
errSPDXInvalid,
50+
)
51+
)
52+
53+
// ValidateSPDXLicence validates that the provided string is a valid SPDX
54+
// licence expression.
55+
//
56+
// The expression is parsed using an lenient SPDX parser which will try to identify licences even if they are not in their canonical form.
57+
//
58+
// Returns an error if:
59+
// - the expression is empty
60+
// - the expression cannot be parsed as a valid SPDX licence expression
61+
//
62+
// Example valid expressions:
63+
//
64+
// MIT
65+
// Apache-2.0
66+
// MIT OR Apache-2.0
67+
// GPL-2.0-or-later
68+
func ValidateSPDXLicence(licence string) error {
69+
if reflection.IsEmpty(licence) {
70+
return commonerrors.UndefinedVariable("licence expression")
71+
}
72+
_, err := spdx.Parse(licence)
73+
if err != nil {
74+
err = commonerrors.WrapError(commonerrors.ErrInvalid, err, "failed normalising SPDX expression")
75+
}
76+
return err
77+
}
78+
79+
// NormaliseSPDXLicence converts an SPDX licence expression into its canonical
80+
// SPDX representation.
81+
//
82+
// This function performs a lax normalisation using the SPDX library, allowing
83+
// minor variations in input formatting while still producing a valid canonical
84+
// SPDX expression.
85+
//
86+
// For example:
87+
//
88+
// "apache 2" → "Apache-2.0"
89+
// "mit or apache2" → "MIT OR Apache-2.0"
90+
//
91+
// Returns the canonical SPDX expression or an error if the expression cannot
92+
// be parsed or normalised.
93+
func NormaliseSPDXLicence(expression string) (canonical string, err error) {
94+
if reflection.IsEmpty(expression) {
95+
err = commonerrors.UndefinedVariable("licence expression")
96+
return
97+
}
98+
canonical, err = spdx.NormalizeExpressionLax(expression) //nolint:misspell
99+
if err != nil {
100+
err = commonerrors.WrapError(commonerrors.ErrInvalid, err, "failed normalising SPDX expression")
101+
}
102+
return
103+
}
104+
105+
// SatisfiesLicensingConstraints determines whether a licence expression is
106+
// compatible with a list of allowed licences.
107+
//
108+
// Note: The input licence expression and all entries in the allowed list are first
109+
// normalised to their canonical SPDX form before evaluation.
110+
//
111+
// Behaviour:
112+
// - The expression may contain SPDX operators such as AND / OR.
113+
// - The function returns true if the licence expression satisfies at least
114+
// one licence in the allowed list according to SPDX semantics.
115+
//
116+
// Example:
117+
//
118+
// licence = "MIT OR Apache-2.0"
119+
// allowedList = ["MIT"]
120+
//
121+
// Result:
122+
//
123+
// true
124+
//
125+
// This behaviour is similar to:
126+
// https://pkg.go.dev/github.com/github/go-spdx/v2/spdxexp#Satisfies
127+
func SatisfiesLicensingConstraints(licence string, allowedList []string) (pass bool, err error) {
128+
norm, err := NormaliseSPDXLicence(licence)
129+
if err != nil {
130+
return
131+
}
132+
allowed, err := collection.MapWithError[string, string](allowedList, NormaliseSPDXLicence)
133+
if err != nil {
134+
return
135+
}
136+
pass, err = spdx.Satisfies(norm, allowed)
137+
if err != nil {
138+
err = commonerrors.WrapError(commonerrors.ErrUnexpected, err, "failed checking licence constraints")
139+
}
140+
return
141+
}
142+
143+
// FetchLicenceURL returns the SPDX website URL corresponding to the provided
144+
// SPDX licence identifier.
145+
//
146+
// The input licence is normalised before constructing the URL.
147+
//
148+
// Example:
149+
//
150+
// MIT → https://spdx.org/licenses/MIT.html
151+
// Apache-2.0 → https://spdx.org/licenses/Apache-2.0.html
152+
//
153+
// The input must represent a single licence identifier rather than a compound
154+
// SPDX expression.
155+
func FetchLicenceURL(spdxLicence *string) (licenceURL *url.URL, err error) {
156+
if reflection.IsEmpty(spdxLicence) {
157+
err = commonerrors.UndefinedVariable("licence")
158+
return
159+
}
160+
lStr := field.OptionalString(spdxLicence, "")
161+
l, err := NormaliseSPDXLicence(lStr)
162+
if err != nil {
163+
err = commonerrors.WrapErrorf(commonerrors.ErrInvalid, err, "failed identifying SPDX licence [%v]", lStr)
164+
return
165+
}
166+
if !spdx.ValidLicense(l) {
167+
err = commonerrors.WrapErrorf(commonerrors.ErrInvalid, err, "not a valid SPDX licence [%v]", lStr)
168+
return
169+
}
170+
licenceURL, err = url.Parse(fmt.Sprintf("https://spdx.org/licenses/%v.html", l))
171+
if err != nil {
172+
err = commonerrors.WrapErrorf(commonerrors.ErrInvalid, err, "failed determining the licence's URL [%v]", lStr)
173+
return
174+
}
175+
return
176+
}
177+
178+
// FetchLicenceURLs extracts all licences referenced in an SPDX licence
179+
// expression and returns the SPDX reference URLs for each licence.
180+
//
181+
// Note: The expression is first normalised before extracting the licences.
182+
// Moreover, operators such as AND, OR, and WITH are ignored when extracting licences
183+
//
184+
// Example:
185+
//
186+
// expression: "MIT OR Apache-2.0"
187+
//
188+
// returns:
189+
//
190+
// https://spdx.org/licenses/MIT.html
191+
// https://spdx.org/licenses/Apache-2.0.html
192+
func FetchLicenceURLs(expression string) (urls iter.Seq[url.URL], err error) {
193+
l, err := NormaliseSPDXLicence(expression)
194+
if err != nil {
195+
return
196+
}
197+
licences, err := spdx.ExtractLicenses(l)
198+
if err != nil {
199+
err = commonerrors.WrapError(commonerrors.ErrInvalid, err, "failed extracting the licences from the expression")
200+
return
201+
}
202+
urls = collection.MapSequenceRefWithError[string, url.URL](slices.Values(licences), FetchLicenceURL)
203+
return
204+
}

0 commit comments

Comments
 (0)