Skip to content

Commit 0d0908a

Browse files
susnuxAndyScherzinger
authored andcommitted
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>
1 parent 76e7389 commit 0d0908a

File tree

3 files changed

+232
-8
lines changed

3 files changed

+232
-8
lines changed

build/WebpackSPDXPlugin.js

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
"use strict";
2+
3+
/**
4+
* Party inspired by https://github.com/FormidableLabs/webpack-stats-plugin
5+
*
6+
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: MIT
8+
*/
9+
10+
const { constants } = require('node:fs')
11+
const fs = require('node:fs/promises')
12+
const path = require('node:path')
13+
const webpack = require('webpack')
14+
15+
class WebpackSPDXPlugin {
16+
#options
17+
18+
/**
19+
* @param {object} opts Parameters
20+
* @param {Record<string, string>} opts.override Override licenses for packages
21+
*/
22+
constructor(opts = {}) {
23+
this.#options = { override: {}, ...opts }
24+
}
25+
26+
apply(compiler) {
27+
compiler.hooks.thisCompilation.tap("spdx-plugin", (compilation) => {
28+
// `processAssets` is one of the last hooks before frozen assets.
29+
// We choose `PROCESS_ASSETS_STAGE_REPORT` which is the last possible
30+
// stage after which to emit.
31+
compilation.hooks.processAssets.tapPromise(
32+
{
33+
name: "spdx-plugin",
34+
stage: compilation.constructor.PROCESS_ASSETS_STAGE_REPORT
35+
},
36+
() => this.emitLicenses(compilation)
37+
)
38+
})
39+
}
40+
41+
/**
42+
* Find the nearest package.json
43+
* @param {string} dir Directory to start checking
44+
*/
45+
async #findPackage(dir) {
46+
if (!dir || dir === '/' || dir === '.') {
47+
return null
48+
}
49+
50+
const packageJson = `${dir}/package.json`
51+
try {
52+
await fs.access(packageJson, constants.F_OK)
53+
} catch (e) {
54+
return await this.#findPackage(path.dirname(dir))
55+
}
56+
57+
const { private: isPrivatePacket, name } = JSON.parse(await fs.readFile(packageJson))
58+
// "private" is set in internal package.json which should not be resolved but the parent package.json
59+
// Same if no name is set in package.json
60+
if (isPrivatePacket === true || !name) {
61+
return (await this.#findPackage(path.dirname(dir))) ?? packageJson
62+
}
63+
return packageJson
64+
}
65+
66+
/**
67+
*
68+
* @param {webpack.Compilation} compilation
69+
* @param {*} callback
70+
* @returns
71+
*/
72+
async emitLicenses(compilation, callback) {
73+
const moduleNames = (module) => module.modules?.map(moduleNames) ?? [module.name]
74+
const logger = compilation.getLogger('spdx-plugin')
75+
// cache the node packages
76+
const packageInformation = new Map()
77+
78+
const warnings = new Set()
79+
/** @type {Map<string, Set<webpack.Chunk>>} */
80+
const sourceMap = new Map()
81+
82+
for (const chunk of compilation.chunks) {
83+
for (const file of chunk.files) {
84+
if (sourceMap.has(file)) {
85+
sourceMap.get(file).add(chunk)
86+
} else {
87+
sourceMap.set(file, new Set([chunk]))
88+
}
89+
}
90+
}
91+
92+
for (const [asset, chunks] of sourceMap.entries()) {
93+
/** @type {Set<webpack.Module>} */
94+
const modules = new Set()
95+
/**
96+
* @param {webpack.Module} module
97+
*/
98+
const addModule = (module) => {
99+
if (module && !modules.has(module)) {
100+
modules.add(module)
101+
for (const dep of module.dependencies) {
102+
addModule(compilation.moduleGraph.getModule(dep))
103+
}
104+
}
105+
}
106+
chunks.forEach((chunk) => chunk.getModules().forEach(addModule))
107+
108+
const sources = [...modules].map((module) => module.identifier())
109+
.map((source) => {
110+
const skipped = [
111+
'delegated',
112+
'external',
113+
'container entry',
114+
'ignored',
115+
'remote',
116+
'data:',
117+
]
118+
// Webpack sources that we can not infer license information or that is not included (external modules)
119+
if (skipped.some((prefix) => source.startsWith(prefix))) {
120+
return ''
121+
}
122+
// Internal webpack sources
123+
if (source.startsWith('webpack/runtime')) {
124+
return require.resolve('webpack')
125+
}
126+
// Handle webpack loaders
127+
if (source.includes('!')) {
128+
return source.split('!').at(-1)
129+
}
130+
if (source.includes('|')) {
131+
return source
132+
.split('|')
133+
.filter((s) => s.startsWith(path.sep))
134+
.at(0)
135+
}
136+
return source
137+
})
138+
.filter((s) => !!s)
139+
.map((s) => s.split('?', 2)[0])
140+
141+
// Skip assets without modules, these are emitted by webpack plugins
142+
if (sources.length === 0) {
143+
logger.warn(`Skipping ${asset} because it does not contain any source information`)
144+
continue
145+
}
146+
147+
/** packages used by the current asset
148+
* @type {Set<string>}
149+
*/
150+
const packages = new Set()
151+
152+
// packages is the list of packages used by the asset
153+
for (const sourcePath of sources) {
154+
const pkg = await this.#findPackage(path.dirname(sourcePath))
155+
if (!pkg) {
156+
logger.warn(`No package for source found (${sourcePath})`)
157+
continue
158+
}
159+
160+
if (!packageInformation.has(pkg)) {
161+
// Get the information from the package
162+
const { author: packageAuthor, name, version, license: packageLicense, licenses } = JSON.parse(await fs.readFile(pkg))
163+
// Handle legacy packages
164+
let license = !packageLicense && licenses
165+
? licenses.map((entry) => entry.type ?? entry).join(' OR ')
166+
: packageLicense
167+
if (license?.includes(' ') && !license?.startsWith('(')) {
168+
license = `(${license})`
169+
}
170+
// Handle both object style and string style author
171+
const author = typeof packageAuthor === 'object'
172+
? `${packageAuthor.name}` + (packageAuthor.mail ? ` <${packageAuthor.mail}>` : '')
173+
: packageAuthor ?? `${name} developers`
174+
175+
packageInformation.set(pkg, {
176+
version,
177+
// Fallback to directory name if name is not set
178+
name: name ?? path.basename(path.dirname(pkg)),
179+
author,
180+
license,
181+
})
182+
}
183+
packages.add(pkg)
184+
}
185+
186+
let output = 'This file is generated from multiple sources. Included packages:\n'
187+
const authors = new Set()
188+
const licenses = new Set()
189+
for (const packageName of [...packages].sort()) {
190+
const pkg = packageInformation.get(packageName)
191+
const license = this.#options.override[pkg.name] ?? pkg.license
192+
// Emit warning if not already done
193+
if (!license && !warnings.has(pkg.name)) {
194+
logger.warn(`Missing license information for package ${pkg.name}, you should add it to the 'override' option.`)
195+
warnings.add(pkg.name)
196+
}
197+
licenses.add(license || 'unknown')
198+
authors.add(pkg.author)
199+
output += `\n- ${pkg.name}\n\t- version: ${pkg.version}\n\t- license: ${license}`
200+
}
201+
output += `\n\nSPDX-License-Identifier: ${[...licenses].sort().join(' AND ')}\n`
202+
output += [...authors].sort().map((author) => `SPDX-FileCopyrightText: ${author}`).join('\n');
203+
204+
compilation.emitAsset(
205+
asset.split('?', 2)[0] + '.license',
206+
new webpack.sources.RawSource(output),
207+
)
208+
}
209+
210+
if (callback) {
211+
return void callback()
212+
}
213+
}
214+
}
215+
216+
module.exports = WebpackSPDXPlugin;

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"name": "nextcloud",
33
"version": "1.0.0",
44
"description": "Nextcloud Server",
5+
"author": "Nextcloud GmbH and Nextcloud contributors",
56
"private": true,
67
"directories": {
78
"lib": "lib",
@@ -188,6 +189,7 @@
188189
"webpack": "^5.91.0",
189190
"webpack-cli": "^5.0.2",
190191
"webpack-merge": "^5.8.0",
192+
"webpack-stats-plugin": "^1.1.3",
191193
"workbox-webpack-plugin": "^7.0.0"
192194
},
193195
"browserslist": [

webpack.common.js

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@
44
* SPDX-License-Identifier: AGPL-3.0-or-later
55
*/
66
const { VueLoaderPlugin } = require('vue-loader')
7+
const { readFileSync } = require('fs')
78
const path = require('path')
9+
810
const BabelLoaderExcludeNodeModulesExcept = require('babel-loader-exclude-node-modules-except')
911
const webpack = require('webpack')
1012
const NodePolyfillPlugin = require('node-polyfill-webpack-plugin')
1113
const WorkboxPlugin = require('workbox-webpack-plugin')
14+
const WebpackSPDXPlugin = require('./build/WebpackSPDXPlugin.js')
1215

1316
const modules = require('./webpack.modules.js')
14-
const { readFileSync } = require('fs')
1517

1618
const appVersion = readFileSync('./version.php').toString().match(/OC_VersionString[^']+'([^']+)/)?.[1] ?? 'unknown'
1719

@@ -152,14 +154,11 @@ module.exports = {
152154
// Lazy load the Terser plugin
153155
const TerserPlugin = require('terser-webpack-plugin')
154156
new TerserPlugin({
155-
extractComments: {
156-
condition: /^\**!|@license|@copyright|SPDX-License-Identifier|SPDX-FileCopyrightText/i,
157-
filename: (fileData) => {
158-
// The "fileData" argument contains object with "filename", "basename", "query" and "hash"
159-
return `${fileData.filename}.license${fileData.query}`
160-
},
161-
},
157+
extractComments: false,
162158
terserOptions: {
159+
format: {
160+
comments: false,
161+
},
163162
compress: {
164163
passes: 2,
165164
},
@@ -239,6 +238,13 @@ module.exports = {
239238
resourceRegExp: /^\.\/locale$/,
240239
contextRegExp: /moment\/min$/,
241240
}),
241+
242+
// Generate reuse license files
243+
new WebpackSPDXPlugin({
244+
override: {
245+
select2: 'MIT',
246+
}
247+
}),
242248
],
243249
externals: {
244250
OC: 'OC',

0 commit comments

Comments
 (0)