Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: Properly extract copyright information from bundled packages #45833

Merged
merged 3 commits into from
Jun 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 216 additions & 0 deletions build/WebpackSPDXPlugin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
"use strict";

/**
* Party inspired by https://github.com/FormidableLabs/webpack-stats-plugin
*
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: MIT
*/

const { constants } = require('node:fs')
const fs = require('node:fs/promises')
const path = require('node:path')
const webpack = require('webpack')

class WebpackSPDXPlugin {
#options

/**
* @param {object} opts Parameters
* @param {Record<string, string>} opts.override Override licenses for packages
*/
constructor(opts = {}) {
this.#options = { override: {}, ...opts }
}

apply(compiler) {
compiler.hooks.thisCompilation.tap("spdx-plugin", (compilation) => {
// `processAssets` is one of the last hooks before frozen assets.
// We choose `PROCESS_ASSETS_STAGE_REPORT` which is the last possible
// stage after which to emit.
compilation.hooks.processAssets.tapPromise(
{
name: "spdx-plugin",
stage: compilation.constructor.PROCESS_ASSETS_STAGE_REPORT
},
() => this.emitLicenses(compilation)
)
})
}

/**
* Find the nearest package.json
* @param {string} dir Directory to start checking
*/
async #findPackage(dir) {
if (!dir || dir === '/' || dir === '.') {
return null
}

const packageJson = `${dir}/package.json`
try {
await fs.access(packageJson, constants.F_OK)
} catch (e) {
return await this.#findPackage(path.dirname(dir))
}

const { private: isPrivatePacket, name } = JSON.parse(await fs.readFile(packageJson))
// "private" is set in internal package.json which should not be resolved but the parent package.json
// Same if no name is set in package.json
if (isPrivatePacket === true || !name) {
return (await this.#findPackage(path.dirname(dir))) ?? packageJson
}
return packageJson
}

/**
*
* @param {webpack.Compilation} compilation
* @param {*} callback
* @returns
*/
async emitLicenses(compilation, callback) {
const moduleNames = (module) => module.modules?.map(moduleNames) ?? [module.name]
const logger = compilation.getLogger('spdx-plugin')
// cache the node packages
const packageInformation = new Map()

const warnings = new Set()
/** @type {Map<string, Set<webpack.Chunk>>} */
const sourceMap = new Map()

for (const chunk of compilation.chunks) {
for (const file of chunk.files) {
if (sourceMap.has(file)) {
sourceMap.get(file).add(chunk)
} else {
sourceMap.set(file, new Set([chunk]))
}
}
}

for (const [asset, chunks] of sourceMap.entries()) {
/** @type {Set<webpack.Module>} */
const modules = new Set()
/**
* @param {webpack.Module} module
*/
const addModule = (module) => {
if (module && !modules.has(module)) {
modules.add(module)
for (const dep of module.dependencies) {
addModule(compilation.moduleGraph.getModule(dep))
}
}
}
chunks.forEach((chunk) => chunk.getModules().forEach(addModule))

const sources = [...modules].map((module) => module.identifier())
.map((source) => {
const skipped = [
'delegated',
'external',
'container entry',
'ignored',
'remote',
'data:',
]
// Webpack sources that we can not infer license information or that is not included (external modules)
if (skipped.some((prefix) => source.startsWith(prefix))) {
return ''
}
// Internal webpack sources
if (source.startsWith('webpack/runtime')) {
return require.resolve('webpack')
}
// Handle webpack loaders
if (source.includes('!')) {
return source.split('!').at(-1)
}
if (source.includes('|')) {
return source
.split('|')
.filter((s) => s.startsWith(path.sep))
.at(0)
}
return source
})
.filter((s) => !!s)
.map((s) => s.split('?', 2)[0])

// Skip assets without modules, these are emitted by webpack plugins
if (sources.length === 0) {
logger.warn(`Skipping ${asset} because it does not contain any source information`)
continue
}

/** packages used by the current asset
* @type {Set<string>}
*/
const packages = new Set()

// packages is the list of packages used by the asset
for (const sourcePath of sources) {
const pkg = await this.#findPackage(path.dirname(sourcePath))
if (!pkg) {
logger.warn(`No package for source found (${sourcePath})`)
continue
}

if (!packageInformation.has(pkg)) {
// Get the information from the package
const { author: packageAuthor, name, version, license: packageLicense, licenses } = JSON.parse(await fs.readFile(pkg))
// Handle legacy packages
let license = !packageLicense && licenses
? licenses.map((entry) => entry.type ?? entry).join(' OR ')
: packageLicense
if (license?.includes(' ') && !license?.startsWith('(')) {
license = `(${license})`
}
// Handle both object style and string style author
const author = typeof packageAuthor === 'object'
? `${packageAuthor.name}` + (packageAuthor.mail ? ` <${packageAuthor.mail}>` : '')
: packageAuthor ?? `${name} developers`

packageInformation.set(pkg, {
version,
// Fallback to directory name if name is not set
name: name ?? path.basename(path.dirname(pkg)),
author,
license,
})
}
packages.add(pkg)
}

let output = 'This file is generated from multiple sources. Included packages:\n'
const authors = new Set()
const licenses = new Set()
for (const packageName of [...packages].sort()) {
const pkg = packageInformation.get(packageName)
const license = this.#options.override[pkg.name] ?? pkg.license
// Emit warning if not already done
if (!license && !warnings.has(pkg.name)) {
logger.warn(`Missing license information for package ${pkg.name}, you should add it to the 'override' option.`)
warnings.add(pkg.name)
}
licenses.add(license || 'unknown')
authors.add(pkg.author)
output += `\n- ${pkg.name}\n\t- version: ${pkg.version}\n\t- license: ${license}`
}
output += `\n\nSPDX-License-Identifier: ${[...licenses].sort().join(' AND ')}\n`
output += [...authors].sort().map((author) => `SPDX-FileCopyrightText: ${author}`).join('\n');

compilation.emitAsset(
asset.split('?', 2)[0] + '.license',
new webpack.sources.RawSource(output),
)
}

if (callback) {
return void callback()
}
}
}

