diff --git a/spdxexp/compare.go b/spdxexp/compare.go new file mode 100644 index 0000000..d4f201a --- /dev/null +++ b/spdxexp/compare.go @@ -0,0 +1,38 @@ +package spdxexp + +func compareGT(first *Node, second *Node) bool { + firstRange := GetLicenseRange(*first.License()) + secondRange := GetLicenseRange(*second.License()) + + if !sameLicenseGroup(firstRange, secondRange) { + return false + } + return firstRange.Location[VERSION_GROUP] > secondRange.Location[VERSION_GROUP] +} + +func compareLT(first *Node, second *Node) bool { + firstRange := GetLicenseRange(*first.License()) + secondRange := GetLicenseRange(*second.License()) + + if !sameLicenseGroup(firstRange, secondRange) { + return false + } + return firstRange.Location[VERSION_GROUP] < secondRange.Location[VERSION_GROUP] +} + +func compareEQ(first *Node, second *Node) bool { + firstRange := GetLicenseRange(*first.License()) + secondRange := GetLicenseRange(*second.License()) + + if !sameLicenseGroup(firstRange, secondRange) { + return false + } + return firstRange.Location[VERSION_GROUP] == secondRange.Location[VERSION_GROUP] +} + +func sameLicenseGroup(firstRange *LicenseRange, secondRange *LicenseRange) bool { + if firstRange == nil || secondRange == nil || firstRange.Location[LICENSE_GROUP] != secondRange.Location[LICENSE_GROUP] { + return false + } + return true +} diff --git a/spdxexp/compare_test.go b/spdxexp/compare_test.go new file mode 100644 index 0000000..5484525 --- /dev/null +++ b/spdxexp/compare_test.go @@ -0,0 +1,94 @@ +package spdxexp + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCompareGT(t *testing.T) { + tests := []struct { + name string + first *Node + second *Node + result bool + }{ + {"expect greater than: GPL-3.0 > GPL-2.0", getLicenseNode("GPL-3.0", false), getLicenseNode("GPL-2.0", false), true}, + {"expect greater than: GPL-3.0-only > GPL-2.0-only", getLicenseNode("GPL-3.0-only", false), getLicenseNode("GPL-2.0-only", false), true}, + {"expect greater than: LPPL-1.3a > LPPL-1.0", getLicenseNode("LPPL-1.3a", false), getLicenseNode("LPPL-1.0", false), true}, + {"expect greater than: LPPL-1.3c > LPPL-1.3a", getLicenseNode("LPPL-1.3c", false), getLicenseNode("LPPL-1.3a", false), true}, + {"expect greater than: AGPL-3.0 > AGPL-1.0", getLicenseNode("AGPL-3.0", false), getLicenseNode("AGPL-1.0", false), true}, + {"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 + {"expect greater than: GPL-2.0-or-later > GPL-2.0", getLicenseNode("GPL-2.0-or-later", true), getLicenseNode("GPL-2.0", false), true}, + {"expect equal: GPL-3.0 > GPL-3.0", getLicenseNode("GPL-3.0", false), getLicenseNode("GPL-3.0", false), false}, + {"expect less than: MPL-1.0 > MPL-2.0", getLicenseNode("MPL-1.0", false), getLicenseNode("MPL-2.0", false), false}, + {"incompatible: MIT > ISC", getLicenseNode("MIT", false), getLicenseNode("ISC", false), false}, + {"incompatible: OSL-1.0 > OPL-1.0", getLicenseNode("OSL-1.0", false), getLicenseNode("OPL-1.0", false), false}, + {"not simple license: (MIT OR ISC) > GPL-3.0", getLicenseNode("(MIT OR ISC)", false), getLicenseNode("GPL-3.0", false), false}, // TODO: should it raise error? + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.result, compareGT(test.first, test.second)) + }) + } +} + +func TestCompareEQ(t *testing.T) { + tests := []struct { + name string + first *Node + second *Node + result bool + }{ + {"expect greater than: GPL-3.0 == GPL-2.0", getLicenseNode("GPL-3.0", false), getLicenseNode("GPL-2.0", false), false}, + {"expect greater than: GPL-3.0-only == GPL-2.0-only", getLicenseNode("GPL-3.0-only", false), getLicenseNode("GPL-2.0-only", false), false}, + {"expect greater than: LPPL-1.3a == LPPL-1.0", getLicenseNode("LPPL-1.3a", false), getLicenseNode("LPPL-1.0", false), false}, + {"expect greater than: LPPL-1.3c == LPPL-1.3a", getLicenseNode("LPPL-1.3c", false), getLicenseNode("LPPL-1.3a", false), false}, + {"expect greater than: AGPL-3.0 == AGPL-1.0", getLicenseNode("AGPL-3.0", false), getLicenseNode("AGPL-1.0", false), false}, + {"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}, + {"expect greater than: GPL-2.0-or-later == GPL-2.0", getLicenseNode("GPL-2.0-or-later", true), getLicenseNode("GPL-2.0", false), false}, + {"expect equal: GPL-3.0 == GPL-3.0", getLicenseNode("GPL-3.0", false), getLicenseNode("GPL-3.0", false), true}, + {"expect less than: MPL-1.0 == MPL-2.0", getLicenseNode("MPL-1.0", false), getLicenseNode("MPL-2.0", false), false}, + {"incompatible: MIT == ISC", getLicenseNode("MIT", false), getLicenseNode("ISC", false), false}, + {"incompatible: OSL-1.0 == OPL-1.0", getLicenseNode("OSL-1.0", false), getLicenseNode("OPL-1.0", false), false}, + {"not simple license: (MIT OR ISC) == GPL-3.0", getLicenseNode("(MIT OR ISC)", false), getLicenseNode("GPL-3.0", false), false}, // TODO: should it raise error? + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.result, compareEQ(test.first, test.second)) + }) + } +} + +func TestCompareLT(t *testing.T) { + tests := []struct { + name string + first *Node + second *Node + result bool + }{ + {"expect greater than: GPL-3.0 < GPL-2.0", getLicenseNode("GPL-3.0", false), getLicenseNode("GPL-2.0", false), false}, + {"expect greater than: GPL-3.0-only < GPL-2.0-only", getLicenseNode("GPL-3.0-only", false), getLicenseNode("GPL-2.0-only", false), false}, + {"expect greater than: LPPL-1.3a < LPPL-1.0", getLicenseNode("LPPL-1.3a", false), getLicenseNode("LPPL-1.0", false), false}, + {"expect greater than: LPPL-1.3c < LPPL-1.3a", getLicenseNode("LPPL-1.3c", false), getLicenseNode("LPPL-1.3a", false), false}, + {"expect greater than: AGPL-3.0 < AGPL-1.0", getLicenseNode("AGPL-3.0", false), getLicenseNode("AGPL-1.0", false), false}, + {"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}, + {"expect greater than: GPL-2.0-or-later == GPL-2.0", getLicenseNode("GPL-2.0-or-later", true), getLicenseNode("GPL-2.0", false), false}, + {"expect equal: GPL-3.0 < GPL-3.0", getLicenseNode("GPL-3.0", false), getLicenseNode("GPL-3.0", false), false}, + {"expect less than: MPL-1.0 < MPL-2.0", getLicenseNode("MPL-1.0", false), getLicenseNode("MPL-2.0", false), true}, + {"incompatible: MIT < ISC", getLicenseNode("MIT", false), getLicenseNode("ISC", false), false}, + {"incompatible: OSL-1.0 < OPL-1.0", getLicenseNode("OSL-1.0", false), getLicenseNode("OPL-1.0", false), false}, + {"not simple license: (MIT OR ISC) < GPL-3.0", getLicenseNode("(MIT OR ISC)", false), getLicenseNode("GPL-3.0", false), false}, // TODO: should it raise error? + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.result, compareLT(test.first, test.second)) + }) + } +} diff --git a/spdxexp/license.go b/spdxexp/license.go index e40a998..70274f5 100644 --- a/spdxexp/license.go +++ b/spdxexp/license.go @@ -26,18 +26,37 @@ func inLicenseList(licenses []string, id string) bool { return false } -func LicenseRange(id string) []string { +const ( + LICENSE_GROUP uint8 = iota + VERSION_GROUP + LICENSE_INDEX +) + +type LicenseRange struct { + Licenses []string + Location map[uint8]int +} + +func GetLicenseRange(id string) *LicenseRange { allRanges := licenseRanges() - for _, licenseGroup := range allRanges { - for _, versionGroup := range licenseGroup { - for _, license := range versionGroup { + for lg, licenseGroup := range allRanges { + for vg, versionGroup := range licenseGroup { + for li, license := range versionGroup { if id == license { - return versionGroup + location := map[uint8]int{ + LICENSE_GROUP: lg, + VERSION_GROUP: vg, + LICENSE_INDEX: li, + } + return &LicenseRange{ + Licenses: versionGroup, + Location: location, + } } } } } - return []string{id} + return nil } func getLicenses() []string { @@ -570,9 +589,17 @@ func licenseRanges() [][][]string { { { "AFL-1.1", + }, + { "AFL-1.2", + }, + { "AFL-2.0", + }, + { "AFL-2.1", + }, + { "AFL-3.0", }, }, @@ -588,118 +615,194 @@ func licenseRanges() [][][]string { { { "Apache-1.0", + }, + { "Apache-1.1", + }, + { "Apache-2.0", }, }, { { "APSL-1.0", + }, + { "APSL-1.1", + }, + { "APSL-1.2", + }, + { "APSL-2.0", }, }, { { "Artistic-1.0", + }, + { "Artistic-2.0", }, }, { { "BitTorrent-1.0", + }, + { "BitTorrent-1.1", }, }, { { "CC-BY-1.0", + }, + { "CC-BY-2.0", + }, + { "CC-BY-2.5", + }, + { "CC-BY-3.0", + }, + { "CC-BY-4.0", }, }, { { "CC-BY-NC-1.0", + }, + { "CC-BY-NC-2.0", + }, + { "CC-BY-NC-2.5", + }, + { "CC-BY-NC-3.0", + }, + { "CC-BY-NC-4.0", }, }, { { "CC-BY-NC-ND-1.0", + }, + { "CC-BY-NC-ND-2.0", + }, + { "CC-BY-NC-ND-2.5", + }, + { "CC-BY-NC-ND-3.0", + }, + { "CC-BY-NC-ND-4.0", }, }, { { "CC-BY-NC-SA-1.0", + }, + { "CC-BY-NC-SA-2.0", + }, + { "CC-BY-NC-SA-2.5", + }, + { "CC-BY-NC-SA-3.0", + }, + { "CC-BY-NC-SA-4.0", }, }, { { "CC-BY-ND-1.0", + }, + { "CC-BY-ND-2.0", + }, + { "CC-BY-ND-2.5", + }, + { "CC-BY-ND-3.0", + }, + { "CC-BY-ND-4.0", }, }, { { "CC-BY-SA-1.0", + }, + { "CC-BY-SA-2.0", + }, + { "CC-BY-SA-2.5", + }, + { "CC-BY-SA-3.0", + }, + { "CC-BY-SA-4.0", }, }, { { "CDDL-1.0", + }, + { "CDDL-1.1", }, }, { { "CECILL-1.0", + }, + { "CECILL-1.1", + }, + { "CECILL-2.0", }, }, { { "ECL-1.0", + }, + { "ECL-2.0", }, }, { { "EFL-1.0", + }, + { "EFL-2.0", }, }, { { "EPL-1.0", + }, + { "EPL-2.0", }, }, { { "EUPL-1.0", + }, + { "EUPL-1.1", }, }, @@ -757,114 +860,192 @@ func licenseRanges() [][][]string { { { "LPL-1.0", + }, + { "LPL-1.02", }, }, { { "LPPL-1.0", + }, + { "LPPL-1.1", + }, + { "LPPL-1.2", + }, + { "LPPL-1.3a", + }, + { "LPPL-1.3c", }, }, { { "MPL-1.0", + }, + { "MPL-1.1", + }, + { "MPL-2.0", }, }, { { "MPL-1.0", + }, + { "MPL-1.1", + }, + { "MPL-2.0-no-copyleft-exception", }, }, { { "NPL-1.0", + }, + { "NPL-1.1", }, }, { { "OFL-1.0", + }, + { "OFL-1.1", }, }, { { "OLDAP-1.1", + }, + { "OLDAP-1.2", + }, + { "OLDAP-1.3", + }, + { "OLDAP-1.4", + }, + { "OLDAP-2.0", + }, + { "OLDAP-2.0.1", + }, + { "OLDAP-2.1", + }, + { "OLDAP-2.2", + }, + { "OLDAP-2.2.1", + }, + { "OLDAP-2.2.2", + }, + { "OLDAP-2.3", + }, + { "OLDAP-2.4", + }, + { "OLDAP-2.5", + }, + { "OLDAP-2.6", + }, + { "OLDAP-2.7", + }, + { "OLDAP-2.8", }, }, { { "OSL-1.0", + }, + { "OSL-1.1", + }, + { "OSL-2.0", + }, + { "OSL-2.1", + }, + { "OSL-3.0", }, }, { { "PHP-3.0", + }, + { "PHP-3.01", }, }, { { "RPL-1.1", + }, + { "RPL-1.5", }, }, { { "SGI-B-1.0", + }, + { "SGI-B-1.1", + }, + { "SGI-B-2.0", }, }, { { "YPL-1.0", + }, + { "YPL-1.1", }, }, { { "ZPL-1.1", + }, + { "ZPL-2.0", + }, + { "ZPL-2.1", }, }, { { "Zimbra-1.3", + }, + { "Zimbra-1.4", }, }, { { "bzip2-1.0.5", + }, + { "bzip2-1.0.6", }, }, diff --git a/spdxexp/license_test.go b/spdxexp/license_test.go index f32bef3..9974765 100644 --- a/spdxexp/license_test.go +++ b/spdxexp/license_test.go @@ -63,24 +63,26 @@ func TestExceptionLicense(t *testing.T) { } } -func TestLicenseRange(t *testing.T) { +func TestGetLicenseRange(t *testing.T) { tests := []struct { - name string - id string - result []string + name string + id string + licenseRange *LicenseRange }{ - {"single range", "Apache-2.0", - []string{"Apache-1.0", "Apache-1.1", "Apache-2.0"}}, - {"multiple ranges", "GFDL-1.2-only", - []string{"GFDL-1.2", "GFDL-1.2-only"}}, - {"no range", "Bison-exception-2.2", - []string{"Bison-exception-2.2"}}, // TODO: should this return empty array? + {"no multi-element ranges", "Apache-2.0", &LicenseRange{ + Licenses: []string{"Apache-2.0"}, + Location: map[uint8]int{LICENSE_GROUP: 2, VERSION_GROUP: 2, LICENSE_INDEX: 0}}}, + {"multi-element ranges", "GFDL-1.2-only", &LicenseRange{ + Licenses: []string{"GFDL-1.2", "GFDL-1.2-only"}, + Location: map[uint8]int{LICENSE_GROUP: 18, VERSION_GROUP: 1, LICENSE_INDEX: 1}}}, + {"no range", "Bison-exception-2.2", nil}, } for _, test := range tests { test := test t.Run(test.name, func(t *testing.T) { - assert.Equal(t, test.result, LicenseRange(test.id)) + licenseRange := GetLicenseRange(test.id) + assert.Equal(t, test.licenseRange, licenseRange) }) } } diff --git a/spdxexp/node.go b/spdxexp/node.go index eb6f2ce..a945a7d 100644 --- a/spdxexp/node.go +++ b/spdxexp/node.go @@ -1,12 +1,12 @@ package spdxexp -type nodeRole int64 - type NodePair struct { firstNode *Node secondNode *Node } +type nodeRole uint8 + const ( EXPRESSION_NODE nodeRole = iota LICENSEREF_NODE @@ -126,7 +126,7 @@ func (node *Node) HasDocumentRef() bool { // ---------------------- Comparator Methods ---------------------- // Return true if two licenses are compatible; otherwise, false. -func (nodes *NodePair) licensesAreCompatible() bool { +func (nodes *NodePair) LicensesAreCompatible() bool { if !nodes.firstNode.IsLicense() || !nodes.secondNode.IsLicense() { return false } @@ -150,10 +150,6 @@ func (nodes *NodePair) licensesAreCompatible() bool { } } -func (nodes *NodePair) licensesExactlyEqual() bool { - return *nodes.firstNode.License() == *nodes.secondNode.License() -} - // Return true if two licenses are compatible in the context of their ranges; otherwise, false. func (nodes *NodePair) rangesAreCompatible() bool { if nodes.licensesExactlyEqual() { @@ -161,28 +157,14 @@ func (nodes *NodePair) rangesAreCompatible() bool { return true } - firstLicenseRange := LicenseRange(*nodes.firstNode.License()) - secondLicenseRange := LicenseRange(*nodes.secondNode.License()) - - return licenseInRange(*nodes.firstNode.License(), secondLicenseRange) && - licenseInRange(*nodes.secondNode.License(), firstLicenseRange) -} - -// Return true if the (first) simple license is in range of the (second) ranged license; otherwise, false. -func (nodes *NodePair) identifierInRange() bool { - if nodes.licensesExactlyEqual() { - // licenses specify ranges exactly the same - return true - } - - // STUBBED - return false + firstLicense := *nodes.firstNode.License() + secondLicense := *nodes.secondNode.License() - // simpleLicense := nodes.firstNode.License() - // plusLicense := nodes.secondNode.License() + firstLicenseRange := GetLicenseRange(firstLicense) + secondLicenseRange := GetLicenseRange(secondLicense) - // return CompareGT(simpleLicense, plusLicense) || - // CompareEQ(simpleLicense, plusLicense) + return licenseInRange(firstLicense, secondLicenseRange.Licenses) && + licenseInRange(secondLicense, firstLicenseRange.Licenses) } // Return true if license is found in licenseRange; otherwise, false @@ -194,3 +176,17 @@ func licenseInRange(simpleLicense string, licenseRange []string) bool { } return false } + +// Return true if the (first) simple license is in range of the (second) ranged license; otherwise, false. +func (nodes *NodePair) identifierInRange() bool { + simpleLicense := nodes.firstNode + plusLicense := nodes.secondNode + + return compareGT(simpleLicense, plusLicense) || + compareEQ(simpleLicense, plusLicense) +} + +// Return true if the licenses are the same; otherwise, false +func (nodes *NodePair) licensesExactlyEqual() bool { + return *nodes.firstNode.License() == *nodes.secondNode.License() +} diff --git a/spdxexp/node_test.go b/spdxexp/node_test.go new file mode 100644 index 0000000..2b07e6a --- /dev/null +++ b/spdxexp/node_test.go @@ -0,0 +1,140 @@ +package spdxexp + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLicensesAreCompatible(t *testing.T) { + tests := []struct { + name string + first *Node + second *Node + result bool + }{ + {"expect greater than: GPL-3.0 > GPL-2.0", getLicenseNode("GPL-3.0", false), getLicenseNode("GPL-2.0", false), true}, + {"expect greater than: GPL-3.0-only > GPL-2.0-only", getLicenseNode("GPL-3.0-only", false), getLicenseNode("GPL-2.0-only", false), true}, + {"expect greater than: LPPL-1.3a > LPPL-1.0", getLicenseNode("LPPL-1.3a", false), getLicenseNode("LPPL-1.0", false), true}, + {"expect greater than: LPPL-1.3c > LPPL-1.3a", getLicenseNode("LPPL-1.3c", false), getLicenseNode("LPPL-1.3a", false), true}, + {"expect greater than: AGPL-3.0 > AGPL-1.0", getLicenseNode("AGPL-3.0", false), getLicenseNode("AGPL-1.0", false), true}, + {"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 + {"expect greater than: GPL-2.0-or-later > GPL-2.0", getLicenseNode("GPL-2.0-or-later", true), getLicenseNode("GPL-2.0", false), true}, + {"expect equal: GPL-3.0 > GPL-3.0", getLicenseNode("GPL-3.0", false), getLicenseNode("GPL-3.0", false), false}, + {"expect less than: MPL-1.0 > MPL-2.0", getLicenseNode("MPL-1.0", false), getLicenseNode("MPL-2.0", false), false}, + {"incompatible: MIT > ISC", getLicenseNode("MIT", false), getLicenseNode("ISC", false), false}, + {"incompatible: OSL-1.0 > OPL-1.0", getLicenseNode("OSL-1.0", false), getLicenseNode("OPL-1.0", false), false}, + {"not simple license: (MIT OR ISC) > GPL-3.0", getLicenseNode("(MIT OR ISC)", false), getLicenseNode("GPL-3.0", false), false}, // TODO: should it raise error? + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.result, compareGT(test.first, test.second)) + }) + } +} + +func TestRangesAreCompatible(t *testing.T) { + tests := []struct { + name string + nodes *NodePair + result bool + }{ + {"compatible - both use -or-later", &NodePair{ + firstNode: getLicenseNode("GPL-1.0-or-later", true), + secondNode: getLicenseNode("GPL-2.0-or-later", true)}, true}, + // {"compatible - both use +", &NodePair{ // TODO: fails here and in js, but passes js satisfies + // firstNode: getLicenseNode("Apache-1.0", true), + // secondNode: getLicenseNode("Apache-2.0", true)}, true}, + {"not compatible", &NodePair{ + firstNode: getLicenseNode("GPL-1.0-or-later", true), + secondNode: getLicenseNode("LGPL-3.0-or-later", true)}, false}, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.result, test.nodes.rangesAreCompatible()) + }) + } +} + +func TestLicenseInRange(t *testing.T) { + tests := []struct { + name string + license string + licenseRange []string + result bool + }{ + {"in range", "GPL-3.0", []string{ + "GPL-1.0-or-later", + "GPL-2.0-or-later", + "GPL-3.0", + "GPL-3.0-only", + "GPL-3.0-or-later"}, true}, + {"not in range", "GPL-3.0", []string{ + "GPL-2.0", + "GPL-2.0-only"}, false}, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.result, licenseInRange(test.license, test.licenseRange)) + }) + } +} + +func TestIdentifierInRange(t *testing.T) { + tests := []struct { + name string + nodes *NodePair + result bool + }{ + {"in or-later range (later)", &NodePair{ + firstNode: getLicenseNode("GPL-3.0", false), + secondNode: getLicenseNode("GPL-2.0-or-later", true)}, true}, + {"in or-later range (same)", &NodePair{ + firstNode: getLicenseNode("GPL-2.0", false), + secondNode: getLicenseNode("GPL-2.0-or-later", true)}, false}, // TODO: why doesn't this + {"in + range", &NodePair{ + firstNode: getLicenseNode("Apache-2.0", false), + secondNode: getLicenseNode("Apache-1.0+", true)}, false}, // TODO: think this doesn't match because Apache doesn't have any -or-later + {"not in range", &NodePair{ + firstNode: getLicenseNode("GPL-1.0", false), + secondNode: getLicenseNode("GPL-2.0-or-later", true)}, false}, + {"different base license", &NodePair{ + firstNode: getLicenseNode("GPL-1.0", false), + secondNode: getLicenseNode("LGPL-2.0-or-later", true)}, false}, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.result, test.nodes.identifierInRange()) + }) + } +} + +func TestLicensesExactlyEqual(t *testing.T) { + tests := []struct { + name string + nodes *NodePair + result bool + }{ + {"equal", &NodePair{ + firstNode: getLicenseNode("GPL-2.0", false), + secondNode: getLicenseNode("GPL-2.0", false)}, true}, + {"not equal", &NodePair{ + firstNode: getLicenseNode("GPL-1.0", false), + secondNode: getLicenseNode("GPL-2.0", false)}, false}, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.result, test.nodes.licensesExactlyEqual()) + }) + } +} diff --git a/spdxexp/parse.go b/spdxexp/parse.go index 46271fb..e4a805a 100644 --- a/spdxexp/parse.go +++ b/spdxexp/parse.go @@ -238,6 +238,11 @@ func (t *tokenStream) parseLicense() *Node { hasException: false, exception: ""} + // for licenses that specifically support -or-later, a `+` operator token isn't expected to be present + if strings.HasSuffix(token.value, "-or-later") { + lic.hasPlus = true + } + if t.hasMore() { // use new var idx to avoid creating a new var index operator := t.parseOperator("+") @@ -245,14 +250,16 @@ func (t *tokenStream) parseLicense() *Node { lic.hasPlus = true } - exception := t.parseWith() - if t.err != nil { - return nil - } - if exception != nil { - lic.hasException = true - lic.exception = *exception - t.next() + if t.hasMore() { + exception := t.parseWith() + if t.err != nil { + return nil + } + if exception != nil { + lic.hasException = true + lic.exception = *exception + t.next() + } } } diff --git a/spdxexp/satisfies.go b/spdxexp/satisfies.go new file mode 100644 index 0000000..0ef7466 --- /dev/null +++ b/spdxexp/satisfies.go @@ -0,0 +1,126 @@ +package spdxexp + +// "fmt" + +// func licenseString (e spdx.Expression) { +// if (e.hasOwnProperty("noassertion")) return "NOASSERTION" +// if (e.license) return `${e.license}${e.plus ? "+" : ""}${e.exception ? ` WITH ${e.exception}` : ""}` +// } + +// // Expand the given expression into an equivalent array where each member is an array of licenses AND"d +// // together and the members are OR"d together. For example, `(MIT OR ISC) AND GPL-3.0` expands to +// // `[[GPL-3.0 AND MIT], [ISC AND MIT]]`. Note that within each array of licenses, the entries are +// // normalized (sorted) by license name. +// func expand (expression spdx.Expression) { +// return sort(Array.from(expandInner(expression))) +// } + +// // Flatten the given expression into an array of all licenses mentioned in the expression. +// func flatten (expression) { +// const expanded = Array.from(expandInner(expression)) +// const flattened = expanded.reduce(func (result, clause) { +// return Object.assign(result, clause) +// }, {}) +// return sort([flattened])[0] +// } + +// func expandInner (expression spdx.Expression) spdx.Expression { + +// type := reflect.TypeOf(expression) +// switch type { +// case reflect.TypeOf(spdx.LicenseID{}): + +// case reflect.TypeOf(spdx.Or{}): +// case reflect.TypeOf(spdx.And{}): +// case reflect.TypeOf(spdx.Left{}): +// case reflect.TypeOf(spdx.Right{}): + +// } +// if (!expression.conjunction) return [{ [licenseString(expression)]: expression }] +// if (expression.conjunction === "or") return expandInner(expression.left).concat(expandInner(expression.right)) +// if (expression.conjunction === "and") { +// var left = expandInner(expression.left) +// var right = expandInner(expression.right) +// return left.reduce(func (result, l) { +// right.forEach(func (r) { result.push(Object.assign({}, l, r)) }) +// return result +// }, []) +// } +// } + +// func sort (licenseList) { +// var sortedLicenseLists = licenseList +// .filter(func (e) { return Object.keys(e).length }) +// .map(func (e) { return Object.keys(e).sort() }) +// return sortedLicenseLists.map(func (list, i) { +// return list.map(func (license) { return licenseList[i][license] }) +// }) +// } + +// // func isANDCompatible (one string, two string) bool { +// // return one.every(func (o) { +// // return two.some(func (t) { return licensesAreCompatible(o, t) }) +// // }) +// // } + +// Determine if first expression satisfies second expression. +// +// Examples: +// "MIT" satisfies "MIT" is true +// +// "MIT" satisfies "MIT OR Apache-2.0" is true +// "MIT OR Apache-2.0" satisfies "MIT" is true +// "GPL" satisfies "MIT OR Apache-2.0" is false +// "MIT OR Apache-2.0" satisfies "GPL" is false +// +// "Apache-2.0 AND MIT" satisfies "MIT AND Apache-2.0" is true +// "MIT AND Apache-2.0" satisfies "MIT AND Apache-2.0" is true +// "MIT" satisfies "MIT AND Apache-2.0" is false +// "MIT AND Apache-2.0" satisfies "MIT" is false +// "GPL" satisfies "MIT AND Apache-2.0" is false +// +// "MIT AND Apache-2.0" satisfies "MIT AND (Apache-1.0 OR Apache-2.0)" +// +// "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+" 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" is true +// +// "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" is false +// +func satisfies(firstExp string, secondExp string) (bool, error) { + firstTree, err := Parse(firstExp) + if err != nil { + return false, err + } + + secondTree, err := Parse(secondExp) + if err != nil { + return false, err + } + + nodes := &NodePair{firstNode: firstTree, secondNode: secondTree} + if firstTree.IsLicense() && secondTree.IsLicense() { + return nodes.LicensesAreCompatible(), nil + } + + // firstNormalized := firstTree // normalizeGPLIdentifiers(firstTree) + // secondNormalized := secondTree // normalizeGPLIdentifiers(secondTree) + + // firstExpanded := expand(firstNormalized) + // secondFlattened := flatten(secondNormalized) + + // satisfactionFunc := func(o string) bool { return isAndCompatible(o, secondFlattened) } + // satisfaction := some(firstExpanded, satisfactionFunc) + + // return one.some(satisfactionFunc) + // return satisfaction + + // TODO: Stubbed + return false, nil +} diff --git a/spdxexp/satisfies_test.go b/spdxexp/satisfies_test.go new file mode 100644 index 0000000..b5d944d --- /dev/null +++ b/spdxexp/satisfies_test.go @@ -0,0 +1,109 @@ +package spdxexp + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSatisfies(t *testing.T) { + tests := []struct { + name string + firstExp string + secondExp string + satisfied bool + err error + }{ + // regression tests from spdx-satisfies.js - comments for satisfies function + // TODO: Commented out tests are not yet supported. + {"MIT satisfies MIT is true", "MIT", "MIT", true, nil}, + + // {"MIT satisfies MIT OR Apache-2.0 is true", "MIT", "MIT OR Apache-2.0", true, nil}, + // {"MIT OR Apache-2.0 satisfies MIT is true", "MIT OR Apache-2.0", "MIT", true, nil}, + // {"GPL satisfies MIT OR Apache-2.0 is false", "GPL", "MIT OR Apache-2.0", false, nil}, + // {"MIT OR Apache-2.0 satisfies GPL is false", "MIT OR Apache-2.0", "GPL", false, nil}, + + // {"Apache-2.0 AND MIT satisfies MIT AND Apache-2.0 is true", "Apache-2.0 AND MIT", "MIT AND Apache-2.0", true, nil}, + // {"MIT AND Apache-2.0 satisfies MIT AND Apache-2.0 is true", "MIT AND Apache-2.0", "MIT AND Apache-2.0", true, nil}, + // {"MIT satisfies MIT AND Apache-2.0 is false", "MIT", "MIT AND Apache-2.0", false, nil}, + // {"MIT AND Apache-2.0 satisfies MIT is false", "MIT AND Apache-2.0", "MIT", false, nil}, + // {"GPL satisfies MIT AND Apache-2.0 is false", "GPL", "MIT AND Apache-2.0", false, nil}, + + // {"MIT AND Apache-2.0 satisfies MIT AND (Apache-1.0 OR Apache-2.0)", "MIT AND Apache-2.0", "MIT AND (Apache-1.0 OR Apache-2.0)", true, nil}, + + // {"Apache-1.0+ satisfies Apache-2.0+ is true", "Apache-1.0+", "Apache-2.0+", true, nil}, // TODO: why does this fail here but passes js? + {"Apache-1.0 satisfies Apache-2.0+ is false", "Apache-1.0", "Apache-2.0+", false, nil}, + {"Apache-2.0 satisfies Apache-2.0+ is true", "Apache-2.0", "Apache-2.0+", true, nil}, + // {"Apache-3.0 satisfies Apache-2.0+ is true", "Apache-3.0", "Apache-2.0+", true, nil}, // TODO: gets error b/c Apache-3.0 doesn't exist -- need better error message + + {"Apache-1.0 satisfies Apache-2.0-or-later is false", "Apache-1.0", "Apache-2.0-or-later", false, nil}, + {"Apache-2.0 satisfies Apache-2.0-or-later is true", "Apache-2.0", "Apache-2.0-or-later", true, nil}, + // {"Apache-3.0 satisfies Apache-2.0-or-later is true", "Apache-3.0", "Apache-2.0-or-later", true, nil}, + + {"Apache-1.0 satisfies Apache-2.0-only is false", "Apache-1.0", "Apache-2.0-only", false, nil}, + {"Apache-2.0 satisfies Apache-2.0-only is true", "Apache-2.0", "Apache-2.0-only", true, nil}, + // {"Apache-3.0 satisfies Apache-2.0-only is false", "Apache-3.0", "Apache-2.0-only", false, nil}, + + // regression tests from spdx-satisfies.js - assert statements in README + // TODO: Commented out tests are not yet supported. + {"MIT satisfies MIT", "MIT", "MIT", true, nil}, + + // {"MIT satisfies (ISC OR MIT)", "MIT", "(ISC OR MIT)", true, nil}, + // {"Zlib satisfies (ISC OR (MIT OR Zlib))", "Zlib", "(ISC OR (MIT OR Zlib))", true, nil}, + // {"GPL-3.0 !satisfies (ISC OR MIT)", "GPL-3.0", "(ISC OR MIT)", false, nil}, + + // {"GPL-2.0 satisfies GPL-2.0+", "GPL-2.0", "GPL-2.0+", true, nil}, // TODO: why does this fail here but passes js? + // {"GPL-2.0 satisfies GPL-2.0-or-later", "GPL-2.0", "GPL-2.0-or-later", true, nil}, // TODO: why does this fail here but passes js? + {"GPL-3.0 satisfies GPL-2.0+", "GPL-3.0", "GPL-2.0+", true, nil}, + {"GPL-1.0-or-later satisfies GPL-2.0-or-later", "GPL-1.0-or-later", "GPL-2.0-or-later", true, nil}, + {"GPL-1.0+ satisfies GPL-2.0+", "GPL-1.0+", "GPL-2.0+", true, nil}, + {"GPL-1.0 !satisfies GPL-2.0+", "GPL-1.0", "GPL-2.0+", false, nil}, + {"GPL-2.0-only satisfies GPL-2.0-only", "GPL-2.0-only", "GPL-2.0-only", true, nil}, + {"GPL-3.0-only satisfies GPL-2.0+", "GPL-3.0-only", "GPL-2.0+", true, nil}, + + // {"GPL-2.0 !satisfies GPL-2.0+ WITH Bison-exception-2.2", + // "GPL-2.0", "GPL-2.0+ WITH Bison-exception-2.2", false, nil}, + // {"GPL-3.0 WITH Bison-exception-2.2 satisfies GPL-2.0+ WITH Bison-exception-2.2", + // "GPL-3.0 WITH Bison-exception-2.2", "GPL-2.0+ WITH Bison-exception-2.2", true, nil}, + + // {"(MIT OR GPL-2.0) satisfies (ISC OR MIT)", "(MIT OR GPL-2.0)", "(ISC OR MIT)", true, nil}, + // {"(MIT AND GPL-2.0) satisfies (MIT AND GPL-2.0)", "(MIT AND GPL-2.0)", "(MIT AND GPL-2.0)", true, nil}, + // {"MIT AND GPL-2.0 AND ISC satisfies MIT AND GPL-2.0 AND ISC", + // "MIT AND GPL-2.0 AND ISC", "MIT AND GPL-2.0 AND ISC", true, nil}, + // {"MIT AND GPL-2.0 AND ISC satisfies ISC AND GPL-2.0 AND MIT", + // "MIT AND GPL-2.0 AND ISC", "ISC AND GPL-2.0 AND MIT", true, nil}, + // {"(MIT OR GPL-2.0) AND ISC satisfies MIT AND ISC", + // "(MIT OR GPL-2.0) AND ISC", "MIT AND ISC", true, nil}, + // {"MIT AND ISC satisfies (MIT OR GPL-2.0) AND ISC", + // "MIT AND ISC", "(MIT OR GPL-2.0) AND ISC", true, nil}, + // {"MIT AND ISC satisfies (MIT AND GPL-2.0) OR ISC", + // "MIT AND ISC", "(MIT AND GPL-2.0) OR ISC", true, nil}, + // {"(MIT OR Apache-2.0) AND (ISC OR GPL-2.0) satisfies Apache-2.0 AND ISC", + // "(MIT OR Apache-2.0) AND (ISC OR GPL-2.0)", "Apache-2.0 AND ISC", true, nil}, + // {"(MIT OR Apache-2.0) AND (ISC OR GPL-2.0) satisfies Apache-2.0 OR ISC", + // "(MIT OR Apache-2.0) AND (ISC OR GPL-2.0)", "Apache-2.0 OR ISC", true, nil}, + // {"(MIT AND GPL-2.0) satisfies (MIT OR GPL-2.0)", + // "(MIT AND GPL-2.0)", "(MIT OR GPL-2.0)", true, nil}, + // {"(MIT AND GPL-2.0) satisfies (GPL-2.0 AND MIT)", + // "(MIT AND GPL-2.0)", "(GPL-2.0 AND MIT)", true, nil}, + // {"MIT satisfies (GPL-2.0 OR MIT) AND (MIT OR ISC)", + // "MIT", "(GPL-2.0 OR MIT) AND (MIT OR ISC)", true, nil}, + // {"MIT AND ICU satisfies (MIT AND GPL-2.0) OR (ISC AND (Apache-2.0 OR ICU))", + // "MIT AND ICU", "(MIT AND GPL-2.0) OR (ISC AND (Apache-2.0 OR ICU))", true, nil}, + // {"(MIT AND GPL-2.0) !satisfies (ISC OR GPL-2.0)", + // "(MIT AND GPL-2.0)", "(ISC OR GPL-2.0)", false, nil}, + // {"MIT AND (GPL-2.0 OR ISC) !satisfies MIT", + // "MIT AND (GPL-2.0 OR ISC)", "MIT", false, nil}, + // {"(MIT OR Apache-2.0) AND (ISC OR GPL-2.0) !satisfies MIT", + // "(MIT OR Apache-2.0) AND (ISC OR GPL-2.0)", "MIT", false, nil}, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + satisfied, err := satisfies(test.firstExp, test.secondExp) + assert.Equal(t, test.err, err) + assert.Equal(t, test.satisfied, satisfied) + }) + } +} diff --git a/spdxexp/scan.go b/spdxexp/scan.go index ff35787..523035f 100644 --- a/spdxexp/scan.go +++ b/spdxexp/scan.go @@ -20,7 +20,7 @@ type token struct { value string } -type tokenrole int64 +type tokenrole uint8 const ( OPERATOR_TOKEN tokenrole = iota @@ -91,7 +91,7 @@ func (exp *expressionStream) parseToken() *token { return lref } - identifier := exp.readIdentifier() + identifier := exp.readLicense() if exp.err != nil { return nil } @@ -202,22 +202,92 @@ func (exp *expressionStream) readLicenseRef() *token { } // Read a LICENSE/EXCEPTION in expression starting at index if it exists. Raise error if found and id doesn't follow. -func (exp *expressionStream) readIdentifier() *token { +func (exp *expressionStream) readLicense() *token { // because readID matches broadly, save the index so it can be reset if an actual license is not found index := exp.index - id := exp.readID() + license := exp.readID() if exp.err != nil { return nil } - if ActiveLicense(id) || DeprecatedLicense(id) { - return &token{role: LICENSE_TOKEN, value: id} - } else if ExceptionLicense(id) { - return &token{role: EXCEPTION_TOKEN, value: id} + if token := exp.normalizeLicense(license); token != nil { + return token } - // license not found in indices + // license not found in indices, need to reset index since readID advanced it exp.index = index return nil } + +// 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 +func (exp *expressionStream) normalizeLicense(license string) *token { + if token := licenseLookup(license); token != nil { + // checks active and exception license lists + // deprecated list is checked at the end to avoid a deprecated license being used for + + // (example: GPL-1.0 is on the depcated list, but GPL-1.0+ should become GPL-1.0-or-later) + return token + } + + len_license := len(license) + if strings.HasSuffix(license, "-only") { + adjusted_license := license[0 : len_license-5] + if token := licenseLookup(adjusted_license); token != nil { + // no need to remove the -only from the expression stream; it is ignored + return token + } + } + if exp.hasMore() && exp.expression[exp.index:exp.index+1] == "+" { + adjusted_license := license[0:len_license] + "-or-later" + if token := licenseLookup(adjusted_license); token != nil { + // need to consume the + to avoid a + operator token being added + exp.index += 1 + return token + } + } + if strings.HasSuffix(license, "-or-later") { + adjusted_license := license[0 : len_license-9] + if token := licenseLookup(adjusted_license); token != nil { + // replace `-or-later` with `+` + new_expression := exp.expression[0:exp.index-len("-or-later")] + "+" + if exp.hasMore() { + new_expression += exp.expression[exp.index+1:] + } + exp.expression = new_expression + // update index to remove `-or-later`; now pointing at the `+` operator + exp.index -= len("-or-later") + + return token + } + } + if token := deprecatedLicenseLookup(license); token != nil { + return token + } + return nil +} + +// Lookup license identifier in active and exception lists to determine if it is a supported SPDX id +func licenseLookup(license string) *token { + if ActiveLicense(license) { + return &token{role: LICENSE_TOKEN, value: license} + } + if ExceptionLicense(license) { + return &token{role: EXCEPTION_TOKEN, value: license} + } + return nil +} + +// Lookup license identifier in deprecated list to determine if it is a supported SPDX id +func deprecatedLicenseLookup(license string) *token { + if DeprecatedLicense(license) { + return &token{role: LICENSE_TOKEN, value: license} + } + return nil +} diff --git a/spdxexp/scan_test.go b/spdxexp/scan_test.go index 3a01c0f..f03d4e3 100644 --- a/spdxexp/scan_test.go +++ b/spdxexp/scan_test.go @@ -343,24 +343,30 @@ func TestReadLicenseRef(t *testing.T) { } } -func TestReadIdentifier(t *testing.T) { +func TestReadLicense(t *testing.T) { tests := []struct { - name string - exp *expressionStream - license *token - newIndex int - err error + name string + exp *expressionStream + license *token + newExpression string + newIndex int + err error }{ - {"active license", getExpressionStream("MIT", 0), &token{role: LICENSE_TOKEN, value: "MIT"}, 3, nil}, - {"deprecated license", getExpressionStream("LGPL-2.1", 0), &token{role: LICENSE_TOKEN, value: "LGPL-2.1"}, 8, nil}, - {"exception license", getExpressionStream("GPL-CC-1.0", 0), &token{role: EXCEPTION_TOKEN, value: "GPL-CC-1.0"}, 10, nil}, - {"invalid license", getExpressionStream("NON-EXISTENT-LICENSE", 0), nil, 0, nil}, // TODO: should this return an error? + {"active license", getExpressionStream("MIT", 0), &token{role: LICENSE_TOKEN, value: "MIT"}, "MIT", 3, nil}, + {"active -or-later", getExpressionStream("AGPL-1.0-or-later", 0), &token{role: LICENSE_TOKEN, value: "AGPL-1.0-or-later"}, "AGPL-1.0-or-later", 17, nil}, + {"active -or-later using +", getExpressionStream("AGPL-1.0+", 0), &token{role: LICENSE_TOKEN, value: "AGPL-1.0-or-later"}, "AGPL-1.0+", 9, nil}, // no valid example for this; all that include -or-later have the base as a deprecated license + {"active -or-later not in list", getExpressionStream("Apache-1.0-or-later", 0), &token{role: LICENSE_TOKEN, value: "Apache-1.0"}, "Apache-1.0+", 10, nil}, + {"active -only", getExpressionStream("GPL-2.0-only", 0), &token{role: LICENSE_TOKEN, value: "GPL-2.0-only"}, "GPL-2.0-only", 12, nil}, + {"active -only not in list", getExpressionStream("ECL-1.0-only", 0), &token{role: LICENSE_TOKEN, value: "ECL-1.0"}, "ECL-1.0-only", 12, nil}, + {"deprecated license", getExpressionStream("LGPL-2.1", 0), &token{role: LICENSE_TOKEN, value: "LGPL-2.1"}, "LGPL-2.1", 8, nil}, + {"exception license", getExpressionStream("GPL-CC-1.0", 0), &token{role: EXCEPTION_TOKEN, value: "GPL-CC-1.0"}, "GPL-CC-1.0", 10, nil}, + {"invalid license", getExpressionStream("NON-EXISTENT-LICENSE", 0), nil, "NON-EXISTENT-LICENSE", 0, nil}, // TODO: should this return an error? } for _, test := range tests { test := test t.Run(test.name, func(t *testing.T) { - license := test.exp.readIdentifier() + license := test.exp.readLicense() assert.Equal(t, test.newIndex, test.exp.index) require.Equal(t, test.err, test.exp.err) @@ -373,6 +379,7 @@ func TestReadIdentifier(t *testing.T) { // license found, check license value assert.Equal(t, test.license, license) + assert.Equal(t, test.newExpression, test.exp.expression) }) } } diff --git a/spdxexp/test_helper.go b/spdxexp/test_helper.go index a53894c..c8c8127 100644 --- a/spdxexp/test_helper.go +++ b/spdxexp/test_helper.go @@ -1,6 +1,6 @@ package spdxexp -func getLicenseNode(license string) *Node { +func getLicenseNode(license string, hasPlus bool) *Node { return &Node{ role: LICENSE_NODE, exp: nil,