diff --git a/packages/scripts/CHANGELOG.md b/packages/scripts/CHANGELOG.md index 6d72d80d91f0a5..ba644aa11f4a67 100644 --- a/packages/scripts/CHANGELOG.md +++ b/packages/scripts/CHANGELOG.md @@ -2,13 +2,16 @@ ## Unreleased +### Internal + +- Refactor to extract license related logic to a reusable module ([#66179](https://github.com/WordPress/gutenberg/pull/66179)). + ## 30.2.0 (2024-10-16) ## 30.1.0 (2024-10-03) ## 30.0.0 (2024-09-19) - ### Breaking Changes - Updated `stylelint` dependency to `^16.8.2` ([#64828](https://github.com/WordPress/gutenberg/pull/64828)). diff --git a/packages/scripts/scripts/check-licenses.js b/packages/scripts/scripts/check-licenses.js index 6401fd83e3482d..40537cf4880fce 100644 --- a/packages/scripts/scripts/check-licenses.js +++ b/packages/scripts/scripts/check-licenses.js @@ -2,13 +2,12 @@ * External dependencies */ const spawn = require( 'cross-spawn' ); -const { existsSync, readFileSync } = require( 'fs' ); -const chalk = require( 'chalk' ); /** * Internal dependencies */ const { getArgFromCLI, hasArgInCLI } = require( '../utils' ); +const { checkDepsInTree } = require( '../utils/license' ); /* * WARNING: Changes to this file may inadvertently cause us to distribute code that @@ -19,9 +18,6 @@ const { getArgFromCLI, hasArgInCLI } = require( '../utils' ); * reviewed and approved. */ -const ERROR = chalk.reset.inverse.bold.red( ' ERROR ' ); -const WARNING = chalk.reset.inverse.bold.yellow( ' WARNING ' ); - const prod = hasArgInCLI( '--prod' ) || hasArgInCLI( '--production' ); const dev = hasArgInCLI( '--dev' ) || hasArgInCLI( '--development' ); const gpl2 = hasArgInCLI( '--gpl2' ); @@ -33,144 +29,6 @@ const ignored = hasArgInCLI( '--ignore' ) .map( ( moduleName ) => moduleName.trim() ) : []; -/* - * A list of license strings that we've found to be GPL2 compatible. - * - * Note the strings with "AND" in them at the bottom: these should only be added when - * all the licenses in that string are GPL2 compatible. - */ -const gpl2CompatibleLicenses = [ - '0BSD', - 'Apache-2.0 WITH LLVM-exception', - 'Artistic-2.0', - 'BSD-2-Clause', - 'BSD-3-Clause-W3C', - 'BSD-3-Clause', - 'BSD', - 'CC-BY-4.0', - 'CC0-1.0', - 'GPL-2.0-or-later', - 'GPL-2.0', - 'GPL-2.0+', - 'ISC', - 'LGPL-2.1', - 'MIT', - 'MIT/X11', - 'MPL-2.0', - 'ODC-By-1.0', - 'Public Domain', - 'Unlicense', - 'W3C-20150513', - 'WTFPL', - 'Zlib', -]; - -/* - * A list of OSS license strings that aren't GPL2 compatible. - * - * We're cool with using packages that are licensed under any of these if we're not - * distributing them (for example, build tools), but we can't included them in a release. - */ -const otherOssLicenses = [ - 'Apache-2.0', - 'Apache License, Version 2.0', - 'CC-BY-3.0', - 'CC-BY-SA-2.0', - 'LGPL', - 'Python-2.0', -]; - -const licenses = [ - ...gpl2CompatibleLicenses, - ...( gpl2 ? [] : otherOssLicenses ), -]; - -/* - * Some packages don't included a license string in their package.json file, but they - * do have a license listed elsewhere. These files are checked for matching license strings. - * Only the first matching license file with a matching license string is considered. - * - * See: licenseFileStrings. - */ -const licenseFiles = [ - 'LICENCE', - 'license', - 'LICENSE', - 'LICENSE.md', - 'LICENSE.txt', - 'LICENSE-MIT', - 'MIT-LICENSE.txt', - 'Readme.md', - 'README.md', -]; - -/* - * When searching through files for licensing information, these are the strings we look for, - * and their matching license. - */ -const licenseFileStrings = { - 'Apache-2.0': [ 'Licensed under the Apache License, Version 2.0' ], - BSD: [ - 'Redistributions in binary form must reproduce the above copyright notice,', - ], - 'BSD-3-Clause-W3C': [ 'W3C 3-clause BSD License' ], - MIT: [ - 'Permission is hereby granted, free of charge,', - '## License\n\nMIT', - '## License\n\n MIT', - ], -}; - -/** - * Check if a license string matches the given license. - * - * The license string can be a single license, or an SPDX-compatible "OR" license string. - * eg, "(MIT OR Zlib)". - * - * @param {string} allowedLicense The license that's allowed. - * @param {string} licenseType The license string to check. - * - * @return {boolean} true if the licenseType matches the allowedLicense, false if it doesn't. - */ -const checkLicense = ( allowedLicense, licenseType ) => { - if ( ! licenseType ) { - return false; - } - - // Some licenses have unusual capitalisation in them. - const formattedAllowedLicense = allowedLicense.toLowerCase(); - const formattedlicenseType = licenseType.toLowerCase(); - - if ( formattedAllowedLicense === formattedlicenseType ) { - return true; - } - - // We can skip the parsing below if there isn't an 'OR' in the license. - if ( ! formattedlicenseType.includes( ' or ' ) ) { - return false; - } - - /* - * In order to do a basic parse of SPDX-compatible "OR" license strings, we: - * - Remove surrounding brackets: "(mit or zlib)" -> "mit or zlib" - * - Split it into an array: "mit or zlib" -> [ "mit", "zlib" ] - * - Trim any remaining whitespace from each element - */ - const subLicenseTypes = formattedlicenseType - .replace( /^\(*/g, '' ) - .replace( /\)*$/, '' ) - .split( ' or ' ) - .map( ( e ) => e.trim() ); - - // We can then check our array of licenses against the allowedLicense. - return ( - undefined !== - subLicenseTypes.find( ( subLicenseType ) => - checkLicense( allowedLicense, subLicenseType ) - ) - ); -}; - // Use `npm ls` to grab a list of all the packages. const child = spawn.sync( 'npm', @@ -193,204 +51,4 @@ const result = JSON.parse( child.stdout.toString() ); const topLevelDeps = result.dependencies; -function traverseDepTree( deps ) { - for ( const key in deps ) { - const dep = deps[ key ]; - - if ( ignored.includes( dep.name ) ) { - continue; - } - - if ( Object.keys( dep ).length === 0 ) { - continue; - } - - if ( ! dep.hasOwnProperty( 'path' ) && ! dep.missing ) { - if ( dep.hasOwnProperty( 'peerMissing' ) ) { - process.stdout.write( - `${ WARNING } Unable to locate path for missing peer dep ${ dep.name }@${ dep.version }. ` - ); - } else { - process.exitCode = 1; - process.stdout.write( - `${ ERROR } Unable to locate path for ${ dep.name }@${ dep.version }. ` - ); - } - } else if ( dep.missing ) { - for ( const problem of dep.problems ) { - process.stdout.write( `${ WARNING } ${ problem }.\n` ); - } - } else { - checkDepLicense( dep.path ); - } - - if ( dep.hasOwnProperty( 'dependencies' ) ) { - traverseDepTree( dep.dependencies ); - } - } -} - -function detectTypeFromLicenseFiles( path ) { - return licenseFiles.reduce( ( detectedType, licenseFile ) => { - // If another LICENSE file already had licenses in it, use those. - if ( detectedType ) { - return detectedType; - } - - const licensePath = path + '/' + licenseFile; - - if ( existsSync( licensePath ) ) { - const licenseText = readFileSync( licensePath ).toString(); - return detectTypeFromLicenseText( licenseText ); - } - - return detectedType; - }, false ); -} - -function detectTypeFromLicenseText( licenseText ) { - // Check if the file contains any of the strings in licenseFileStrings. - return Object.keys( licenseFileStrings ).reduce( - ( stringDetectedType, licenseStringType ) => { - const licenseFileString = licenseFileStrings[ licenseStringType ]; - - return licenseFileString.reduce( - ( currentDetectedType, fileString ) => { - if ( licenseText.includes( fileString ) ) { - if ( currentDetectedType ) { - return currentDetectedType.concat( - ' AND ', - licenseStringType - ); - } - return licenseStringType; - } - return currentDetectedType; - }, - stringDetectedType - ); - }, - false - ); -} - -const reportedPackages = new Set(); - -function checkDepLicense( path ) { - if ( ! path ) { - return; - } - - const filename = path + '/package.json'; - if ( ! existsSync( filename ) ) { - process.stdout.write( `Unable to locate package.json in ${ path }.` ); - process.exit( 1 ); - } - - /* - * The package.json format can be kind of weird. We allow for the following formats: - * - { license: 'MIT' } - * - { license: { type: 'MIT' } } - * - { licenses: [ 'MIT', 'Zlib' ] } - * - { licenses: [ { type: 'MIT' }, { type: 'Zlib' } ] } - */ - const packageInfo = require( filename ); - const license = - packageInfo.license || - ( packageInfo.licenses && - packageInfo.licenses.map( ( l ) => l.type || l ).join( ' OR ' ) ); - let licenseType = typeof license === 'object' ? license.type : license; - - // Check if the license we've detected is telling us to look in the license file, instead. - if ( - licenseType && - licenseFiles.find( ( licenseFile ) => - licenseType.includes( licenseFile ) - ) - ) { - licenseType = undefined; - } - - if ( licenseType !== undefined ) { - let licenseTypes = [ licenseType ]; - if ( licenseType.includes( ' AND ' ) ) { - licenseTypes = licenseType - .replace( /^\(*/g, '' ) - .replace( /\)*$/, '' ) - .split( ' AND ' ) - .map( ( e ) => e.trim() ); - } - - if ( checkAllCompatible( licenseTypes, licenses ) ) { - return; - } - } - - /* - * If we haven't been able to detect a license in the package.json file, - * or the type was invalid, try reading it from the files defined in - * license files, instead. - */ - const detectedLicenseType = detectTypeFromLicenseFiles( path ); - if ( ! licenseType && ! detectedLicenseType ) { - return; - } - - let detectedLicenseTypes = [ detectedLicenseType ]; - if ( detectedLicenseType && detectedLicenseType.includes( ' AND ' ) ) { - detectedLicenseTypes = detectedLicenseType - .replace( /^\(*/g, '' ) - .replace( /\)*$/, '' ) - .split( ' AND ' ) - .map( ( e ) => e.trim() ); - } - - if ( checkAllCompatible( detectedLicenseTypes, licenses ) ) { - return; - } - - // Do not report same package twice. - if ( reportedPackages.has( packageInfo.name ) ) { - return; - } - - reportedPackages.add( packageInfo.name ); - - process.exitCode = 1; - process.stdout.write( - `${ ERROR } Module ${ packageInfo.name } has an incompatible license '${ licenseType }'.\n` - ); -} - -/** - * Check that all of the licenses for a package are compatible. - * - * This function is invoked when the licenses are a conjunctive ("AND") list of licenses. - * In that case, the software is only compatible if all of the licenses in the list are - * compatible. - * - * @param {Array} packageLicenses The licenses that a package is licensed under. - * @param {Array} compatibleLicenses The list of compatible licenses. - * - * @return {boolean} true if all of the packageLicenses appear in compatibleLicenses. - */ -function checkAllCompatible( packageLicenses, compatibleLicenses ) { - return packageLicenses.reduce( ( compatible, packageLicense ) => { - return ( - compatible && - compatibleLicenses.reduce( - ( found, allowedLicense ) => - found || checkLicense( allowedLicense, packageLicense ), - false - ) - ); - }, true ); -} - -traverseDepTree( topLevelDeps ); - -// Required for unit testing -module.exports = { - detectTypeFromLicenseText, - checkAllCompatible, -}; +checkDepsInTree( topLevelDeps, { ignored, gpl2 } ); diff --git a/packages/scripts/utils/license.js b/packages/scripts/utils/license.js new file mode 100644 index 00000000000000..00043e77ff1412 --- /dev/null +++ b/packages/scripts/utils/license.js @@ -0,0 +1,376 @@ +/** + * External dependencies + */ +const chalk = require( 'chalk' ); +const { existsSync, readFileSync } = require( 'node:fs' ); + +const ERROR_TEXT = chalk.reset.inverse.bold.red( ' ERROR ' ); +const WARNING_TEXT = chalk.reset.inverse.bold.yellow( ' WARNING ' ); + +/** + * @typedef {ReadonlyArray} Licenses + */ + +/** + * Some packages don't included a license string in their package.json file, but they + * do have a license listed elsewhere. These files are checked for matching license strings. + * Only the first matching license file with a matching license string is considered. + * + * See: licenseFileStrings. + * @type {ReadonlyArray} + */ +const licenseFiles = [ + 'LICENCE', + 'license', + 'LICENSE', + 'LICENSE.md', + 'LICENSE.txt', + 'LICENSE-MIT', + 'MIT-LICENSE.txt', + 'Readme.md', + 'README.md', +]; + +/** + * When searching through files for licensing information, these are the strings we look for, + * and their matching license. + */ +const licenseFileStrings = { + 'Apache-2.0': [ 'Licensed under the Apache License, Version 2.0' ], + BSD: [ + 'Redistributions in binary form must reproduce the above copyright notice,', + ], + 'BSD-3-Clause-W3C': [ 'W3C 3-clause BSD License' ], + MIT: [ + 'Permission is hereby granted, free of charge,', + '## License\n\nMIT', + '## License\n\n MIT', + ], +}; + +/* + * A list of license strings that we've found to be GPL2 compatible. + * + * Note the strings with "AND" in them at the bottom: these should only be added when + * all the licenses in that string are GPL2 compatible. + */ +const gpl2CompatibleLicenses = [ + '0BSD', + 'Apache-2.0 WITH LLVM-exception', + 'Artistic-2.0', + 'BSD-2-Clause', + 'BSD-3-Clause-W3C', + 'BSD-3-Clause', + 'BSD', + 'CC-BY-4.0', + 'CC0-1.0', + 'GPL-2.0-or-later', + 'GPL-2.0', + 'GPL-2.0+', + 'ISC', + 'LGPL-2.1', + 'MIT', + 'MIT/X11', + 'MPL-2.0', + 'ODC-By-1.0', + 'Public Domain', + 'Unlicense', + 'W3C-20150513', + 'WTFPL', + 'Zlib', +]; + +/* + * A list of OSS license strings that aren't GPL2 compatible. + * + * We're cool with using packages that are licensed under any of these if we're not + * distributing them (for example, build tools), but we can't included them in a release. + */ +const otherOssLicenses = [ + 'Apache-2.0', + 'Apache License, Version 2.0', + 'CC-BY-3.0', + 'CC-BY-SA-2.0', + 'LGPL', + 'Python-2.0', +]; + +/** + * @param {boolean} gpl2 Only allow GPL2 compatible licenses. + * @return {Licenses} Allowed licenses. + */ +const getLicenses = ( gpl2 ) => { + return [ ...gpl2CompatibleLicenses, ...( gpl2 ? [] : otherOssLicenses ) ]; +}; + +/** + * Check if a license string matches the given license. + * + * The license string can be a single license, or an SPDX-compatible "OR" license string. + * eg, "(MIT OR Zlib)". + * + * @param {string} allowedLicense The license that's allowed. + * @param {string} licenseType The license string to check. + * + * @return {boolean} true if the licenseType matches the allowedLicense, false if it doesn't. + */ +const checkLicense = ( allowedLicense, licenseType ) => { + if ( ! licenseType ) { + return false; + } + + // Some licenses have unusual capitalisation in them. + const formattedAllowedLicense = allowedLicense.toLowerCase(); + const formattedlicenseType = licenseType.toLowerCase(); + + if ( formattedAllowedLicense === formattedlicenseType ) { + return true; + } + + // We can skip the parsing below if there isn't an 'OR' in the license. + if ( ! formattedlicenseType.includes( ' or ' ) ) { + return false; + } + + /* + * In order to do a basic parse of SPDX-compatible "OR" license strings, we: + * - Remove surrounding brackets: "(mit or zlib)" -> "mit or zlib" + * - Split it into an array: "mit or zlib" -> [ "mit", "zlib" ] + * - Trim any remaining whitespace from each element + */ + const subLicenseTypes = formattedlicenseType + .replace( /^\(*/g, '' ) + .replace( /\)*$/, '' ) + .split( ' or ' ) + .map( ( e ) => e.trim() ); + + // We can then check our array of licenses against the allowedLicense. + return ( + undefined !== + subLicenseTypes.find( ( subLicenseType ) => + checkLicense( allowedLicense, subLicenseType ) + ) + ); +}; + +/** + * Check that all of the licenses for a package are compatible. + * + * This function is invoked when the licenses are a conjunctive ("AND") list of licenses. + * In that case, the software is only compatible if all of the licenses in the list are + * compatible. + * + * @param {Licenses} packageLicenses The licenses that a package is licensed under. + * @param {Licenses} compatibleLicenses The list of compatible licenses. + * + * @return {boolean} true if all of the packageLicenses appear in compatibleLicenses. + */ +function checkAllCompatible( packageLicenses, compatibleLicenses ) { + return packageLicenses.reduce( ( compatible, packageLicense ) => { + return ( + compatible && + compatibleLicenses.reduce( + ( found, allowedLicense ) => + found || checkLicense( allowedLicense, packageLicense ), + false + ) + ); + }, true ); +} + +function detectTypeFromLicenseText( licenseText ) { + // Check if the file contains any of the strings in licenseFileStrings. + return Object.keys( licenseFileStrings ).reduce( + ( stringDetectedType, licenseStringType ) => { + const licenseFileString = licenseFileStrings[ licenseStringType ]; + + return licenseFileString.reduce( + ( currentDetectedType, fileString ) => { + if ( licenseText.includes( fileString ) ) { + if ( currentDetectedType ) { + return currentDetectedType.concat( + ' AND ', + licenseStringType + ); + } + return licenseStringType; + } + return currentDetectedType; + }, + stringDetectedType + ); + }, + false + ); +} + +const reportedPackages = new Set(); + +/** + * @param {string} path + * @param {Licenses} licenses + * @return {void} + */ +function checkDepLicense( path, licenses ) { + if ( ! path ) { + return; + } + + const filename = path + '/package.json'; + if ( ! existsSync( filename ) ) { + process.stdout.write( `Unable to locate package.json in ${ path }.` ); + process.exit( 1 ); + } + + /* + * The package.json format can be kind of weird. We allow for the following formats: + * - { license: 'MIT' } + * - { license: { type: 'MIT' } } + * - { licenses: [ 'MIT', 'Zlib' ] } + * - { licenses: [ { type: 'MIT' }, { type: 'Zlib' } ] } + */ + const packageInfo = require( filename ); + const license = + packageInfo.license || + ( packageInfo.licenses && + packageInfo.licenses.map( ( l ) => l.type || l ).join( ' OR ' ) ); + let licenseType = typeof license === 'object' ? license.type : license; + + // Check if the license we've detected is telling us to look in the license file, instead. + if ( + licenseType && + licenseFiles.find( ( licenseFile ) => + licenseType.includes( licenseFile ) + ) + ) { + licenseType = undefined; + } + + if ( licenseType !== undefined ) { + let licenseTypes = [ licenseType ]; + if ( licenseType.includes( ' AND ' ) ) { + licenseTypes = licenseType + .replace( /^\(*/g, '' ) + .replace( /\)*$/, '' ) + .split( ' AND ' ) + .map( ( e ) => e.trim() ); + } + + if ( checkAllCompatible( licenseTypes, licenses ) ) { + return; + } + } + + /* + * If we haven't been able to detect a license in the package.json file, + * or the type was invalid, try reading it from the files defined in + * license files, instead. + */ + const detectedLicenseType = detectTypeFromLicenseFiles( path ); + if ( ! licenseType && ! detectedLicenseType ) { + return; + } + + let detectedLicenseTypes = [ detectedLicenseType ]; + if ( detectedLicenseType && detectedLicenseType.includes( ' AND ' ) ) { + detectedLicenseTypes = detectedLicenseType + .replace( /^\(*/g, '' ) + .replace( /\)*$/, '' ) + .split( ' AND ' ) + .map( ( e ) => e.trim() ); + } + + if ( checkAllCompatible( detectedLicenseTypes, licenses ) ) { + return; + } + + // Do not report same package twice. + if ( reportedPackages.has( packageInfo.name ) ) { + return; + } + + reportedPackages.add( packageInfo.name ); + + process.exitCode = 1; + process.stdout.write( + `${ ERROR_TEXT } Module ${ packageInfo.name } has an incompatible license '${ licenseType }'.\n` + ); +} + +/** + * + * @typedef Options + * @property {boolean} gpl2 Only allow GPL2 compatible licenses. + * @property {Array} [ignored] The list of ignored packages. + */ + +/** + * @param {Object} deps The dependencies tree. + * @param {Options} options + */ +function checkDepsInTree( deps, options ) { + const licenses = getLicenses( options.gpl2 ); + + for ( const key in deps ) { + const dep = deps[ key ]; + + if ( options.ignored?.includes( dep.name ) ) { + continue; + } + + if ( Object.keys( dep ).length === 0 ) { + continue; + } + + if ( ! dep.hasOwnProperty( 'path' ) && ! dep.missing ) { + if ( dep.hasOwnProperty( 'peerMissing' ) ) { + process.stdout.write( + `${ WARNING_TEXT } Unable to locate path for missing peer dep ${ dep.name }@${ dep.version }. ` + ); + } else { + process.exitCode = 1; + process.stdout.write( + `${ ERROR_TEXT } Unable to locate path for ${ dep.name }@${ dep.version }. ` + ); + } + } else if ( dep.missing ) { + for ( const problem of dep.problems ) { + process.stdout.write( `${ WARNING_TEXT } ${ problem }.\n` ); + } + } else { + checkDepLicense( dep.path, licenses ); + } + + if ( dep.hasOwnProperty( 'dependencies' ) ) { + checkDepsInTree( dep.dependencies, options ); + } + } +} + +/** + * @param {string} path The path to the package. + * @return {boolean} true if the package has a license, false if it + */ +function detectTypeFromLicenseFiles( path ) { + return licenseFiles.reduce( ( detectedType, licenseFile ) => { + // If another LICENSE file already had licenses in it, use those. + if ( detectedType ) { + return detectedType; + } + + const licensePath = path + '/' + licenseFile; + + if ( existsSync( licensePath ) ) { + const licenseText = readFileSync( licensePath ).toString(); + return detectTypeFromLicenseText( licenseText ); + } + + return detectedType; + }, false ); +} + +module.exports = { + detectTypeFromLicenseText, + checkAllCompatible, + checkDepsInTree, +}; diff --git a/packages/scripts/scripts/test/check-licenses.js b/packages/scripts/utils/test/license.js similarity index 96% rename from packages/scripts/scripts/test/check-licenses.js rename to packages/scripts/utils/test/license.js index 13116bd9222a06..2c188ba9a347da 100644 --- a/packages/scripts/scripts/test/check-licenses.js +++ b/packages/scripts/utils/test/license.js @@ -7,10 +7,7 @@ const path = require( 'path' ); /** * Internal dependencies */ -import { - detectTypeFromLicenseText, - checkAllCompatible, -} from '../check-licenses'; +import { detectTypeFromLicenseText, checkAllCompatible } from '../license'; describe( 'detectTypeFromLicenseText', () => { let licenseText; diff --git a/packages/scripts/scripts/test/licenses/apache2-mit.txt b/packages/scripts/utils/test/licenses/apache2-mit.txt similarity index 100% rename from packages/scripts/scripts/test/licenses/apache2-mit.txt rename to packages/scripts/utils/test/licenses/apache2-mit.txt diff --git a/packages/scripts/scripts/test/licenses/apache2.txt b/packages/scripts/utils/test/licenses/apache2.txt similarity index 100% rename from packages/scripts/scripts/test/licenses/apache2.txt rename to packages/scripts/utils/test/licenses/apache2.txt diff --git a/packages/scripts/scripts/test/licenses/bsd3clause.txt b/packages/scripts/utils/test/licenses/bsd3clause.txt similarity index 100% rename from packages/scripts/scripts/test/licenses/bsd3clause.txt rename to packages/scripts/utils/test/licenses/bsd3clause.txt diff --git a/packages/scripts/scripts/test/licenses/mit.txt b/packages/scripts/utils/test/licenses/mit.txt similarity index 100% rename from packages/scripts/scripts/test/licenses/mit.txt rename to packages/scripts/utils/test/licenses/mit.txt diff --git a/packages/scripts/scripts/test/licenses/w3cbsd.txt b/packages/scripts/utils/test/licenses/w3cbsd.txt similarity index 100% rename from packages/scripts/scripts/test/licenses/w3cbsd.txt rename to packages/scripts/utils/test/licenses/w3cbsd.txt