diff options
author | Ferdinand Thiessen <opensource@fthiessen.de> | 2024-06-12 21:37:42 +0200 |
---|---|---|
committer | Andy Scherzinger <info@andy-scherzinger.de> | 2024-06-16 19:06:52 +0200 |
commit | 876beec5a7d4a993c94b2cd47ac28bc2c717ee5f (patch) | |
tree | 2e3bcb532cb91a5b07928dc3c8894d644256f7ff /build | |
parent | 424b51e630890b342093a3225b725e7b9257663d (diff) | |
download | nextcloud-server-876beec5a7d4a993c94b2cd47ac28bc2c717ee5f.tar.gz nextcloud-server-876beec5a7d4a993c94b2cd47ac28bc2c717ee5f.zip |
chore: Add webpack plugin to properly extract licenses used in compiled assets
This will create proper extracted license information for assets and stores it in `fist/file.js.license`.
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
Diffstat (limited to 'build')
-rw-r--r-- | build/WebpackSPDXPlugin.js | 216 |
1 files changed, 216 insertions, 0 deletions
diff --git a/build/WebpackSPDXPlugin.js b/build/WebpackSPDXPlugin.js new file mode 100644 index 00000000000..e9eeccca0f1 --- /dev/null +++ b/build/WebpackSPDXPlugin.js @@ -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; |