module.exports = WebpackSPDXPlugin;
3 changes: 1 addition & 2 deletions dist/1110-1110.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

133 changes: 112 additions & 21 deletions dist/1110-1110.js.license
Original file line number Diff line number Diff line change
@@ -1,21 +1,112 @@
/**
* @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>
*
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
This file is generated from multiple sources. Included packages:

- @nextcloud/dialogs
- version: 5.3.1
- license: GPL-3.0-or-later
- assert
- version: 2.1.0
- license: MIT
- available-typed-arrays
- version: 1.0.7
- license: MIT
- call-bind
- version: 1.0.7
- license: MIT
- console-browserify
- version: 1.2.0
- license: MIT
- define-data-property
- version: 1.1.4
- license: MIT
- define-properties
- version: 1.2.1
- license: MIT
- es-define-property
- version: 1.0.0
- license: MIT
- es-errors
- version: 1.3.0
- license: MIT
- for-each
- version: 0.3.3
- license: MIT
- function-bind
- version: 1.1.2
- license: MIT
- get-intrinsic
- version: 1.2.4
- license: MIT
- gopd
- version: 1.0.1
- license: MIT
- has-property-descriptors
- version: 1.0.2
- license: MIT
- has-proto
- version: 1.0.3
- license: MIT
- has-symbols
- version: 1.0.3
- license: MIT
- has-tostringtag
- version: 1.0.2
- license: MIT
- hasown
- version: 2.0.1
- license: MIT
- inherits
- version: 2.0.4
- license: ISC
- is-arguments
- version: 1.1.1
- license: MIT
- is-callable
- version: 1.2.7
- license: MIT
- is-generator-function
- version: 1.0.10
- license: MIT
- is-nan
- version: 1.3.2
- license: MIT
- is-typed-array
- version: 1.1.13
- license: MIT
- object-is
- version: 1.1.5
- license: MIT
- object-keys
- version: 1.1.1
- license: MIT
- object.assign
- version: 4.1.5
- license: MIT
- possible-typed-array-names
- version: 1.0.0
- license: MIT
- process
- version: 0.11.10
- license: MIT
- set-function-length
- version: 1.2.1
- license: MIT
- util
- version: 0.12.5
- license: MIT
- vue
- version: 2.7.16
- license: MIT
- which-typed-array
- version: 1.1.14
- license: MIT

SPDX-License-Identifier: GPL-3.0-or-later AND ISC AND MIT
SPDX-FileCopyrightText: @nextcloud/dialogs developers
SPDX-FileCopyrightText: Evan You
SPDX-FileCopyrightText: Jordan Harband
SPDX-FileCopyrightText: Jordan Harband <ljharb@gmail.com>
SPDX-FileCopyrightText: Joyent
SPDX-FileCopyrightText: Raynos <raynos2@gmail.com>
SPDX-FileCopyrightText: Roman Shtylman <shtylman@gmail.com>
SPDX-FileCopyrightText: assert developers
SPDX-FileCopyrightText: inherits developers
2 changes: 1 addition & 1 deletion dist/1110-1110.js.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading