Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/20260313122127.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
:sparkles: Introduced package `[licensing]` in order to provide helpers regarding licence management
4 changes: 3 additions & 1 deletion utils/go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 4 additions & 6 deletions utils/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -66,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=
Expand Down Expand Up @@ -287,8 +291,6 @@ golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGc
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
Expand All @@ -297,10 +299,6 @@ golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
Expand Down
204 changes: 204 additions & 0 deletions utils/licensing/spdx.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading