aboutsummaryrefslogtreecommitdiffstats
path: root/build
diff options
context:
space:
mode:
authorFerdinand Thiessen <opensource@fthiessen.de>2024-06-12 21:37:42 +0200
committerAndy Scherzinger <info@andy-scherzinger.de>2024-06-16 19:06:52 +0200
commit876beec5a7d4a993c94b2cd47ac28bc2c717ee5f (patch)
tree2e3bcb532cb91a5b07928dc3c8894d644256f7ff /build
parent424b51e630890b342093a3225b725e7b9257663d (diff)
downloadnextcloud-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.js216
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;