diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3dbc254..4ee0816 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,11 +4,12 @@ jobs: build: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: node: ['4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14'] steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v3 with: node-version: ${{ matrix.node }} check-latest: true diff --git a/README.md b/README.md index 76c8b38..789f49b 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,14 @@ +`satisfies(SPDX license expression, array of approved licenses)` + +Approved licenses may be simple license identifiers like `MIT`, plus-ranges like `EPL-2.0+`, or licenses with exceptions like `Apache-2.0 WITH LLVM`. They may _not_ be compound expressions using `AND` or `OR`. + ```javascript var assert = require('assert') var satisfies = require('spdx-satisfies') -assert(satisfies('MIT', 'MIT')) - -assert(satisfies('MIT', '(ISC OR MIT)')) -assert(satisfies('Zlib', '(ISC OR (MIT OR Zlib))')) -assert(!satisfies('GPL-3.0', '(ISC OR MIT)')) - -assert(satisfies('GPL-2.0', 'GPL-2.0+')) -assert(satisfies('GPL-3.0', 'GPL-2.0+')) -assert(satisfies('GPL-1.0+', 'GPL-2.0+')) -assert(!satisfies('GPL-1.0', 'GPL-2.0+')) -assert(satisfies('GPL-2.0-only', 'GPL-2.0-only')) -assert(satisfies('GPL-3.0-only', 'GPL-2.0+')) -assert(satisfies('LGPL-3.0-only', 'LGPL-3.0-or-later')) -assert(satisfies('GPL-2.0', 'GPL-2.0+')) -assert(satisfies('GPL-2.0-only', 'GPL-2.0+')) -assert(satisfies('GPL-2.0', 'GPL-2.0-or-later')) - -assert(!satisfies( - 'GPL-2.0', - 'GPL-2.0+ WITH Bison-exception-2.2' -)) - -assert(satisfies( - 'GPL-3.0 WITH Bison-exception-2.2', - 'GPL-2.0+ WITH Bison-exception-2.2' -)) - -assert(satisfies('(MIT OR GPL-2.0)', '(ISC OR MIT)')) -assert(satisfies('(MIT AND GPL-2.0)', '(MIT AND GPL-2.0)')) -assert(satisfies('MIT AND GPL-2.0 AND ISC', 'MIT AND GPL-2.0 AND ISC')) -assert(satisfies('MIT AND GPL-2.0 AND ISC', 'ISC AND GPL-2.0 AND MIT')) -assert(satisfies('(MIT OR GPL-2.0) AND ISC', 'MIT AND ISC')) -assert(satisfies('MIT AND ISC', '(MIT OR GPL-2.0) AND ISC')) -assert(satisfies('MIT AND ISC', '(MIT AND GPL-2.0) OR ISC')) -assert(satisfies('(MIT OR Apache-2.0) AND (ISC OR GPL-2.0)', 'Apache-2.0 AND ISC')) -assert(satisfies('(MIT OR Apache-2.0) AND (ISC OR GPL-2.0)', 'Apache-2.0 OR ISC')) -assert(satisfies('(MIT AND GPL-2.0)', '(MIT OR GPL-2.0)')) -assert(satisfies('(MIT AND GPL-2.0)', '(GPL-2.0 AND MIT)')) -assert(satisfies('MIT', '(GPL-2.0 OR MIT) AND (MIT OR ISC)')) -assert(satisfies('MIT AND ICU', '(MIT AND GPL-2.0) OR (ISC AND (Apache-2.0 OR ICU))')) -assert(!satisfies('(MIT AND GPL-2.0)', '(ISC OR GPL-2.0)')) -assert(!satisfies('MIT AND (GPL-2.0 OR ISC)', 'MIT')) -assert(!satisfies('(MIT OR Apache-2.0) AND (ISC OR GPL-2.0)', 'MIT')) +assert(satisfies('MIT', ['MIT', 'ISC', 'BSD-2-Clause', 'Apache-2.0'])) +assert(satisfies('GPL-2.0 OR MIT', ['MIT'])) +assert(!satisfies('GPL-2.0 AND MIT', ['MIT'])) +assert(satisfies('GPL-3.0', ['GPL-2.0+'])) +assert(!satisfies('GPL-1.0', ['GPL-2.0+'])) ``` diff --git a/examples.json b/examples.json new file mode 100644 index 0000000..be339fa --- /dev/null +++ b/examples.json @@ -0,0 +1,34 @@ +{ + "returnTrue": [ + ["MIT", ["MIT"]], + ["MIT", ["ISC", "MIT"]], + ["Zlib", ["ISC", "MIT", "Zlib"]], + ["GPL-2.0", ["GPL-2.0+"]], + ["GPL-3.0", ["GPL-2.0+"]], + ["GPL-1.0+", ["GPL-2.0+"]], + ["GPL-2.0-only", ["GPL-2.0-only"]], + ["GPL-3.0-only", ["GPL-2.0+"]], + ["LGPL-3.0-only", ["LGPL-3.0-or-later"]], + ["GPL-2.0", ["GPL-2.0+"]], + ["GPL-2.0-only", ["GPL-2.0+"]], + ["GPL-2.0", ["GPL-2.0-or-later"]], + ["GPL-3.0 WITH Bison-exception-2.2", ["GPL-2.0+ WITH Bison-exception-2.2"]], + ["(MIT OR GPL-2.0)", ["ISC", "MIT"]], + ["(MIT OR Apache-2.0) AND (ISC OR GPL-2.0)", ["Apache-2.0", "ISC"]], + ["(MIT AND GPL-2.0)", ["MIT", "GPL-2.0"]] + ], + + "returnFalse": [ + ["GPL-3.0", ["ISC", "MIT"]], + ["GPL-1.0", ["GPL-2.0+"]], + ["GPL-2.0", ["GPL-2.0+ WITH Bison-exception-2.2"]], + ["(MIT AND GPL-2.0)", ["ISC", "GPL-2.0"]], + ["MIT AND (GPL-2.0 OR ISC)", ["MIT"]], + ["(MIT OR Apache-2.0) AND (ISC OR GPL-2.0)", ["MIT"]] + ], + + "throwErrors": [ + ["MIT AND ISC", ["(MIT OR GPL-2.0) AND ISC"]], + ["MIT AND ISC", ["(MIT AND GPL-2.0)", "ISC"]] + ] +} diff --git a/index.js b/index.js index 0fd51df..39bc40d 100644 --- a/index.js +++ b/index.js @@ -2,7 +2,7 @@ var compare = require('spdx-compare') var parse = require('spdx-expression-parse') var ranges = require('spdx-ranges') -var rangesAreCompatible = function (first, second) { +function rangesAreCompatible (first, second) { return ( first.license === second.license || ranges.some(function (range) { @@ -26,7 +26,7 @@ function licenseInRange (license, range) { ) } -var identifierInRange = function (identifier, range) { +function identifierInRange (identifier, range) { return ( identifier.license === range.license || compare.gt(identifier.license, range.license) || @@ -34,7 +34,7 @@ var identifierInRange = function (identifier, range) { ) } -var licensesAreCompatible = function (first, second) { +function licensesAreCompatible (first, second) { if (first.exception !== second.exception) { return false } else if (second.hasOwnProperty('license')) { @@ -58,7 +58,7 @@ var licensesAreCompatible = function (first, second) { } } -function normalizeGPLIdentifiers (argument) { +function replaceGPLOnlyOrLaterWithRanges (argument) { var license = argument.license if (license) { if (endsWith(license, '-or-later')) { @@ -69,8 +69,8 @@ function normalizeGPLIdentifiers (argument) { delete argument.plus } } else if (argument.left && argument.right) { - argument.left = normalizeGPLIdentifiers(argument.left) - argument.right = normalizeGPLIdentifiers(argument.right) + argument.left = replaceGPLOnlyOrLaterWithRanges(argument.left) + argument.right = replaceGPLOnlyOrLaterWithRanges(argument.right) } return argument } @@ -81,7 +81,13 @@ function endsWith (string, substring) { function licenseString (e) { if (e.hasOwnProperty('noassertion')) return 'NOASSERTION' - if (e.license) return `${e.license}${e.plus ? '+' : ''}${e.exception ? ` WITH ${e.exception}` : ''}` + 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 @@ -92,15 +98,6 @@ function expand (expression) { return sort(expandInner(expression)) } -// Flatten the given expression into an array of all licenses mentioned in the expression. -function flatten (expression) { - var expanded = expandInner(expression) - var flattened = expanded.reduce(function (result, clause) { - return Object.assign(result, clause) - }, {}) - return sort([flattened])[0] -} - function expandInner (expression) { if (!expression.conjunction) return [{ [licenseString(expression)]: expression }] if (expression.conjunction === 'or') return expandInner(expression.left).concat(expandInner(expression.right)) @@ -123,16 +120,23 @@ function sort (licenseList) { }) } -function isANDCompatible (one, two) { - return one.every(function (o) { - return two.some(function (t) { return licensesAreCompatible(o, t) }) +function isANDCompatible (parsedExpression, parsedLicenses) { + return parsedExpression.every(function (element) { + return parsedLicenses.some(function (approvedLicense) { + return licensesAreCompatible(element, approvedLicense) + }) }) } -function satisfies (first, second) { - var one = expand(normalizeGPLIdentifiers(parse(first))) - var two = flatten(normalizeGPLIdentifiers(parse(second))) - return one.some(function (o) { return isANDCompatible(o, two) }) +function satisfies (spdxExpression, arrayOfLicenses) { + var parsedExpression = expand(replaceGPLOnlyOrLaterWithRanges(parse(spdxExpression))) + var parsedLicenses = arrayOfLicenses.map(function (l) { return replaceGPLOnlyOrLaterWithRanges(parse(l)) }) + for (const parsed of parsedLicenses) { + if (parsed.hasOwnProperty('conjunction')) { + throw new Error('Approved licenses cannot be AND or OR expressions.') + } + } + return parsedExpression.some(function (o) { return isANDCompatible(o, parsedLicenses) }) } module.exports = satisfies diff --git a/package.json b/package.json index 74e4ed6..433f65c 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "spdx-ranges": "^2.0.0" }, "devDependencies": { - "defence-cli": "^2.0.1", + "defence-cli": "^3.0.1", "replace-require-self": "^1.1.1", "standard": "^11.0.0" }, @@ -34,7 +34,9 @@ "index.js" ], "scripts": { - "test": "defence -i javascript README.md | replace-require-self | node", + "test": "npm run test:suite && npm run test:readme", + "test:suite": "node test.js", + "test:readme": "defence -i javascript README.md | replace-require-self | node", "lint": "standard" } } diff --git a/test.js b/test.js new file mode 100644 index 0000000..cd206ae --- /dev/null +++ b/test.js @@ -0,0 +1,51 @@ +var assert = require('assert') +var examples = require('./examples.json') +var satisfies = require('./') + +var failed = false + +function write (string) { process.stdout.write(string) } + +function label (example) { + write('satisfies(' + JSON.stringify(example[0]) + ', ' + JSON.stringify(example[1]) + ')') +} + +examples.returnTrue.forEach(function (example) { + label(example) + try { + assert(satisfies(example[0], example[1]) === true) + } catch (error) { + failed = true + write(' did not return true\n') + return + } + write(' returned true\n') +}) + +// False Examples +examples.returnFalse.forEach(function (example) { + label(example) + try { + assert(satisfies(example[0], example[1]) === false) + } catch (error) { + failed = true + write(' did not return false\n') + return + } + write(' returned false\n') +}) + +// Invalid License Arrays +examples.throwErrors.forEach(function (example) { + label(example) + try { + satisfies(example[0], example[1]) + } catch (error) { + write(' threw an exception\n') + return + } + failed = true + write(' did not throw an exception\n') +}) + +process.exit(failed ? 1 : 0)