Skip to content

Commit

Permalink
Merge pull request #20 from github/roman/validate_licenses
Browse files Browse the repository at this point in the history
Add a function to validate licenses
  • Loading branch information
RomanIakovlev authored Oct 26, 2022
2 parents a00894e + 261c83b commit 00d018e
Show file tree
Hide file tree
Showing 4 changed files with 141 additions and 54 deletions.
46 changes: 44 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ Golang implementation of a checker for determining if a set of SPDX IDs satisfie

## Public API

_NOTE: The public API is initially limited to the Satisfies function. If there is interest in the
output of the parser or license checking being public, please submit an issue for consideration._
_NOTE: The public API is initially limited to the Satisfies and ValidateLicenses functions. If
there is interest in the output of the parser or license checking being public, please submit an
issue for consideration._

### Function: Satisfies

Expand Down Expand Up @@ -45,6 +46,10 @@ Example allowedList:
[]string{"GPL-2.0-or-later"}
```

**N.B.** If at least one of expressions from `allowedList` is not a valid SPDX expression, the call
to `Satisfies` will produce an error. Use [`ValidateLicenses`](###-ValidateLicenses) function
to first check if all of the expressions from `allowedList` are valid.

#### Examples: Satisfies returns true

```go
Expand All @@ -65,6 +70,43 @@ Satisfies("Apache-1.0", []string{"Apache-2.0+"})
Satisfies("MIT AND Apache-2.0", []string{"MIT"})
```

### ValidateLicenses

```go
func ValidateLicenses(licenses []string) (bool, []string)
```

Function `ValidateLicenses` is used to determine if any of the provided license expressions is
invalid.

**parameter: licenses**

Licenses is a slice of strings which must be validated as SPDX expressions.

**returns**

Function `ValidateLicenses` has 2 return values. First is `bool` which equals `true` if all of
the provided licenses provided are valid, and `false` otherwise.

The second parameter is a slice of all invalid licenses which were provided.

#### Examples: ValidateLicenses returns no invalid licenses

```go
valid, invalidLicenses := ValidateLicenses([]string{"Apache-2.0"})
assert.True(valid)
assert.Empty(invalidLicenses)
```

#### Examples: ValidateLicenses returns invalid licenses

```go
valid, invalidLicenses := ValidateLicenses([]string{"NON-EXISTENT-LICENSE", "MIT"})
assert.False(valid)
assert.Contains(invalidLicenses, "NON-EXISTENT-LICENSE")
assert.NotContains(invalidLicenses, "MIT")
```

## Background

This package was developed to support testing whether a repository's license requirements are met by an allowed-list of licenses.
Expand Down
116 changes: 69 additions & 47 deletions spdxexp/satisfies.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,35 +5,50 @@ import (
"sort"
)

// ValidateLicenses checks if given licenses are valid according to spdx.
//
// Returns all the invalid licenses contained in the `licenses` argument.
func ValidateLicenses(licenses []string) (bool, []string) {
valid := true
invalidLicenses := []string{}
for _, license := range licenses {
if _, err := parse(license); err != nil {
valid = false
invalidLicenses = append(invalidLicenses, license)
}
}
return valid, invalidLicenses
}

// Satisfies determines if test license expression satisfies allowed list of licenses.
//
// Examples:
// "MIT" satisfies "MIT" is true
//
// "MIT" satisfies ["MIT", "Apache-2.0"] is true
// "MIT OR Apache-2.0" satisfies ["MIT"] is true
// "GPL" satisfies ["MIT", "Apache-2.0"] is false
// "MIT OR Apache-2.0" satisfies ["GPL"] is false
// "MIT" satisfies "MIT" is true
//
// "Apache-2.0 AND MIT" satisfies ["MIT", "Apache-2.0"] is true
// "MIT AND Apache-2.0" satisfies ["MIT", "Apache-2.0"] is true
// "MIT AND Apache-2.0" satisfies ["MIT"] is false
// "GPL" satisfies ["MIT", "Apache-2.0"] is false
// "MIT" satisfies ["MIT", "Apache-2.0"] is true
// "MIT OR Apache-2.0" satisfies ["MIT"] is true
// "GPL" satisfies ["MIT", "Apache-2.0"] is false
// "MIT OR Apache-2.0" satisfies ["GPL"] is false
//
// "MIT AND Apache-2.0" satisfies ["MIT", "Apache-1.0", "Apache-2.0"] is true
// "Apache-2.0 AND MIT" satisfies ["MIT", "Apache-2.0"] is true
// "MIT AND Apache-2.0" satisfies ["MIT", "Apache-2.0"] is true
// "MIT AND Apache-2.0" satisfies ["MIT"] is false
// "GPL" satisfies ["MIT", "Apache-2.0"] is false
//
// "Apache-1.0" satisfies ["Apache-2.0+"] is false
// "Apache-2.0" satisfies ["Apache-2.0+"] is true
// "Apache-3.0" satisfies ["Apache-2.0+"] returns error about Apache-3.0 license not existing
// "MIT AND Apache-2.0" satisfies ["MIT", "Apache-1.0", "Apache-2.0"] is true
//
// "Apache-1.0" satisfies ["Apache-2.0-or-later"] is false
// "Apache-2.0" satisfies ["Apache-2.0-or-later"] is true
// "Apache-3.0" satisfies ["Apache-2.0-or-later"] returns error about Apache-3.0 license not existing
// "Apache-1.0" satisfies ["Apache-2.0+"] is false
// "Apache-2.0" satisfies ["Apache-2.0+"] is true
// "Apache-3.0" satisfies ["Apache-2.0+"] returns error about Apache-3.0 license not existing
//
// "Apache-1.0" satisfies ["Apache-2.0-only"] is false
// "Apache-2.0" satisfies ["Apache-2.0-only"] is true
// "Apache-3.0" satisfies ["Apache-2.0-only"] returns error about Apache-3.0 license not existing
// "Apache-1.0" satisfies ["Apache-2.0-or-later"] is false
// "Apache-2.0" satisfies ["Apache-2.0-or-later"] is true
// "Apache-3.0" satisfies ["Apache-2.0-or-later"] returns error about Apache-3.0 license not existing
//
// "Apache-1.0" satisfies ["Apache-2.0-only"] is false
// "Apache-2.0" satisfies ["Apache-2.0-only"] is true
// "Apache-3.0" satisfies ["Apache-2.0-only"] returns error about Apache-3.0 license not existing
func Satisfies(testExpression string, allowedList []string) (bool, error) {
expressionNode, err := parse(testExpression)
if err != nil {
Expand Down Expand Up @@ -103,25 +118,26 @@ func isCompatible(expressionPart, allowed []*node) bool {
// grouped in an array and ORed licenses each in a separate array.
//
// Example:
// License node: "MIT" becomes [["MIT"]]
// OR Expression: "MIT OR Apache-2.0" becomes [["MIT"], ["Apache-2.0"]]
// AND Expression: "MIT AND Apache-2.0" becomes [["MIT", "Apache-2.0"]]
// OR-AND Expression: "MIT OR Apache-2.0 AND GPL-2.0" becomes [["MIT"], ["Apache-2.0", "GPL-2.0"]]
// OR(AND) Expression: "MIT OR (Apache-2.0 AND GPL-2.0)" becomes [["MIT"], ["Apache-2.0", "GPL-2.0"]]
// AND-OR Expression: "MIT AND Apache-2.0 OR GPL-2.0" becomes [["Apache-2.0", "MIT], ["GPL-2.0"]]
// AND(OR) Expression: "MIT AND (Apache-2.0 OR GPL-2.0)" becomes [["Apache-2.0", "MIT], ["GPL-2.0", "MIT"]]
// OR-AND-OR Expression: "MIT OR ISC AND Apache-2.0 OR GPL-2.0" becomes
// [["MIT"], ["Apache-2.0", "ISC"], ["GPL-2.0"]]
// (OR)AND(OR) Expression: "(MIT OR ISC) AND (Apache-2.0 OR GPL-2.0)" becomes
// [["Apache-2.0", "MIT"], ["GPL-2.0", "MIT"], ["Apache-2.0", "ISC"], ["GPL-2.0", "ISC"]]
// OR(AND)OR Expression: "MIT OR (ISC AND Apache-2.0) OR GPL-2.0" becomes
// [["MIT"], ["Apache-2.0", "ISC"], ["GPL-2.0"]]
// AND-OR-AND Expression: "MIT AND ISC OR Apache-2.0 AND GPL-2.0" becomes
// [["ISC", "MIT"], ["Apache-2.0", "GPL-2.0"]]
// (AND)OR(AND) Expression: "(MIT AND ISC) OR (Apache-2.0 AND GPL-2.0)" becomes
// [["ISC", "MIT"], ["Apache-2.0", "GPL-2.0"]]
// AND(OR)AND Expression: "MIT AND (ISC OR Apache-2.0) AND GPL-2.0" becomes
// [["GPL-2.0", "ISC", "MIT"], ["Apache-2.0", "GPL-2.0", "MIT"]]
//
// License node: "MIT" becomes [["MIT"]]
// OR Expression: "MIT OR Apache-2.0" becomes [["MIT"], ["Apache-2.0"]]
// AND Expression: "MIT AND Apache-2.0" becomes [["MIT", "Apache-2.0"]]
// OR-AND Expression: "MIT OR Apache-2.0 AND GPL-2.0" becomes [["MIT"], ["Apache-2.0", "GPL-2.0"]]
// OR(AND) Expression: "MIT OR (Apache-2.0 AND GPL-2.0)" becomes [["MIT"], ["Apache-2.0", "GPL-2.0"]]
// AND-OR Expression: "MIT AND Apache-2.0 OR GPL-2.0" becomes [["Apache-2.0", "MIT], ["GPL-2.0"]]
// AND(OR) Expression: "MIT AND (Apache-2.0 OR GPL-2.0)" becomes [["Apache-2.0", "MIT], ["GPL-2.0", "MIT"]]
// OR-AND-OR Expression: "MIT OR ISC AND Apache-2.0 OR GPL-2.0" becomes
// [["MIT"], ["Apache-2.0", "ISC"], ["GPL-2.0"]]
// (OR)AND(OR) Expression: "(MIT OR ISC) AND (Apache-2.0 OR GPL-2.0)" becomes
// [["Apache-2.0", "MIT"], ["GPL-2.0", "MIT"], ["Apache-2.0", "ISC"], ["GPL-2.0", "ISC"]]
// OR(AND)OR Expression: "MIT OR (ISC AND Apache-2.0) OR GPL-2.0" becomes
// [["MIT"], ["Apache-2.0", "ISC"], ["GPL-2.0"]]
// AND-OR-AND Expression: "MIT AND ISC OR Apache-2.0 AND GPL-2.0" becomes
// [["ISC", "MIT"], ["Apache-2.0", "GPL-2.0"]]
// (AND)OR(AND) Expression: "(MIT AND ISC) OR (Apache-2.0 AND GPL-2.0)" becomes
// [["ISC", "MIT"], ["Apache-2.0", "GPL-2.0"]]
// AND(OR)AND Expression: "MIT AND (ISC OR Apache-2.0) AND GPL-2.0" becomes
// [["GPL-2.0", "ISC", "MIT"], ["Apache-2.0", "GPL-2.0", "MIT"]]
func (n *node) expand(withDeepSort bool) [][]*node {
if n.isLicense() || n.isLicenseRef() {
return [][]*node{{n}}
Expand All @@ -143,7 +159,8 @@ func (n *node) expand(withDeepSort bool) [][]*node {
// expandOr expands the given expression into an equivalent array representing ORed licenses each in a separate array.
//
// Example:
// OR Expression: "MIT OR Apache-2.0" becomes [["MIT"], ["Apache-2.0"]]
//
// OR Expression: "MIT OR Apache-2.0" becomes [["MIT"], ["Apache-2.0"]]
func (n *node) expandOr() [][]*node {
var result [][]*node
result = expandOrTerm(n.left(), result)
Expand Down Expand Up @@ -172,8 +189,10 @@ func expandOrTerm(term *node, result [][]*node) [][]*node {
// expressions are combined with the ANDed expressions.
//
// Example:
// AND Expression: "MIT AND Apache-2.0" becomes [["MIT", "Apache-2.0"]]
// AND(OR) Expression: "MIT AND (Apache-2.0 OR GPL-2.0)" becomes [["Apache-2.0", "MIT], ["GPL-2.0", "MIT"]]
//
// AND Expression: "MIT AND Apache-2.0" becomes [["MIT", "Apache-2.0"]]
// AND(OR) Expression: "MIT AND (Apache-2.0 OR GPL-2.0)" becomes [["Apache-2.0", "MIT], ["GPL-2.0", "MIT"]]
//
// See more examples under func expand.
func (n *node) expandAnd() [][]*node {
left := expandAndTerm(n.left())
Expand Down Expand Up @@ -210,8 +229,9 @@ func expandAndTerm(term *node) [][]*node {
// producing more results than exists in the left or right results.
//
// Example:
// left: {{"MIT"}} right: {{"ISC"}, {"Apache-2.0"}} becomes
// {{"MIT", "ISC"}, {"MIT", "Apache-2.0"}}
//
// left: {{"MIT"}} right: {{"ISC"}, {"Apache-2.0"}} becomes
// {{"MIT", "ISC"}, {"MIT", "Apache-2.0"}}
func appendTerms(left, right [][]*node) [][]*node {
var result [][]*node
for _, r := range right {
Expand All @@ -229,8 +249,9 @@ func appendTerms(left, right [][]*node) [][]*node {
// are merged left and right results.
//
// Example:
// left: {{"MIT"}} right: {{"ISC", "Apache-2.0"}} becomes
// {{"MIT", "ISC", "Apache-2.0"}}
//
// left: {{"MIT"}} right: {{"ISC", "Apache-2.0"}} becomes
// {{"MIT", "ISC", "Apache-2.0"}}
func mergeTerms(left, right [][]*node) [][]*node {
results := left
for _, r := range right {
Expand Down Expand Up @@ -263,8 +284,9 @@ func sortAndDedup(nodes []*node) []*node {
// Then each array of nodes are sorted relative to the other arrays.
//
// Example:
// BEFORE {{"MIT", "GPL-2.0"}, {"ISC", "Apache-2.0"}}
// AFTER {{"Apache-2.0", "ISC"}, {"GPL-2.0", "MIT"}}
//
// BEFORE {{"MIT", "GPL-2.0"}, {"ISC", "Apache-2.0"}}
// AFTER {{"Apache-2.0", "ISC"}, {"GPL-2.0", "MIT"}}
func deepSort(nodes2d [][]*node) [][]*node {
if len(nodes2d) == 0 || len(nodes2d) == 1 && len(nodes2d[0]) <= 1 {
return nodes2d
Expand Down
23 changes: 23 additions & 0 deletions spdxexp/satisfies_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ import (
"github.com/stretchr/testify/assert"
)

func TestValidateLicenses(t *testing.T) {
tests := []struct {
name string
inputLicenses []string
allValid bool
invalidLicenses []string
}{
{"All invalid", []string{"MTI", "Apche-2.0", "0xDEADBEEF", ""}, false, []string{"MTI", "Apche-2.0", "0xDEADBEEF", ""}},
{"All valid", []string{"MIT", "Apache-2.0", "GPL-2.0"}, true, []string{}},
{"Some invalid", []string{"MTI", "Apche-2.0", "GPL-2.0"}, false, []string{"MTI", "Apche-2.0"}},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
valid, invalidLicenses := ValidateLicenses(test.inputLicenses)
assert.EqualValues(t, test.invalidLicenses, invalidLicenses)
assert.Equal(t, test.allValid, valid)
})
}
}

func TestSatisfies(t *testing.T) {
tests := []struct {
name string
Expand All @@ -28,6 +49,8 @@ func TestSatisfies(t *testing.T) {
errors.New("allowedList requires at least one element, but is empty")},
{"err - invalid license", "NON-EXISTENT-LICENSE", []string{"MIT", "Apache-2.0"}, false,
errors.New("unknown license 'NON-EXISTENT-LICENSE' at offset 0")},
{"err - invalid license in allowed list", "MIT", []string{"NON-EXISTENT-LICENSE", "Apache-2.0"}, false,
errors.New("unknown license 'NON-EXISTENT-LICENSE' at offset 0")},

{"MIT satisfies [MIT, Apache-2.0]", "MIT", []string{"MIT", "Apache-2.0"}, true, nil},
{"MIT OR Apache-2.0 satisfies [MIT]", "MIT OR Apache-2.0", []string{"MIT"}, true, nil},
Expand Down
10 changes: 5 additions & 5 deletions spdxexp/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,11 +225,11 @@ func (exp *expressionStream) readLicense() *token {
// Generate a token using the normalized form of the license name.
//
// License name can be in the form:
// * a_license-2.0, a_license, a_license-ab - there is variability in the form of the base license. a_license-2.0 is used for these
// examples, but any base license form can have the suffixes described.
// * a_license-2.0-only - normalizes to a_license-2.0 if the -only form is not specifically in the set of licenses
// * a_license-2.0-or-later - normalizes to a_license-2.0+ if the -or-later form is not specifically in the set of licenses
// * a_license-2.0+ - normalizes to a_license-2.0-or-later if the -or-later form is specifically in the set of licenses
// - a_license-2.0, a_license, a_license-ab - there is variability in the form of the base license. a_license-2.0 is used for these
// examples, but any base license form can have the suffixes described.
// - a_license-2.0-only - normalizes to a_license-2.0 if the -only form is not specifically in the set of licenses
// - a_license-2.0-or-later - normalizes to a_license-2.0+ if the -or-later form is not specifically in the set of licenses
// - a_license-2.0+ - normalizes to a_license-2.0-or-later if the -or-later form is specifically in the set of licenses
func (exp *expressionStream) normalizeLicense(license string) *token {
if token := licenseLookup(license); token != nil {
// checks active and exception license lists
Expand Down

0 comments on commit 00d018e

Please sign in to comment.