Skip to content

Commit 271c9f4

Browse files
committed
support for multi-version compatibility for licenses
Example of multi-version license: * Apache-1.0+ * GPL-2.0-or-later
1 parent 6b5aa11 commit 271c9f4

8 files changed

+123
-84
lines changed

spdxexp/compare.go

+9
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package spdxexp
22

33
func compareGT(first *node, second *node) bool {
4+
if !first.isLicense() || !second.isLicense() {
5+
return false
6+
}
47
firstRange := getLicenseRange(*first.license())
58
secondRange := getLicenseRange(*second.license())
69

@@ -11,6 +14,9 @@ func compareGT(first *node, second *node) bool {
1114
}
1215

1316
func compareLT(first *node, second *node) bool {
17+
if !first.isLicense() || !second.isLicense() {
18+
return false
19+
}
1420
firstRange := getLicenseRange(*first.license())
1521
secondRange := getLicenseRange(*second.license())
1622

@@ -21,6 +27,9 @@ func compareLT(first *node, second *node) bool {
2127
}
2228

2329
func compareEQ(first *node, second *node) bool {
30+
if !first.isLicense() || !second.isLicense() {
31+
return false
32+
}
2433
firstRange := getLicenseRange(*first.license())
2534
secondRange := getLicenseRange(*second.license())
2635

spdxexp/compare_test.go

+4-4
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ func TestCompareGT(t *testing.T) {
1818
{"expect greater than: LPPL-1.3a > LPPL-1.0", getLicenseNode("LPPL-1.3a", false), getLicenseNode("LPPL-1.0", false), true},
1919
{"expect greater than: LPPL-1.3c > LPPL-1.3a", getLicenseNode("LPPL-1.3c", false), getLicenseNode("LPPL-1.3a", false), true},
2020
{"expect greater than: AGPL-3.0 > AGPL-1.0", getLicenseNode("AGPL-3.0", false), getLicenseNode("AGPL-1.0", false), true},
21-
{"expect greater than: GPL-2.0-or-later > GPL-2.0-only", getLicenseNode("GPL-2.0-or-later", true), getLicenseNode("GPL-2.0-only", false), true}, // TODO: Double check that -or-later and -only should be true for GT
22-
{"expect greater than: GPL-2.0-or-later > GPL-2.0", getLicenseNode("GPL-2.0-or-later", true), getLicenseNode("GPL-2.0", false), true},
21+
{"expect equal: GPL-2.0-or-later > GPL-2.0-only", getLicenseNode("GPL-2.0-or-later", true), getLicenseNode("GPL-2.0-only", false), false},
22+
{"expect equal: GPL-2.0-or-later > GPL-2.0", getLicenseNode("GPL-2.0-or-later", true), getLicenseNode("GPL-2.0", false), false},
2323
{"expect equal: GPL-3.0 > GPL-3.0", getLicenseNode("GPL-3.0", false), getLicenseNode("GPL-3.0", false), false},
2424
{"expect less than: MPL-1.0 > MPL-2.0", getLicenseNode("MPL-1.0", false), getLicenseNode("MPL-2.0", false), false},
2525
{"incompatible: MIT > ISC", getLicenseNode("MIT", false), getLicenseNode("ISC", false), false},
@@ -47,8 +47,8 @@ func TestCompareEQ(t *testing.T) {
4747
{"expect greater than: LPPL-1.3a == LPPL-1.0", getLicenseNode("LPPL-1.3a", false), getLicenseNode("LPPL-1.0", false), false},
4848
{"expect greater than: LPPL-1.3c == LPPL-1.3a", getLicenseNode("LPPL-1.3c", false), getLicenseNode("LPPL-1.3a", false), false},
4949
{"expect greater than: AGPL-3.0 == AGPL-1.0", getLicenseNode("AGPL-3.0", false), getLicenseNode("AGPL-1.0", false), false},
50-
{"expect greater than: GPL-2.0-or-later == GPL-2.0-only", getLicenseNode("GPL-2.0-or-later", true), getLicenseNode("GPL-2.0-only", false), false},
51-
{"expect greater than: GPL-2.0-or-later == GPL-2.0", getLicenseNode("GPL-2.0-or-later", true), getLicenseNode("GPL-2.0", false), false},
50+
{"expect equal: GPL-2.0-or-later > GPL-2.0-only", getLicenseNode("GPL-2.0-or-later", true), getLicenseNode("GPL-2.0-only", false), true},
51+
{"expect equal: GPL-2.0-or-later > GPL-2.0", getLicenseNode("GPL-2.0-or-later", true), getLicenseNode("GPL-2.0", false), true},
5252
{"expect equal: GPL-3.0 == GPL-3.0", getLicenseNode("GPL-3.0", false), getLicenseNode("GPL-3.0", false), true},
5353
{"expect less than: MPL-1.0 == MPL-2.0", getLicenseNode("MPL-1.0", false), getLicenseNode("MPL-2.0", false), false},
5454
{"incompatible: MIT == ISC", getLicenseNode("MIT", false), getLicenseNode("ISC", false), false},

spdxexp/license.go

+9-1
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,12 @@ type licenseRange struct {
4242

4343
// getLicenseRange returns a range of licenses from licenseRanges
4444
func getLicenseRange(id string) *licenseRange {
45+
simpleID := simplifyLicense(id)
4546
allRanges := licenseRanges()
4647
for i, licenseGrp := range allRanges {
4748
for j, versionGrp := range licenseGrp {
4849
for k, license := range versionGrp {
49-
if id == license {
50+
if simpleID == license {
5051
location := map[uint8]int{
5152
licenseGroup: i,
5253
versionGroup: j,
@@ -63,6 +64,13 @@ func getLicenseRange(id string) *licenseRange {
6364
return nil
6465
}
6566

67+
func simplifyLicense(id string) string {
68+
if strings.HasSuffix(id, "-or-later") {
69+
return id[0 : len(id)-9]
70+
}
71+
return id
72+
}
73+
6674
func getLicenses() []string {
6775
return []string{
6876
"0BSD",

spdxexp/node.go

+39-20
Original file line numberDiff line numberDiff line change
@@ -212,37 +212,56 @@ func (nodes *nodePair) licensesAreCompatible() bool {
212212
// Return true if two licenses are compatible in the context of their ranges; otherwise, false.
213213
func (nodes *nodePair) rangesAreCompatible() bool {
214214
if nodes.licensesExactlyEqual() {
215-
// licenses specify ranges exactly the same
215+
// licenses specify ranges exactly the same (e.g. Apache-1.0+, Apache-1.0+)
216216
return true
217217
}
218218

219-
firstLicense := *nodes.firstNode.license()
220-
secondLicense := *nodes.secondNode.license()
219+
firstNode := *nodes.firstNode
220+
secondNode := *nodes.secondNode
221221

222-
firstLicenseRange := getLicenseRange(firstLicense)
223-
secondLicenseRange := getLicenseRange(secondLicense)
222+
firstRange := getLicenseRange(*firstNode.license())
223+
secondRange := getLicenseRange(*secondNode.license())
224224

225-
return licenseInRange(firstLicense, secondLicenseRange.licenses) &&
226-
licenseInRange(secondLicense, firstLicenseRange.licenses)
225+
// When both licenses allow later versions (i.e. hasPlus==true), being in the same license
226+
// group is sufficient for compatibility, as long as, any exception is also compatible
227+
// Example: All Apache licenses (e.g. Apache-1.0, Apache-2.0) are in the same license group
228+
return sameLicenseGroup(firstRange, secondRange) && nodes.exceptionsAreCompatible()
227229
}
228230

229-
// Return true if license is found in licenseRange; otherwise, false
230-
func licenseInRange(simpleLicense string, licenseRange []string) bool {
231-
for _, testLicense := range licenseRange {
232-
if simpleLicense == testLicense {
233-
return true
234-
}
235-
}
236-
return false
237-
}
238-
239-
// Return true if the (first) simple license is in range of the (second) ranged license; otherwise, false.
231+
// identifierInRange returns true if the (first) simple license is in range of the (second)
232+
// ranged license; otherwise, false.
240233
func (nodes *nodePair) identifierInRange() bool {
241234
simpleLicense := nodes.firstNode
242235
plusLicense := nodes.secondNode
243236

244-
return compareGT(simpleLicense, plusLicense) ||
245-
compareEQ(simpleLicense, plusLicense)
237+
if !compareGT(simpleLicense, plusLicense) && !compareEQ(simpleLicense, plusLicense) {
238+
return false
239+
}
240+
241+
// With simpleLicense >= plusLicense, licenses are compatible, as long as, any exception
242+
// is also compatible
243+
return nodes.exceptionsAreCompatible()
244+
245+
}
246+
247+
// exceptionsAreCompatible returns true if neither license has an exception or they have
248+
// the same exception; otherwise, false
249+
func (nodes *nodePair) exceptionsAreCompatible() bool {
250+
firstNode := *nodes.firstNode
251+
secondNode := *nodes.secondNode
252+
253+
if !firstNode.hasException() && !secondNode.hasException() {
254+
// if neither has an exception, then licenses are compatible
255+
return true
256+
}
257+
258+
if firstNode.hasException() != secondNode.hasException() {
259+
// if one has and exception and the other does not, then the license are NOT compatible
260+
return false
261+
}
262+
263+
return *nodes.firstNode.exception() == *nodes.secondNode.exception()
264+
246265
}
247266

248267
// Return true if the licenses are the same; otherwise, false

spdxexp/node_test.go

+42-52
Original file line numberDiff line numberDiff line change
@@ -68,24 +68,40 @@ func TestLicensesAreCompatible(t *testing.T) {
6868
{"compatible (diff case equal): Apache-2.0, APACHE-2.0", &nodePair{
6969
getLicenseNode("Apache-2.0", false),
7070
getLicenseNode("APACHE-2.0", false)}, true},
71-
// {"compatible (same version with +): Apache-1.0+, Apache-1.0", &nodePair{
72-
// getLicenseNode("Apache-1.0+", true),
73-
// getLicenseNode("Apache-1.0", false)}, true},
74-
// {"compatible (later version with +): Apache-1.0+, Apache-2.0", &nodePair{
75-
// getLicenseNode("Apache-1.0+", true),
76-
// getLicenseNode("Apache-2.0", false)}, true},
77-
// {"compatible (same version with -or-later): GPL-2.0-or-later, GPL-2.0", &nodePair{
78-
// getLicenseNode("GPL-2.0-or-later", true),
79-
// getLicenseNode("GPL-2.0", false)}, true},
80-
// {"compatible (same version with -or-later and -only): GPL-2.0-or-later, GPL-2.0-only", &nodePair{
81-
// getLicenseNode("GPL-2.0-or-later", true),
82-
// getLicenseNode("GPL-2.0-only", false)}, true}, // TODO: Double check that -or-later and -only should be true for GT
83-
// {"compatible (later version with -or-later): GPL-2.0-or-later, GPL-3.0", &nodePair{
84-
// getLicenseNode("GPL-2.0-or-later", true),
85-
// getLicenseNode("GPL-3.0", false)}, true},
86-
// {"incompatible (different versions using -only): GPL-3.0-only, GPL-2.0-only", &nodePair{
87-
// getLicenseNode("GPL-3.0-only", false),
88-
// getLicenseNode("GPL-2.0-only", false)}, false},
71+
{"compatible (same version with +): Apache-1.0+, Apache-1.0", &nodePair{
72+
getLicenseNode("Apache-1.0", true),
73+
getLicenseNode("Apache-1.0", false)}, true},
74+
{"compatible (later version with +): Apache-1.0+, Apache-2.0", &nodePair{
75+
getLicenseNode("Apache-1.0", true),
76+
getLicenseNode("Apache-2.0", false)}, true},
77+
{"compatible (second version with +): Apache-2.0, Apache-1.0+", &nodePair{
78+
getLicenseNode("Apache-2.0", false),
79+
getLicenseNode("Apache-1.0", true)}, true},
80+
{"compatible (later version with both +): Apache-1.0+, Apache-2.0+", &nodePair{
81+
getLicenseNode("Apache-1.0", true),
82+
getLicenseNode("Apache-2.0", true)}, true},
83+
{"compatible (same version with -or-later): GPL-2.0-or-later, GPL-2.0", &nodePair{
84+
getLicenseNode("GPL-2.0-or-later", true),
85+
getLicenseNode("GPL-2.0", false)}, true},
86+
{"compatible (same version with -or-later and -only): GPL-2.0-or-later, GPL-2.0-only", &nodePair{
87+
getLicenseNode("GPL-2.0-or-later", true),
88+
getLicenseNode("GPL-2.0-only", false)}, true}, // TODO: Double check that -or-later and -only should be true for GT
89+
{"compatible (later version with -or-later): GPL-2.0-or-later, GPL-3.0", &nodePair{
90+
getLicenseNode("GPL-2.0-or-later", true),
91+
getLicenseNode("GPL-3.0", false)}, true},
92+
{"incompatible (same version with -or-later exception): GPL-2.0, GPL-2.0-or-later WITH Bison-exception-2.2", &nodePair{
93+
getLicenseNode("GPL-2.0", true),
94+
&node{
95+
role: licenseNode,
96+
exp: nil,
97+
lic: &licenseNodePartial{
98+
license: "GPL-2.0", hasPlus: true,
99+
hasException: true, exception: "Bison-exception-2.2"},
100+
ref: nil,
101+
}}, false},
102+
{"incompatible (different versions using -only): GPL-3.0-only, GPL-2.0-only", &nodePair{
103+
getLicenseNode("GPL-3.0-only", false),
104+
getLicenseNode("GPL-2.0-only", false)}, false},
89105
{"incompatible (different versions with letter): LPPL-1.3c, LPPL-1.3a", &nodePair{
90106
getLicenseNode("LPPL-1.3c", false),
91107
getLicenseNode("LPPL-1.3a", false)}, false},
@@ -99,8 +115,8 @@ func TestLicensesAreCompatible(t *testing.T) {
99115
getLicenseNode("MIT", false),
100116
getLicenseNode("ISC", false)}, false},
101117
{"not simple license: (MIT OR ISC), GPL-3.0", &nodePair{
102-
getLicenseNode("(MIT OR ISC)", false),
103-
getLicenseNode("GPL-3.0", false)}, false}, // TODO: should it raise error?
118+
getParsedNode("(MIT OR ISC)"),
119+
getLicenseNode("GPL-3.0", false)}, false},
104120
}
105121

106122
for _, test := range tests {
@@ -120,9 +136,9 @@ func TestRangesAreCompatible(t *testing.T) {
120136
{"compatible - both use -or-later", &nodePair{
121137
firstNode: getLicenseNode("GPL-1.0-or-later", true),
122138
secondNode: getLicenseNode("GPL-2.0-or-later", true)}, true},
123-
// {"compatible - both use +", &nodePair{ // TODO: fails here and in js, but passes js satisfies
124-
// firstNode: getLicenseNode("Apache-1.0", true),
125-
// secondNode: getLicenseNode("Apache-2.0", true)}, true},
139+
{"compatible - both use +", &nodePair{
140+
firstNode: getLicenseNode("Apache-1.0", true),
141+
secondNode: getLicenseNode("Apache-2.0", true)}, true},
126142
{"not compatible", &nodePair{
127143
firstNode: getLicenseNode("GPL-1.0-or-later", true),
128144
secondNode: getLicenseNode("LGPL-3.0-or-later", true)}, false},
@@ -136,32 +152,6 @@ func TestRangesAreCompatible(t *testing.T) {
136152
}
137153
}
138154

139-
func TestLicenseInRange(t *testing.T) {
140-
tests := []struct {
141-
name string
142-
license string
143-
licenseRange []string
144-
result bool
145-
}{
146-
{"in range", "GPL-3.0", []string{
147-
"GPL-1.0-or-later",
148-
"GPL-2.0-or-later",
149-
"GPL-3.0",
150-
"GPL-3.0-only",
151-
"GPL-3.0-or-later"}, true},
152-
{"not in range", "GPL-3.0", []string{
153-
"GPL-2.0",
154-
"GPL-2.0-only"}, false},
155-
}
156-
157-
for _, test := range tests {
158-
test := test
159-
t.Run(test.name, func(t *testing.T) {
160-
assert.Equal(t, test.result, licenseInRange(test.license, test.licenseRange))
161-
})
162-
}
163-
}
164-
165155
func TestIdentifierInRange(t *testing.T) {
166156
tests := []struct {
167157
name string
@@ -173,10 +163,10 @@ func TestIdentifierInRange(t *testing.T) {
173163
secondNode: getLicenseNode("GPL-2.0-or-later", true)}, true},
174164
{"in or-later range (same)", &nodePair{
175165
firstNode: getLicenseNode("GPL-2.0", false),
176-
secondNode: getLicenseNode("GPL-2.0-or-later", true)}, false}, // TODO: why doesn't this
177-
{"in + range", &nodePair{
166+
secondNode: getLicenseNode("GPL-2.0-or-later", true)}, true},
167+
{"in + range (1.0+)", &nodePair{
178168
firstNode: getLicenseNode("Apache-2.0", false),
179-
secondNode: getLicenseNode("Apache-1.0+", true)}, false}, // TODO: think this doesn't match because Apache doesn't have any -or-later
169+
secondNode: getLicenseNode("Apache-1.0", true)}, true},
180170
{"not in range", &nodePair{
181171
firstNode: getLicenseNode("GPL-1.0", false),
182172
secondNode: getLicenseNode("GPL-2.0-or-later", true)}, false},

spdxexp/satisfies_test.go

+7-6
Original file line numberDiff line numberDiff line change
@@ -43,18 +43,19 @@ func TestSatisfies(t *testing.T) {
4343

4444
{"MIT AND Apache-2.0 satisfies [MIT, Apache-1.0, Apache-2.0]", "MIT AND Apache-2.0", []string{"MIT", "Apache-1.0", "Apache-2.0"}, true, nil},
4545

46-
// {"Apache-1.0+ satisfies [Apache-2.0+]", "Apache-1.0+", []string{"Apache-2.0+"}, true, nil}, // TODO: Fails here but passes js
46+
{"Apache-1.0+ satisfies [Apache-2.0]", "Apache-1.0+", []string{"Apache-2.0"}, true, nil},
47+
{"Apache-1.0+ satisfies [Apache-2.0+]", "Apache-1.0+", []string{"Apache-2.0+"}, true, nil}, // TODO: Fails here but passes js
4748
{"! Apache-1.0 satisfies [Apache-2.0+]", "Apache-1.0", []string{"Apache-2.0+"}, false, nil},
4849
{"Apache-2.0 satisfies [Apache-2.0+]", "Apache-2.0", []string{"Apache-2.0+"}, true, nil},
49-
// {"Apache-3.0 satisfies [Apache-2.0+]", "Apache-3.0", []string{"Apache-2.0+"}, true, nil}, // TODO: gets error b/c Apache-3.0 doesn't exist -- need better error message
50+
{"! Apache-3.0 satisfies [Apache-2.0+]", "Apache-3.0", []string{"Apache-2.0+"}, false, errors.New("unknown license 'Apache-3.0' at offset 0")},
5051

5152
{"! Apache-1.0 satisfies [Apache-2.0-or-later]", "Apache-1.0", []string{"Apache-2.0-or-later"}, false, nil},
5253
{"Apache-2.0 satisfies [Apache-2.0-or-later]", "Apache-2.0", []string{"Apache-2.0-or-later"}, true, nil},
53-
// {"Apache-3.0 satisfies [Apache-2.0-or-later]", "Apache-3.0", []string{"Apache-2.0-or-later"}, true, nil},
54+
{"! Apache-3.0 satisfies [Apache-2.0-or-later]", "Apache-3.0", []string{"Apache-2.0-or-later"}, false, errors.New("unknown license 'Apache-3.0' at offset 0")},
5455

5556
{"! Apache-1.0 satisfies [Apache-2.0-only]", "Apache-1.0", []string{"Apache-2.0-only"}, false, nil},
5657
{"Apache-2.0 satisfies [Apache-2.0-only]", "Apache-2.0", []string{"Apache-2.0-only"}, true, nil},
57-
// {"Apache-3.0 satisfies [Apache-2.0-only]", "Apache-3.0", []string{"Apache-2.0-only"}, false, nil},
58+
{"! Apache-3.0 satisfies [Apache-2.0-only]", "Apache-3.0", []string{"Apache-2.0-only"}, false, errors.New("unknown license 'Apache-3.0' at offset 0")},
5859

5960
// regression tests from spdx-satisfies.js - assert statements in README
6061
// TODO: Commented out tests are not yet supported.
@@ -63,8 +64,8 @@ func TestSatisfies(t *testing.T) {
6364
{"MIT satisfies [ISC, MIT]", "MIT", []string{"ISC", "MIT"}, true, nil},
6465
{"Zlib satisfies [ISC, MIT, Zlib]", "Zlib", []string{"ISC", "MIT", "Zlib"}, true, nil},
6566
{"! GPL-3.0 satisfies [ISC, MIT]", "GPL-3.0", []string{"ISC", "MIT"}, false, nil},
66-
// {"GPL-2.0 satisfies [GPL-2.0+]", "GPL-2.0", []string{"GPL-2.0+"}, true, nil}, // TODO: Fails here but passes js
67-
// {"GPL-2.0 satisfies [GPL-2.0-or-later]", "GPL-2.0", []string{"GPL-2.0-or-later"}, true, nil}, // TODO: Fails here and js
67+
{"GPL-2.0 satisfies [GPL-2.0+]", "GPL-2.0", []string{"GPL-2.0+"}, true, nil}, // TODO: Fails here but passes js
68+
{"GPL-2.0 satisfies [GPL-2.0-or-later]", "GPL-2.0", []string{"GPL-2.0-or-later"}, true, nil}, // TODO: Fails here and js
6869
{"GPL-3.0 satisfies [GPL-2.0+]", "GPL-3.0", []string{"GPL-2.0+"}, true, nil},
6970
{"GPL-1.0-or-later satisfies [GPL-2.0-or-later]", "GPL-1.0-or-later", []string{"GPL-2.0-or-later"}, true, nil},
7071
{"GPL-1.0+ satisfies [GPL-2.0+]", "GPL-1.0+", []string{"GPL-2.0+"}, true, nil},

spdxexp/scan.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ func (exp *expressionStream) normalizeLicense(license string) *token {
234234
if token := licenseLookup(license); token != nil {
235235
// checks active and exception license lists
236236
// deprecated list is checked at the end to avoid a deprecated license being used for +
237-
// (example: GPL-1.0 is on the depcated list, but GPL-1.0+ should become GPL-1.0-or-later)
237+
// (example: GPL-1.0 is on the deprecated list, but GPL-1.0+ should become GPL-1.0-or-later)
238238
return token
239239
}
240240

spdxexp/test_helper.go

+12
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
package spdxexp
22

3+
// getLicenseNode is a test helper method that is expected to create a valid
4+
// license node. Use this function when the test data is known to be a valid
5+
// license that would parse successfully.
36
func getLicenseNode(license string, hasPlus bool) *node {
47
return &node{
58
role: licenseNode,
@@ -13,3 +16,12 @@ func getLicenseNode(license string, hasPlus bool) *node {
1316
ref: nil,
1417
}
1518
}
19+
20+
// getParsedNode is a test helper method that is expected to create a valid node
21+
// and swallow errors. This allows test structures to use parsed node data.
22+
// Use this function when the test data is expected to parse successfully.
23+
func getParsedNode(expression string) *node {
24+
// swallows errors
25+
n, _ := parse(expression)
26+
return n
27+
}

0 commit comments

Comments
 (0)