WebpackSPDXPlugin.js 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  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. * Emit licenses found in compilation to '.license' files
  59. * @param {webpack.Compilation} compilation Webpack compilation object
  60. * @param {*} callback Callback for old webpack versions
  61. */
  62. async emitLicenses(compilation, callback) {
  63. const logger = compilation.getLogger('spdx-plugin')
  64. // cache the node packages
  65. const packageInformation = new Map()
  66. const warnings = new Set()
  67. /** @type {Map<string, Set<webpack.Chunk>>} */
  68. const sourceMap = new Map()
  69. for (const chunk of compilation.chunks) {
  70. for (const file of chunk.files) {
  71. if (sourceMap.has(file)) {
  72. sourceMap.get(file).add(chunk)
  73. } else {
  74. sourceMap.set(file, new Set([chunk]))
  75. }
  76. }
  77. }
  78. for (const [asset, chunks] of sourceMap.entries()) {
  79. /** @type {Set<webpack.Module>} */
  80. const modules = new Set()
  81. /**
  82. * @param {webpack.Module} module
  83. */
  84. const addModule = (module) => {
  85. if (module && !modules.has(module)) {
  86. modules.add(module)
  87. for (const dep of module.dependencies) {
  88. addModule(compilation.moduleGraph.getModule(dep))
  89. }
  90. }
  91. }
  92. chunks.forEach((chunk) => chunk.getModules().forEach(addModule))
  93. const sources = [...modules].map((module) => module.identifier())
  94. .map((source) => {
  95. const skipped = [
  96. 'delegated',
  97. 'external',
  98. 'container entry',
  99. 'ignored',
  100. 'remote',
  101. 'data:',
  102. ]
  103. // Webpack sources that we can not infer license information or that is not included (external modules)
  104. if (skipped.some((prefix) => source.startsWith(prefix))) {
  105. return ''
  106. }
  107. // Internal webpack sources
  108. if (source.startsWith('webpack/runtime')) {
  109. return require.resolve('webpack')
  110. }
  111. // Handle webpack loaders
  112. if (source.includes('!')) {
  113. return source.split('!').at(-1)
  114. }
  115. if (source.includes('|')) {
  116. return source
  117. .split('|')
  118. .filter((s) => s.startsWith(path.sep))
  119. .at(0)
  120. }
  121. return source
  122. })
  123. .filter((s) => !!s)
  124. .map((s) => s.split('?', 2)[0])
  125. // Skip assets without modules, these are emitted by webpack plugins
  126. if (sources.length === 0) {
  127. logger.warn(`Skipping ${asset} because it does not contain any source information`)
  128. continue
  129. }
  130. /** packages used by the current asset
  131. * @type {Set<string>}
  132. */
  133. const packages = new Set()
  134. // packages is the list of packages used by the asset
  135. for (const sourcePath of sources) {
  136. const pkg = await this.#findPackage(path.dirname(sourcePath))
  137. if (!pkg) {
  138. logger.warn(`No package for source found (${sourcePath})`)
  139. continue
  140. }
  141. if (!packageInformation.has(pkg)) {
  142. // Get the information from the package
  143. const { author: packageAuthor, name, version, license: packageLicense, licenses } = JSON.parse(await fs.readFile(pkg))
  144. // Handle legacy packages
  145. let license = !packageLicense && licenses
  146. ? licenses.map((entry) => entry.type ?? entry).join(' OR ')
  147. : packageLicense
  148. if (license?.includes(' ') && !license?.startsWith('(')) {
  149. license = `(${license})`
  150. }
  151. // Handle both object style and string style author
  152. const author = typeof packageAuthor === 'object'
  153. ? `${packageAuthor.name}` + (packageAuthor.mail ? ` <${packageAuthor.mail}>` : '')
  154. : packageAuthor ?? `${name} developers`
  155. packageInformation.set(pkg, {
  156. version,
  157. // Fallback to directory name if name is not set
  158. name: name ?? path.basename(path.dirname(pkg)),
  159. author,
  160. license,
  161. })
  162. }
  163. packages.add(pkg)
  164. }
  165. let output = 'This file is generated from multiple sources. Included packages:\n'
  166. const authors = new Set()
  167. const licenses = new Set()
  168. for (const packageName of [...packages].sort()) {
  169. const pkg = packageInformation.get(packageName)
  170. const license = this.#options.override[pkg.name] ?? pkg.license
  171. // Emit warning if not already done
  172. if (!license && !warnings.has(pkg.name)) {
  173. logger.warn(`Missing license information for package ${pkg.name}, you should add it to the 'override' option.`)
  174. warnings.add(pkg.name)
  175. }
  176. licenses.add(license || 'unknown')
  177. authors.add(pkg.author)
  178. output += `- ${pkg.name}\n\t- version: ${pkg.version}\n\t- license: ${license}\n`
  179. }
  180. output = `\n\n${output}`
  181. for (const author of [...authors].sort()) {
  182. output = `SPDX-FileCopyrightText: ${author}\n${output}`
  183. }
  184. for (const license of [...licenses].sort()) {
  185. output = `SPDX-License-Identifier: ${license}\n${output}`
  186. }
  187. compilation.emitAsset(
  188. asset.split('?', 2)[0] + '.license',
  189. new webpack.sources.RawSource(output),
  190. )
  191. }
  192. if (callback) {
  193. return callback()
  194. }
  195. }
  196. }
  197. module.exports = WebpackSPDXPlugin