WebpackSPDXPlugin.js 6.3 KB

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