@cdxgen/cdxgen 13.1.0 → 13.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (283) hide show
  1. package/README.md +32 -15
  2. package/bin/cdxgen.js +243 -122
  3. package/bin/evinse.js +7 -0
  4. package/bin/sign.js +88 -14
  5. package/bin/tracebom.js +64 -4
  6. package/bin/validate.js +29 -7
  7. package/bin/verify.js +79 -39
  8. package/data/crypto-oid.json +88 -12
  9. package/data/remediations.json +47 -0
  10. package/data/rules/ai-bom.yaml +78 -0
  11. package/data/rules/cargo-rust.yaml +132 -0
  12. package/data/rules/mcp-servers.yaml +122 -0
  13. package/lib/audit/targets.js +6 -1
  14. package/lib/cli/bomAssembly.js +117 -15
  15. package/lib/cli/cliOptions.js +93 -1
  16. package/lib/cli/index.js +235 -5
  17. package/lib/cli/jsBom.js +108 -26
  18. package/lib/cli/jvmBom.js +68 -12
  19. package/lib/cli/managedBom.js +264 -41
  20. package/lib/cli/nativeBom.js +923 -15
  21. package/lib/core/activity.js +120 -2
  22. package/lib/core/env.js +44 -7
  23. package/lib/core/fs.js +174 -6
  24. package/lib/core/metadataProperties.js +97 -0
  25. package/lib/core/projectConfig.js +31 -4
  26. package/lib/core/userAgent.js +151 -0
  27. package/lib/ecosystems/bunutils.js +269 -101
  28. package/lib/ecosystems/cargoCache.js +364 -0
  29. package/lib/ecosystems/caxa.js +447 -7
  30. package/lib/ecosystems/cmakeResolver.js +66 -0
  31. package/lib/ecosystems/cppEvidence.js +104 -33
  32. package/lib/ecosystems/ecosystems.js +260 -99
  33. package/lib/ecosystems/golem.js +11 -1
  34. package/lib/ecosystems/gradleutils.js +185 -9
  35. package/lib/ecosystems/jsEvidence.js +19 -20
  36. package/lib/ecosystems/kosi.js +1263 -0
  37. package/lib/ecosystems/npmutils.js +110 -9
  38. package/lib/ecosystems/parsers-crystal.js +179 -0
  39. package/lib/ecosystems/parsers-dotnet.js +120 -49
  40. package/lib/ecosystems/parsers-elm.js +816 -0
  41. package/lib/ecosystems/parsers-gleam.js +1 -1
  42. package/lib/ecosystems/parsers-go.js +47 -9
  43. package/lib/ecosystems/parsers-js.js +171 -50
  44. package/lib/ecosystems/parsers-julia.js +268 -0
  45. package/lib/ecosystems/parsers-lua.js +218 -0
  46. package/lib/ecosystems/parsers-misc.js +919 -161
  47. package/lib/ecosystems/parsers-nim.js +221 -0
  48. package/lib/ecosystems/parsers-opam.js +180 -0
  49. package/lib/ecosystems/parsers-perl.js +277 -0
  50. package/lib/ecosystems/parsers-python.js +27 -15
  51. package/lib/ecosystems/parsers-r.js +148 -0
  52. package/lib/ecosystems/parsers-rebar.js +411 -0
  53. package/lib/ecosystems/parsers-rust.js +954 -33
  54. package/lib/ecosystems/parsers-spack.js +204 -0
  55. package/lib/ecosystems/parsers-stack.js +264 -0
  56. package/lib/ecosystems/parsers-terraform.js +194 -0
  57. package/lib/ecosystems/rubyutils.js +1426 -166
  58. package/lib/ecosystems/rusi.js +158 -7
  59. package/lib/evinser/evinser.js +547 -129
  60. package/lib/evinser/swiftsem.js +1914 -302
  61. package/lib/helpers/agenticToolutils.js +925 -0
  62. package/lib/helpers/bomSigner.js +652 -221
  63. package/lib/inventory/agentFormulationParser.js +57 -4
  64. package/lib/inventory/aiCollector.js +504 -10
  65. package/lib/inventory/aiInventory.js +12 -0
  66. package/lib/inventory/analyzer.js +488 -30
  67. package/lib/inventory/astWorker.js +6 -0
  68. package/lib/inventory/atomUtils.js +568 -31
  69. package/lib/inventory/auditCategories.js +8 -0
  70. package/lib/inventory/cbomutils.js +30 -0
  71. package/lib/inventory/cdxrs.js +110 -14
  72. package/lib/inventory/communityAiConfigParser.js +215 -2
  73. package/lib/inventory/depsUtils.js +183 -11
  74. package/lib/inventory/display.js +4 -5
  75. package/lib/inventory/dosai.js +627 -13
  76. package/lib/inventory/dynamic.js +39 -1
  77. package/lib/inventory/envcontext.js +9 -6
  78. package/lib/inventory/evidenceUtils.js +26 -5
  79. package/lib/inventory/fetchBatch.js +190 -19
  80. package/lib/inventory/fetchRate.js +74 -11
  81. package/lib/inventory/forgeEnricher.js +3 -2
  82. package/lib/inventory/jvmToolEnv.js +6 -5
  83. package/lib/inventory/mcpConfigParser.js +166 -2
  84. package/lib/inventory/mcpDiscovery.js +11 -0
  85. package/lib/inventory/mcpServerManifest.js +389 -0
  86. package/lib/inventory/mcpbBundle.js +419 -0
  87. package/lib/inventory/plugins.js +3 -0
  88. package/lib/inventory/protobom.js +62 -89
  89. package/lib/inventory/protobomLoader.js +4 -0
  90. package/lib/inventory/purl.js +152 -3
  91. package/lib/inventory/rustFormulationParser.js +1 -1
  92. package/lib/inventory/sbomDocument.js +2 -70
  93. package/lib/inventory/source.js +47 -23
  94. package/lib/inventory/stylePackageRefs.js +41 -0
  95. package/lib/inventory/svelteUtils.js +524 -0
  96. package/lib/inventory/traceRunner.js +210 -21
  97. package/lib/managers/binary.js +115 -24
  98. package/lib/managers/docker.js +440 -20
  99. package/lib/managers/oci.js +39 -1
  100. package/lib/parsers/cmakeLists.js +452 -0
  101. package/lib/parsers/huggingfaceUtils.js +15 -7
  102. package/lib/parsers/largeJson.js +454 -0
  103. package/lib/parsers/npmrc.js +19 -1
  104. package/lib/parsers/onnx.js +294 -0
  105. package/lib/parsers/safetensors.js +114 -0
  106. package/lib/stages/postgen/introspection/markers.js +29 -6
  107. package/lib/stages/postgen/postgen.js +237 -16
  108. package/lib/third-party/arborist/snapshots/calc-dep-flags.test.cjs +462 -0
  109. package/lib/validator/bomValidator.js +6 -16
  110. package/lib/validator/index.js +48 -10
  111. package/lib/validator/reporters/console.js +3 -0
  112. package/lib/validator/reporters/json.js +1 -0
  113. package/lib/validator/reporters/sarif.js +7 -0
  114. package/package.json +27 -27
  115. package/types/lib/audit/targets.d.ts.map +1 -1
  116. package/types/lib/cli/bomAssembly.d.ts +2 -1
  117. package/types/lib/cli/bomAssembly.d.ts.map +1 -1
  118. package/types/lib/cli/cliOptions.d.ts +33 -1
  119. package/types/lib/cli/cliOptions.d.ts.map +1 -1
  120. package/types/lib/cli/index.d.ts +2 -2
  121. package/types/lib/cli/index.d.ts.map +1 -1
  122. package/types/lib/cli/jsBom.d.ts +23 -0
  123. package/types/lib/cli/jsBom.d.ts.map +1 -1
  124. package/types/lib/cli/jvmBom.d.ts.map +1 -1
  125. package/types/lib/cli/managedBom.d.ts +33 -0
  126. package/types/lib/cli/managedBom.d.ts.map +1 -1
  127. package/types/lib/cli/nativeBom.d.ts +88 -8
  128. package/types/lib/cli/nativeBom.d.ts.map +1 -1
  129. package/types/lib/core/activity.d.ts.map +1 -1
  130. package/types/lib/core/env.d.ts +21 -0
  131. package/types/lib/core/env.d.ts.map +1 -1
  132. package/types/lib/core/fs.d.ts +55 -0
  133. package/types/lib/core/fs.d.ts.map +1 -1
  134. package/types/lib/core/metadataProperties.d.ts +39 -0
  135. package/types/lib/core/metadataProperties.d.ts.map +1 -0
  136. package/types/lib/core/projectConfig.d.ts +6 -2
  137. package/types/lib/core/projectConfig.d.ts.map +1 -1
  138. package/types/lib/core/userAgent.d.ts +56 -0
  139. package/types/lib/core/userAgent.d.ts.map +1 -0
  140. package/types/lib/ecosystems/bunutils.d.ts +12 -5
  141. package/types/lib/ecosystems/bunutils.d.ts.map +1 -1
  142. package/types/lib/ecosystems/cargoCache.d.ts +75 -0
  143. package/types/lib/ecosystems/cargoCache.d.ts.map +1 -0
  144. package/types/lib/ecosystems/caxa.d.ts +14 -0
  145. package/types/lib/ecosystems/caxa.d.ts.map +1 -1
  146. package/types/lib/ecosystems/cmakeResolver.d.ts +23 -0
  147. package/types/lib/ecosystems/cmakeResolver.d.ts.map +1 -1
  148. package/types/lib/ecosystems/cppEvidence.d.ts +2 -2
  149. package/types/lib/ecosystems/cppEvidence.d.ts.map +1 -1
  150. package/types/lib/ecosystems/ecosystems.d.ts +15 -1
  151. package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
  152. package/types/lib/ecosystems/golem.d.ts.map +1 -1
  153. package/types/lib/ecosystems/gradleutils.d.ts +40 -4
  154. package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
  155. package/types/lib/ecosystems/jsEvidence.d.ts +5 -2
  156. package/types/lib/ecosystems/jsEvidence.d.ts.map +1 -1
  157. package/types/lib/ecosystems/kosi.d.ts +86 -0
  158. package/types/lib/ecosystems/kosi.d.ts.map +1 -0
  159. package/types/lib/ecosystems/npmutils.d.ts +42 -0
  160. package/types/lib/ecosystems/npmutils.d.ts.map +1 -1
  161. package/types/lib/ecosystems/parsers-crystal.d.ts +27 -0
  162. package/types/lib/ecosystems/parsers-crystal.d.ts.map +1 -0
  163. package/types/lib/ecosystems/parsers-dotnet.d.ts +19 -0
  164. package/types/lib/ecosystems/parsers-dotnet.d.ts.map +1 -1
  165. package/types/lib/ecosystems/parsers-elm.d.ts +79 -0
  166. package/types/lib/ecosystems/parsers-elm.d.ts.map +1 -0
  167. package/types/lib/ecosystems/parsers-go.d.ts +10 -0
  168. package/types/lib/ecosystems/parsers-go.d.ts.map +1 -1
  169. package/types/lib/ecosystems/parsers-js.d.ts +10 -4
  170. package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
  171. package/types/lib/ecosystems/parsers-julia.d.ts +57 -0
  172. package/types/lib/ecosystems/parsers-julia.d.ts.map +1 -0
  173. package/types/lib/ecosystems/parsers-lua.d.ts +50 -0
  174. package/types/lib/ecosystems/parsers-lua.d.ts.map +1 -0
  175. package/types/lib/ecosystems/parsers-misc.d.ts +90 -3
  176. package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
  177. package/types/lib/ecosystems/parsers-nim.d.ts +43 -0
  178. package/types/lib/ecosystems/parsers-nim.d.ts.map +1 -0
  179. package/types/lib/ecosystems/parsers-opam.d.ts +31 -0
  180. package/types/lib/ecosystems/parsers-opam.d.ts.map +1 -0
  181. package/types/lib/ecosystems/parsers-perl.d.ts +36 -0
  182. package/types/lib/ecosystems/parsers-perl.d.ts.map +1 -0
  183. package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
  184. package/types/lib/ecosystems/parsers-r.d.ts +35 -0
  185. package/types/lib/ecosystems/parsers-r.d.ts.map +1 -0
  186. package/types/lib/ecosystems/parsers-rebar.d.ts +42 -0
  187. package/types/lib/ecosystems/parsers-rebar.d.ts.map +1 -0
  188. package/types/lib/ecosystems/parsers-rust.d.ts +199 -0
  189. package/types/lib/ecosystems/parsers-rust.d.ts.map +1 -1
  190. package/types/lib/ecosystems/parsers-spack.d.ts +33 -0
  191. package/types/lib/ecosystems/parsers-spack.d.ts.map +1 -0
  192. package/types/lib/ecosystems/parsers-stack.d.ts +35 -0
  193. package/types/lib/ecosystems/parsers-stack.d.ts.map +1 -0
  194. package/types/lib/ecosystems/parsers-terraform.d.ts +34 -0
  195. package/types/lib/ecosystems/parsers-terraform.d.ts.map +1 -0
  196. package/types/lib/ecosystems/pythonutils.d.ts +1 -1
  197. package/types/lib/ecosystems/rubyutils.d.ts +143 -2
  198. package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
  199. package/types/lib/ecosystems/rusi.d.ts +13 -0
  200. package/types/lib/ecosystems/rusi.d.ts.map +1 -1
  201. package/types/lib/evinser/evinser.d.ts +81 -2
  202. package/types/lib/evinser/evinser.d.ts.map +1 -1
  203. package/types/lib/evinser/swiftsem.d.ts +280 -15
  204. package/types/lib/evinser/swiftsem.d.ts.map +1 -1
  205. package/types/lib/helpers/agenticToolutils.d.ts +87 -0
  206. package/types/lib/helpers/agenticToolutils.d.ts.map +1 -0
  207. package/types/lib/helpers/bomSigner.d.ts +106 -13
  208. package/types/lib/helpers/bomSigner.d.ts.map +1 -1
  209. package/types/lib/inventory/agentFormulationParser.d.ts.map +1 -1
  210. package/types/lib/inventory/aiCollector.d.ts.map +1 -1
  211. package/types/lib/inventory/aiInventory.d.ts.map +1 -1
  212. package/types/lib/inventory/analyzer.d.ts.map +1 -1
  213. package/types/lib/inventory/atomUtils.d.ts +145 -3
  214. package/types/lib/inventory/atomUtils.d.ts.map +1 -1
  215. package/types/lib/inventory/auditCategories.d.ts +4 -0
  216. package/types/lib/inventory/auditCategories.d.ts.map +1 -1
  217. package/types/lib/inventory/cbomutils.d.ts.map +1 -1
  218. package/types/lib/inventory/cdxrs.d.ts +8 -3
  219. package/types/lib/inventory/cdxrs.d.ts.map +1 -1
  220. package/types/lib/inventory/communityAiConfigParser.d.ts.map +1 -1
  221. package/types/lib/inventory/depsUtils.d.ts +15 -0
  222. package/types/lib/inventory/depsUtils.d.ts.map +1 -1
  223. package/types/lib/inventory/display.d.ts.map +1 -1
  224. package/types/lib/inventory/dosai.d.ts +87 -1
  225. package/types/lib/inventory/dosai.d.ts.map +1 -1
  226. package/types/lib/inventory/dynamic.d.ts.map +1 -1
  227. package/types/lib/inventory/envcontext.d.ts.map +1 -1
  228. package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
  229. package/types/lib/inventory/fetchBatch.d.ts +34 -0
  230. package/types/lib/inventory/fetchBatch.d.ts.map +1 -1
  231. package/types/lib/inventory/fetchRate.d.ts +9 -6
  232. package/types/lib/inventory/fetchRate.d.ts.map +1 -1
  233. package/types/lib/inventory/forgeEnricher.d.ts.map +1 -1
  234. package/types/lib/inventory/jvmToolEnv.d.ts +5 -4
  235. package/types/lib/inventory/jvmToolEnv.d.ts.map +1 -1
  236. package/types/lib/inventory/mcpConfigParser.d.ts.map +1 -1
  237. package/types/lib/inventory/mcpDiscovery.d.ts.map +1 -1
  238. package/types/lib/inventory/mcpServerManifest.d.ts +19 -0
  239. package/types/lib/inventory/mcpServerManifest.d.ts.map +1 -0
  240. package/types/lib/inventory/mcpbBundle.d.ts +19 -0
  241. package/types/lib/inventory/mcpbBundle.d.ts.map +1 -0
  242. package/types/lib/inventory/plugins.d.ts.map +1 -1
  243. package/types/lib/inventory/protobom.d.ts +18 -13
  244. package/types/lib/inventory/protobom.d.ts.map +1 -1
  245. package/types/lib/inventory/protobomLoader.d.ts.map +1 -1
  246. package/types/lib/inventory/purl.d.ts +71 -2
  247. package/types/lib/inventory/purl.d.ts.map +1 -1
  248. package/types/lib/inventory/sbomDocument.d.ts.map +1 -1
  249. package/types/lib/inventory/source.d.ts.map +1 -1
  250. package/types/lib/inventory/stylePackageRefs.d.ts +17 -0
  251. package/types/lib/inventory/stylePackageRefs.d.ts.map +1 -0
  252. package/types/lib/inventory/svelteUtils.d.ts +124 -0
  253. package/types/lib/inventory/svelteUtils.d.ts.map +1 -0
  254. package/types/lib/inventory/traceRunner.d.ts +47 -1
  255. package/types/lib/inventory/traceRunner.d.ts.map +1 -1
  256. package/types/lib/managers/binary.d.ts +30 -1
  257. package/types/lib/managers/binary.d.ts.map +1 -1
  258. package/types/lib/managers/docker.d.ts +29 -0
  259. package/types/lib/managers/docker.d.ts.map +1 -1
  260. package/types/lib/managers/oci.d.ts +14 -0
  261. package/types/lib/managers/oci.d.ts.map +1 -1
  262. package/types/lib/parsers/cmakeLists.d.ts +76 -0
  263. package/types/lib/parsers/cmakeLists.d.ts.map +1 -0
  264. package/types/lib/parsers/huggingfaceUtils.d.ts +8 -1
  265. package/types/lib/parsers/huggingfaceUtils.d.ts.map +1 -1
  266. package/types/lib/parsers/largeJson.d.ts +52 -0
  267. package/types/lib/parsers/largeJson.d.ts.map +1 -0
  268. package/types/lib/parsers/npmrc.d.ts +5 -0
  269. package/types/lib/parsers/npmrc.d.ts.map +1 -1
  270. package/types/lib/parsers/onnx.d.ts +28 -0
  271. package/types/lib/parsers/onnx.d.ts.map +1 -0
  272. package/types/lib/parsers/safetensors.d.ts +22 -0
  273. package/types/lib/parsers/safetensors.d.ts.map +1 -0
  274. package/types/lib/stages/postgen/introspection/markers.d.ts +3 -2
  275. package/types/lib/stages/postgen/introspection/markers.d.ts.map +1 -1
  276. package/types/lib/stages/postgen/postgen.d.ts +33 -0
  277. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  278. package/types/lib/validator/bomValidator.d.ts.map +1 -1
  279. package/types/lib/validator/index.d.ts +10 -1
  280. package/types/lib/validator/index.d.ts.map +1 -1
  281. package/types/lib/validator/reporters/console.d.ts.map +1 -1
  282. package/types/lib/validator/reporters/json.d.ts.map +1 -1
  283. package/types/lib/validator/reporters/sarif.d.ts.map +1 -1
@@ -1,6 +1,14 @@
1
1
  import { readdirSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { basename, dirname, join } from "node:path";
3
+ import {
4
+ basename,
5
+ dirname,
6
+ isAbsolute,
7
+ join,
8
+ relative,
9
+ resolve,
10
+ sep,
11
+ } from "node:path";
4
12
  import process from "node:process";
5
13
 
6
14
  import { build, Purl } from "@cdxgen/cdx-purl";
@@ -14,13 +22,16 @@ import {
14
22
  } from "../core/activity.js";
15
23
  import { shouldFetchLicense } from "../core/env.js";
16
24
  import { getAllFiles, safeExistsSync, safeSpawnSync } from "../core/fs.js";
25
+ import { thoughtLog } from "../core/logger.js";
17
26
  import { dirNameStr, isWin } from "../core/paths.js";
27
+ import { mergeDependencies } from "../inventory/depsUtils.js";
18
28
  import {
19
29
  prefetchEnabled,
20
30
  prefetchedResponse,
21
31
  prefetchJson,
22
32
  recordPolicyDegradationFromError,
23
33
  } from "../inventory/fetchBatch.js";
34
+ import { genericPurl } from "../inventory/purl.js";
24
35
 
25
36
  // FIXME. This has to get removed, once we improve the module detection one-liner.
26
37
  // If you're a Rubyist, please help us improve this code.
@@ -168,7 +179,7 @@ const GEM_METADATA_REFERENCE_TYPES = {
168
179
  documentation_uri: "documentation",
169
180
  funding_uri: "other",
170
181
  homepage_uri: "website",
171
- mailing_list_uri: "other",
182
+ mailing_list_uri: "mailing-list",
172
183
  source_code_uri: "vcs",
173
184
  wiki_uri: "documentation",
174
185
  };
@@ -219,6 +230,430 @@ function parseGemRequirement(value) {
219
230
  return parts.length ? parts.join(", ") : undefined;
220
231
  }
221
232
 
233
+ // Closing delimiters of the bracketing Ruby percent literals, such as `%q{}`.
234
+ // Any other punctuation closes itself, as in `%q|...|`.
235
+ const RUBY_CLOSING_DELIMITERS = { "(": ")", "[": "]", "{": "}", "<": ">" };
236
+
237
+ // Longest heredoc or multi-line array a gemspec assignment is followed across
238
+ const GEMSPEC_MAX_CONTINUATION_LINES = 200;
239
+
240
+ /**
241
+ * Tells whether the source that follows a literal ends the statement, so the
242
+ * literal is the whole value: nothing, a `.freeze`, a comment, or an `if` /
243
+ * `unless` modifier. Anything else, such as `+ suffix`, makes the value an
244
+ * expression that cannot be read statically.
245
+ *
246
+ * @param {string} rest Source after the literal
247
+ * @returns {boolean} true when the literal is the complete value
248
+ */
249
+ function endsRubyStatement(rest) {
250
+ let tail = rest.trim();
251
+ while (tail.startsWith(".freeze")) {
252
+ tail = tail.slice(".freeze".length).trim();
253
+ }
254
+ return (
255
+ !tail.length ||
256
+ tail.startsWith("#") ||
257
+ tail.startsWith(";") ||
258
+ tail.startsWith("if ") ||
259
+ tail.startsWith("unless ")
260
+ );
261
+ }
262
+
263
+ /**
264
+ * Read the Ruby string literal at the start of `text`: a double or single
265
+ * quoted string, or a `%q`/`%Q`/`%` percent literal. Escaped delimiters and
266
+ * backslashes are unescaped; every other escape sequence is kept as written.
267
+ * An interpolation such as `#{spec.name}` is kept verbatim, including any
268
+ * quotes inside it.
269
+ *
270
+ * @param {string} text Source starting with the literal
271
+ * @returns {{value: string, interpolated: boolean, complete: boolean, rest: string} | undefined}
272
+ * The literal's value, whether it holds an interpolation, whether it was
273
+ * closed within `text`, and the source after it. Undefined when `text` does
274
+ * not start with a string literal.
275
+ */
276
+ export function readRubyStringLiteral(text) {
277
+ if (!text?.length) {
278
+ return undefined;
279
+ }
280
+ let opener;
281
+ let interpolates;
282
+ let start;
283
+ if (text[0] === '"' || text[0] === "'") {
284
+ opener = text[0];
285
+ interpolates = opener === '"';
286
+ start = 1;
287
+ } else if (text[0] === "%") {
288
+ const kind = text[1] === "q" || text[1] === "Q" ? text[1] : "";
289
+ opener = text[1 + kind.length];
290
+ // `%w` and `%i` arrays, or a `%` that is the modulo operator
291
+ if (!opener || /[\w\s]/.test(opener)) {
292
+ return undefined;
293
+ }
294
+ interpolates = kind !== "q";
295
+ start = 2 + kind.length;
296
+ } else {
297
+ return undefined;
298
+ }
299
+ const closer = RUBY_CLOSING_DELIMITERS[opener] || opener;
300
+ let depth = 0;
301
+ let value = "";
302
+ let interpolated = false;
303
+ for (let i = start; i < text.length; i++) {
304
+ const ch = text[i];
305
+ if (ch === "\\" && i + 1 < text.length) {
306
+ const next = text[i + 1];
307
+ value += [opener, closer, "\\"].includes(next) ? next : `${ch}${next}`;
308
+ i++;
309
+ continue;
310
+ }
311
+ if (interpolates && ch === "#" && text[i + 1] === "{") {
312
+ const end = text.indexOf("}", i + 2);
313
+ if (end < 0) {
314
+ value += text.slice(i);
315
+ break;
316
+ }
317
+ value += text.slice(i, end + 1);
318
+ interpolated = true;
319
+ i = end;
320
+ continue;
321
+ }
322
+ if (ch === closer && depth === 0) {
323
+ return {
324
+ value,
325
+ interpolated,
326
+ complete: true,
327
+ rest: text.slice(i + 1),
328
+ };
329
+ }
330
+ if (closer !== opener) {
331
+ if (ch === opener) {
332
+ depth++;
333
+ } else if (ch === closer) {
334
+ depth--;
335
+ }
336
+ }
337
+ value += ch;
338
+ }
339
+ return { value, interpolated, complete: false, rest: "" };
340
+ }
341
+
342
+ /**
343
+ * Read a Ruby list of strings as written for `authors`, `email`, or `licenses`:
344
+ * an array of string literals, a `%w[]` word array, or a single string. Array
345
+ * elements that are not string literals, such as constants, are skipped.
346
+ *
347
+ * @param {string} text Source of the value
348
+ * @returns {{values: string[], complete: boolean} | undefined} The strings,
349
+ * and whether the array was closed within `text`. Undefined when the value
350
+ * is an expression that cannot be read statically.
351
+ */
352
+ export function readRubyStringList(text) {
353
+ const source = text?.trim();
354
+ if (!source?.length) {
355
+ return undefined;
356
+ }
357
+ if (/^%[wW][^\w\s]/.test(source)) {
358
+ const closer = RUBY_CLOSING_DELIMITERS[source[2]] || source[2];
359
+ const end = source.indexOf(closer, 3);
360
+ const words = source.slice(3, end < 0 ? undefined : end);
361
+ return {
362
+ values: words.split(" ").filter((word) => word.length),
363
+ complete: end >= 0,
364
+ };
365
+ }
366
+ if (source.startsWith("[")) {
367
+ const values = [];
368
+ let rest = source.slice(1).trim();
369
+ while (rest.length) {
370
+ if (rest.startsWith("]")) {
371
+ return { values, complete: true };
372
+ }
373
+ const literal = readRubyStringLiteral(rest);
374
+ if (literal) {
375
+ if (!literal.complete) {
376
+ return { values, complete: false };
377
+ }
378
+ if (!literal.interpolated && literal.value.trim().length) {
379
+ values.push(literal.value.trim());
380
+ }
381
+ rest = literal.rest.trim();
382
+ while (rest.startsWith(".freeze")) {
383
+ rest = rest.slice(".freeze".length).trim();
384
+ }
385
+ } else {
386
+ // Skip an element that is not a string literal
387
+ const comma = rest.indexOf(",");
388
+ const close = rest.indexOf("]");
389
+ if (close >= 0 && (comma < 0 || close < comma)) {
390
+ return { values, complete: true };
391
+ }
392
+ if (comma < 0) {
393
+ return { values, complete: false };
394
+ }
395
+ rest = rest.slice(comma);
396
+ }
397
+ if (rest.startsWith(",")) {
398
+ rest = rest.slice(1).trim();
399
+ }
400
+ }
401
+ return { values, complete: false };
402
+ }
403
+ const literal = readRubyStringLiteral(source);
404
+ if (
405
+ !literal?.complete ||
406
+ literal.interpolated ||
407
+ !endsRubyStatement(literal.rest)
408
+ ) {
409
+ return undefined;
410
+ }
411
+ return {
412
+ values: literal.value
413
+ .split(",")
414
+ .map((value) => value.trim())
415
+ .filter((value) => value.length),
416
+ complete: true,
417
+ };
418
+ }
419
+
420
+ /**
421
+ * Find the value assigned to a gemspec attribute on a line, such as the
422
+ * `"1.0".freeze` of `s.version = "1.0".freeze`.
423
+ *
424
+ * @param {string} line Whitespace-normalized gemspec line
425
+ * @param {string} attribute Attribute name, such as `version`
426
+ * @returns {string | undefined} Source of the assigned value
427
+ */
428
+ function readGemspecAssignment(line, attribute) {
429
+ const target = `.${attribute}`;
430
+ let idx = line.indexOf(target);
431
+ while (idx >= 0) {
432
+ const after = line.slice(idx + target.length).trimStart();
433
+ // `.version =` but neither `.versions`, `.version ==`, nor `.version =>`
434
+ if (
435
+ after.startsWith("=") &&
436
+ !["=", "~", ">"].includes(after[1]) &&
437
+ !/\w/.test(line[idx + target.length] || "")
438
+ ) {
439
+ return after.slice(1).trim();
440
+ }
441
+ idx = line.indexOf(target, idx + target.length);
442
+ }
443
+ return undefined;
444
+ }
445
+
446
+ /**
447
+ * Read the heredoc terminator of a value such as `<<~DESC` or `<<-'EOS'.strip`.
448
+ *
449
+ * @param {string} value Source of an assigned value
450
+ * @returns {string | undefined} The terminator, or undefined for any other value
451
+ */
452
+ function readHeredocTerminator(value) {
453
+ if (!value.startsWith("<<")) {
454
+ return undefined;
455
+ }
456
+ let rest = value.slice(2);
457
+ if (rest[0] === "~" || rest[0] === "-") {
458
+ rest = rest.slice(1);
459
+ }
460
+ const quote = rest[0] === '"' || rest[0] === "'" ? rest[0] : "";
461
+ rest = rest.slice(quote.length);
462
+ let terminator = "";
463
+ while (
464
+ terminator.length < rest.length &&
465
+ /\w/.test(rest[terminator.length])
466
+ ) {
467
+ terminator += rest[terminator.length];
468
+ }
469
+ return /^[A-Za-z_]\w*$/.test(terminator) ? terminator : undefined;
470
+ }
471
+
472
+ /**
473
+ * Change in the nesting depth of hash braces on a line. Braces inside string
474
+ * literals, including the ones of a `#{...}` interpolation, are not counted.
475
+ *
476
+ * @param {string} line Gemspec line
477
+ * @returns {number} Opened minus closed braces
478
+ */
479
+ function hashBraceDelta(line) {
480
+ let delta = 0;
481
+ for (let i = 0; i < line.length; i++) {
482
+ const ch = line[i];
483
+ if (ch === "#" && line[i + 1] !== "{") {
484
+ // A comment
485
+ break;
486
+ }
487
+ if (ch === '"' || ch === "'") {
488
+ const literal = readRubyStringLiteral(line.slice(i));
489
+ if (!literal?.complete) {
490
+ break;
491
+ }
492
+ i = line.length - literal.rest.length - 1;
493
+ continue;
494
+ }
495
+ if (ch === "{") {
496
+ delta++;
497
+ } else if (ch === "}") {
498
+ delta--;
499
+ }
500
+ }
501
+ return delta;
502
+ }
503
+
504
+ /**
505
+ * Expand the interpolations of a gemspec string that refer to the gem's own
506
+ * fields, such as `#{spec.name}` and `#{spec.version}`. Gems routinely build
507
+ * their changelog and documentation URIs this way.
508
+ *
509
+ * @param {string} value String value with interpolations
510
+ * @param {string | undefined} specVar Block variable of `Gem::Specification.new`
511
+ * @param {Object<string, string | undefined>} fields Known gem fields by name
512
+ * @returns {string} The value with every resolvable interpolation expanded
513
+ */
514
+ function expandGemspecInterpolation(value, specVar, fields) {
515
+ return value.replace(/#\{([^{}]*)\}/g, (token, expression) => {
516
+ const [receiver, field, ...extra] = expression.trim().split(".");
517
+ if (
518
+ extra.length ||
519
+ !(specVar ? receiver === specVar : /^\w+$/.test(receiver)) ||
520
+ !Object.hasOwn(fields, field) ||
521
+ !fields[field]
522
+ ) {
523
+ return token;
524
+ }
525
+ return fields[field];
526
+ });
527
+ }
528
+
529
+ /**
530
+ * Tells whether a string is usable as a RubyGems version.
531
+ *
532
+ * @param {string | undefined} version Candidate version
533
+ * @returns {boolean} true for values such as `0.8.1` or `2.0.0.rc1`
534
+ */
535
+ function looksLikeGemVersion(version) {
536
+ return !!version && /^\d[\w.+-]*$/.test(version);
537
+ }
538
+
539
+ /**
540
+ * Collect the local file a gemspec `require` line loads, when the file looks
541
+ * like the one defining the gem version, such as `lib/<gem>/version.rb`.
542
+ *
543
+ * Supported forms are `require "<gem>/version"`, `require_relative
544
+ * "lib/<gem>/version"`, and `require File.expand_path("../lib/<gem>/version",
545
+ * __FILE__)` or its `__dir__` variant.
546
+ *
547
+ * @param {string} line Whitespace-normalized gemspec line
548
+ * @param {string} gemspecFile Path of the gemspec
549
+ * @returns {string[]} Candidate file paths, most likely first
550
+ */
551
+ function gemspecVersionRequireCandidates(line, gemspecFile) {
552
+ const keyword = ["require_relative", "require"].find(
553
+ (akeyword) =>
554
+ line.startsWith(`${akeyword} `) || line.startsWith(`${akeyword}(`),
555
+ );
556
+ if (!keyword) {
557
+ return [];
558
+ }
559
+ let argument = line.slice(keyword.length).trim();
560
+ if (argument.startsWith("(")) {
561
+ argument = argument.slice(1).trim();
562
+ }
563
+ const baseDir = dirname(gemspecFile);
564
+ let requirePath;
565
+ let candidates;
566
+ const direct = readRubyStringLiteral(argument);
567
+ if (direct?.complete && !direct.interpolated) {
568
+ requirePath = direct.value;
569
+ candidates =
570
+ keyword === "require_relative"
571
+ ? [join(baseDir, requirePath)]
572
+ : [join(baseDir, "lib", requirePath), join(baseDir, requirePath)];
573
+ } else if (argument.startsWith("File.expand_path(")) {
574
+ const expanded = readRubyStringLiteral(
575
+ argument.slice("File.expand_path(".length).trim(),
576
+ );
577
+ if (!expanded?.complete || expanded.interpolated) {
578
+ return [];
579
+ }
580
+ requirePath = expanded.value;
581
+ const anchor = expanded.rest.replace(/^\s*,\s*/, "");
582
+ // `File.expand_path(path, __FILE__)` resolves against the gemspec itself,
583
+ // so its leading `..` steps out of the file name, not the directory
584
+ if (anchor.startsWith("__FILE__")) {
585
+ candidates = [join(gemspecFile, requirePath)];
586
+ } else if (
587
+ anchor.startsWith("__dir__") ||
588
+ anchor.startsWith("File.dirname(__FILE__)")
589
+ ) {
590
+ candidates = [join(baseDir, requirePath)];
591
+ } else {
592
+ return [];
593
+ }
594
+ } else {
595
+ return [];
596
+ }
597
+ if (!/version$/i.test(basename(requirePath).replace(/\.rb$/, ""))) {
598
+ return [];
599
+ }
600
+ return candidates.map((candidate) =>
601
+ candidate.endsWith(".rb") ? candidate : `${candidate}.rb`,
602
+ );
603
+ }
604
+
605
+ /**
606
+ * Resolve a version constant such as `OpenTelemetry::Instrumentation::VERSION`
607
+ * from the version file the gemspec requires. Only files inside the gemspec
608
+ * directory are read, and only a plain string assignment to the constant is
609
+ * accepted.
610
+ *
611
+ * @param {string} gemspecFile Path of the gemspec
612
+ * @param {string[]} candidates Version files the gemspec requires
613
+ * @param {string} constantName Last segment of the constant, such as `VERSION`
614
+ * @returns {string | undefined} Resolved version
615
+ */
616
+ function resolveGemVersionConstant(gemspecFile, candidates, constantName) {
617
+ const root = resolve(dirname(gemspecFile));
618
+ for (const candidate of candidates) {
619
+ const relativePath = relative(root, resolve(candidate));
620
+ if (
621
+ !relativePath.length ||
622
+ relativePath.split(sep)[0] === ".." ||
623
+ isAbsolute(relativePath) ||
624
+ !safeExistsSync(candidate)
625
+ ) {
626
+ continue;
627
+ }
628
+ let content;
629
+ try {
630
+ content = readFileSync(candidate, { encoding: "utf-8" });
631
+ } catch (_err) {
632
+ continue;
633
+ }
634
+ for (const aline of content.split("\n")) {
635
+ const trimmed = aline.trim();
636
+ if (!trimmed.startsWith(constantName)) {
637
+ continue;
638
+ }
639
+ const after = trimmed.slice(constantName.length).trimStart();
640
+ if (!after.startsWith("=") || after[1] === "=") {
641
+ continue;
642
+ }
643
+ const literal = readRubyStringLiteral(after.slice(1).trim());
644
+ if (
645
+ literal?.complete &&
646
+ !literal.interpolated &&
647
+ endsRubyStatement(literal.rest) &&
648
+ looksLikeGemVersion(literal.value)
649
+ ) {
650
+ return literal.value;
651
+ }
652
+ }
653
+ }
654
+ return undefined;
655
+ }
656
+
222
657
  // Digest algorithms Bundler may record in the CHECKSUMS section, mapped to the
223
658
  // CycloneDX hash algorithm names.
224
659
  const GEM_CHECKSUM_ALGOS = {
@@ -487,6 +922,11 @@ export async function enrichGemsFromLocalCache(pkgList, options = {}) {
487
922
  if (!p.description?.length && installed.description?.length) {
488
923
  p.description = installed.description;
489
924
  }
925
+ // The installed gemspec is the only offline source of the gem's
926
+ // authors, which the registry lookups cannot supply in dry-run mode
927
+ if (!p.authors?.length && installed.authors?.length) {
928
+ p.authors = installed.authors;
929
+ }
490
930
  if (!p.license && installed.licenses?.length) {
491
931
  // parseGemspecData returns the CycloneDX license shape, but the
492
932
  // license of an unfinished component is a plain list of names
@@ -596,8 +1036,17 @@ function applyGemMetadata(p, body) {
596
1036
  if (body.sha) {
597
1037
  p._integrity = `sha256-${body.sha}`;
598
1038
  }
599
- if (body.authors) {
600
- p.author = body.authors;
1039
+ // RubyGems reports the authors as a comma separated string. It is split into
1040
+ // the `authors` array CycloneDX expects, so a gemspec sighting that already
1041
+ // resolved the authors with their email addresses keeps the richer value.
1042
+ if (typeof body.authors === "string" && !p.authors?.length) {
1043
+ const names = body.authors
1044
+ .split(",")
1045
+ .map((name) => name.trim())
1046
+ .filter((name) => name.length);
1047
+ if (names.length) {
1048
+ p.authors = names.map((name) => ({ name }));
1049
+ }
601
1050
  }
602
1051
  // `ruby_version` is the `required_ruby_version` requirement. Note that the
603
1052
  // sibling `rubygems_version` field is the version of RubyGems that packaged
@@ -669,14 +1118,18 @@ function gemDirectUrl(p, v2Url, v1Url) {
669
1118
  * @param {Array} pkgList List of packages with metadata
670
1119
  */
671
1120
  export async function getRubyGemsMetadata(pkgList) {
1121
+ // The endpoints are joined by plain concatenation, so an override without
1122
+ // the trailing slash must not turn into a malformed URL
1123
+ const withTrailingSlash = (url) =>
1124
+ url?.length ? (url.endsWith("/") ? url : `${url}/`) : undefined;
672
1125
  const RUBYGEMS_V2_URL =
673
- readEnvironmentVariable("RUBYGEMS_V2_URL") ||
1126
+ withTrailingSlash(readEnvironmentVariable("RUBYGEMS_V2_URL")) ||
674
1127
  "https://rubygems.org/api/v2/rubygems/";
675
1128
  const RUBYGEMS_V1_URL =
676
- readEnvironmentVariable("RUBYGEMS_V1_URL") ||
1129
+ withTrailingSlash(readEnvironmentVariable("RUBYGEMS_V1_URL")) ||
677
1130
  "https://rubygems.org/api/v1/gems/";
678
1131
  const RUBYGEMS_V1_VERSIONS_URL =
679
- readEnvironmentVariable("RUBYGEMS_V1_VERSIONS_URL") ||
1132
+ withTrailingSlash(readEnvironmentVariable("RUBYGEMS_V1_VERSIONS_URL")) ||
680
1133
  "https://rubygems.org/api/v1/versions/";
681
1134
  const rdepList = [];
682
1135
  const apiOptions = {
@@ -1025,13 +1478,95 @@ export function collectGemModuleNames(
1025
1478
  : Array.from(simpleModuleNames).sort();
1026
1479
  }
1027
1480
 
1481
+ // One `"key" => value` pair of a gemspec metadata hash, or one
1482
+ // `metadata["key"] = value` assignment. The value is a string literal or a
1483
+ // reference to another spec field, as in `spec.metadata["homepage_uri"] =
1484
+ // spec.homepage`.
1485
+ const GEM_METADATA_PAIR =
1486
+ /["']([a-z_]+)["']\s*(?:=>|\]\s*=)\s*(?:["']([^"']*)["']|(\w+)\.(homepage|name|version)\b)/g;
1487
+
1028
1488
  /**
1029
- * Method to parse Gemspec file contents
1489
+ * Last segment of a version constant such as `Foo::Bar::VERSION`.
1490
+ *
1491
+ * @param {string} expression Source assigned to `spec.version`
1492
+ * @returns {string | undefined} The constant name, such as `VERSION`
1493
+ */
1494
+ function gemVersionConstantName(expression) {
1495
+ let constantPath = expression.split(" ")[0];
1496
+ let suffix;
1497
+ while (
1498
+ (suffix = [".freeze", ".to_s", ".dup"].find((s) =>
1499
+ constantPath.endsWith(s),
1500
+ ))
1501
+ ) {
1502
+ constantPath = constantPath.slice(0, -suffix.length);
1503
+ }
1504
+ if (constantPath.startsWith("::")) {
1505
+ constantPath = constantPath.slice(2);
1506
+ }
1507
+ const segments = constantPath.split("::");
1508
+ if (!segments.every((segment) => /^[A-Z]\w*$/.test(segment))) {
1509
+ return undefined;
1510
+ }
1511
+ return segments[segments.length - 1];
1512
+ }
1513
+
1514
+ /**
1515
+ * List the executables a gem computes from its bindir, such as the `bundle
1516
+ * gem` template's `spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }`.
1517
+ * RubyGems installs every file of the bindir, `bin` unless the gemspec says
1518
+ * otherwise, so the plain files there are the executables. A bindir outside
1519
+ * the gemspec's directory is not followed.
1520
+ *
1521
+ * @param {string} gemspecFile Path of the gemspec
1522
+ * @param {string} [bindir] The gemspec's `bindir`
1523
+ * @returns {string[]} Executable names, sorted
1524
+ */
1525
+ function listGemBindirFiles(gemspecFile, bindir = "bin") {
1526
+ if (!gemspecFile) {
1527
+ return [];
1528
+ }
1529
+ const gemDir = resolve(dirname(gemspecFile));
1530
+ const binPath = resolve(gemDir, bindir);
1531
+ const relativeBinPath = relative(gemDir, binPath);
1532
+ if (
1533
+ !relativeBinPath.length ||
1534
+ relativeBinPath.startsWith("..") ||
1535
+ isAbsolute(relativeBinPath) ||
1536
+ !safeExistsSync(binPath)
1537
+ ) {
1538
+ return [];
1539
+ }
1540
+ try {
1541
+ return readdirSync(binPath, { withFileTypes: true })
1542
+ .filter((entry) => entry.isFile() && !entry.name.startsWith("."))
1543
+ .map((entry) => entry.name)
1544
+ .sort();
1545
+ } catch (_err) {
1546
+ return [];
1547
+ }
1548
+ }
1549
+
1550
+ /**
1551
+ * Method to parse Gemspec file contents.
1552
+ *
1553
+ * A source gemspec is Ruby code. The values that can be read without running
1554
+ * it are string literals, lists of them, heredocs, and a version constant
1555
+ * defined in the version file the gemspec requires. Anything else is left
1556
+ * unresolved instead of being guessed.
1557
+ *
1558
+ * Bundler evaluates the gemspec of a project that uses the Gemfile `gemspec`
1559
+ * directive, and locks the gem under `PATH remote: .`. Such a locked version
1560
+ * can be supplied through `options.lockedVersions` and is used when the
1561
+ * gemspec computes its version in a way that cannot be read statically.
1030
1562
  *
1031
1563
  * @param {string} gemspecData Gemspec data
1032
1564
  * @param {string} gemspecFile File name for evidence.
1565
+ * @param {Object} [options] Parse options
1566
+ * @param {Object<string, string>} [options.lockedVersions] Locked versions of
1567
+ * the gems the project directory serves, by gem name
1033
1568
  */
1034
- export async function parseGemspecData(gemspecData, gemspecFile) {
1569
+ export async function parseGemspecData(gemspecData, gemspecFile, options = {}) {
1035
1570
  let pkgList = [];
1036
1571
  const pkg = { properties: [] };
1037
1572
  if (gemspecFile) {
@@ -1042,15 +1577,93 @@ export async function parseGemspecData(gemspecData, gemspecFile) {
1042
1577
  }
1043
1578
  let versionHackMatch = false;
1044
1579
  let gemPlatform;
1045
- const gemMetadata = {};
1046
- let metadataBlock = false;
1047
- gemspecData.split("\n").forEach((l) => {
1048
- versionHackMatch = false;
1049
- l = l.replaceAll("\r", "");
1050
- l = l.replace(/\s+/g, " ").replaceAll("%q{", "").trim().replace(/}$/, "");
1051
- if (l.startsWith("#")) {
1580
+ let specVar;
1581
+ let literalVersion;
1582
+ let versionExpression;
1583
+ let bindir;
1584
+ let computedExecutables = false;
1585
+ const texts = {};
1586
+ const lists = {};
1587
+ const versionFiles = [];
1588
+ // Keyed by metadata keys taken from the gemspec, so it has no prototype
1589
+ const gemMetadata = Object.create(null);
1590
+ let metadataDepth = 0;
1591
+ // A heredoc, string, or array whose value continues on the following lines
1592
+ let pending;
1593
+ const assignText = (field, value) => {
1594
+ const text = value.trim();
1595
+ if (text.length) {
1596
+ texts[field] = text;
1597
+ }
1598
+ };
1599
+ gemspecData.split("\n").forEach((rawLine) => {
1600
+ const line = rawLine.replaceAll("\r", "").replace(/\s+/g, " ").trim();
1601
+ // A string or array that seems to run into the next spec attribute was
1602
+ // misread, so keep what it had and parse the line as usual
1603
+ if (
1604
+ pending &&
1605
+ pending.kind !== "heredoc" &&
1606
+ specVar &&
1607
+ line.startsWith(`${specVar}.`)
1608
+ ) {
1609
+ if (pending.kind === "list") {
1610
+ const partialList = readRubyStringList(pending.text);
1611
+ if (partialList?.values.length) {
1612
+ lists[pending.field] = partialList.values;
1613
+ }
1614
+ } else {
1615
+ assignText(pending.field, readRubyStringLiteral(pending.text).value);
1616
+ }
1617
+ pending = undefined;
1618
+ }
1619
+ if (pending) {
1620
+ pending.lineCount++;
1621
+ if (pending.kind === "heredoc") {
1622
+ if (line === pending.terminator) {
1623
+ assignText(pending.field, pending.text);
1624
+ pending = undefined;
1625
+ } else {
1626
+ pending.text = `${pending.text} ${line}`;
1627
+ }
1628
+ } else {
1629
+ pending.text = `${pending.text} ${line}`;
1630
+ if (pending.kind === "list") {
1631
+ const list = readRubyStringList(pending.text);
1632
+ if (list?.complete) {
1633
+ lists[pending.field] = list.values;
1634
+ pending = undefined;
1635
+ }
1636
+ } else {
1637
+ const literal = readRubyStringLiteral(pending.text);
1638
+ if (literal?.complete) {
1639
+ assignText(pending.field, literal.value);
1640
+ pending = undefined;
1641
+ }
1642
+ }
1643
+ }
1644
+ if (pending && pending.lineCount > GEMSPEC_MAX_CONTINUATION_LINES) {
1645
+ pending = undefined;
1646
+ }
1647
+ return;
1648
+ }
1649
+ if (line.startsWith("#")) {
1052
1650
  return;
1053
1651
  }
1652
+ const l = line.replaceAll("%q{", "").replace(/}$/, "");
1653
+ if (!specVar && line.includes("Gem::Specification.new")) {
1654
+ const opening = line.indexOf("|");
1655
+ const closing = line.indexOf("|", opening + 1);
1656
+ const candidate =
1657
+ opening >= 0 && closing > opening
1658
+ ? line.slice(opening + 1, closing).trim()
1659
+ : "";
1660
+ if (/^\w+$/.test(candidate)) {
1661
+ specVar = candidate;
1662
+ }
1663
+ }
1664
+ if (gemspecFile) {
1665
+ versionFiles.push(...gemspecVersionRequireCandidates(line, gemspecFile));
1666
+ }
1054
1667
  // Native gems declare a platform. Installed gemspecs use either a plain
1055
1668
  // string (`s.platform = "java".freeze`) or the marshalled array form
1056
1669
  // (`s.platform = Gem::Platform.new(["x86_64", "linux", nil])`).
@@ -1075,116 +1688,100 @@ export async function parseGemspecData(gemspecData, gemspecFile) {
1075
1688
  }
1076
1689
  }
1077
1690
  }
1078
- for (const aprop of ["name", "version", "description", "homepage"]) {
1079
- if (l.includes(`.${aprop} = `)) {
1080
- let value = l
1081
- .split(`.${aprop} = `)[1]
1082
- .replace(".freeze", "")
1083
- .replaceAll("''', ", "")
1084
- .replace(/"/g, "");
1085
- if (["name", "version"].includes(aprop)) {
1086
- value = value.replace(/["']/g, "");
1087
- }
1088
- // Do not set name=name or version=version
1089
- if (value !== aprop) {
1090
- pkg[aprop] = value;
1091
- break;
1092
- }
1691
+ const versionValue = readGemspecAssignment(line, "version");
1692
+ if (versionValue !== undefined) {
1693
+ const literal = readRubyStringLiteral(versionValue);
1694
+ if (
1695
+ literal?.complete &&
1696
+ !literal.interpolated &&
1697
+ endsRubyStatement(literal.rest) &&
1698
+ looksLikeGemVersion(literal.value.trim())
1699
+ ) {
1700
+ literalVersion = literal.value.trim();
1701
+ versionExpression = undefined;
1702
+ } else {
1703
+ literalVersion = undefined;
1704
+ versionExpression = versionValue;
1093
1705
  }
1094
1706
  }
1095
- // Handle common problems
1096
- if (pkg.name === "name") {
1097
- console.log(
1098
- "Unable to identify the package name by parsing the file",
1099
- gemspecFile,
1100
- );
1101
- return;
1102
- }
1103
- if (
1104
- pkg?.version === "version" ||
1105
- pkg?.version?.includes("$") ||
1106
- pkg?.version?.includes("gem_version") ||
1107
- pkg?.version?.includes("File.") ||
1108
- pkg?.version?.includes("::")
1109
- ) {
1110
- const origVersion = pkg.version;
1111
- pkg.version = undefined;
1112
- // Can we find the version from the directory name?
1113
- if (gemspecFile) {
1114
- const parentDir = basename(dirname(gemspecFile));
1115
- const prefix = `${pkg.name}-`;
1116
- if (parentDir.startsWith(prefix)) {
1117
- const versionFromDir = parentDir.slice(prefix.length).split("-")[0];
1118
- if (/\d/.test(versionFromDir)) {
1119
- pkg.version = versionFromDir;
1120
- versionHackMatch = true;
1121
- }
1707
+ for (const field of ["name", "description", "summary", "homepage"]) {
1708
+ const value = readGemspecAssignment(line, field);
1709
+ if (value === undefined) {
1710
+ continue;
1711
+ }
1712
+ const terminator = readHeredocTerminator(value);
1713
+ if (terminator && field !== "name") {
1714
+ pending = {
1715
+ kind: "heredoc",
1716
+ field,
1717
+ terminator,
1718
+ text: "",
1719
+ lineCount: 0,
1720
+ };
1721
+ return;
1722
+ }
1723
+ const literal = readRubyStringLiteral(value);
1724
+ if (!literal) {
1725
+ continue;
1726
+ }
1727
+ if (!literal.complete) {
1728
+ if (field !== "name") {
1729
+ pending = { kind: "literal", field, text: value, lineCount: 0 };
1730
+ return;
1122
1731
  }
1732
+ continue;
1123
1733
  }
1124
- if (!versionHackMatch && !pkg.version) {
1125
- if (origVersion?.toLowerCase().includes("version")) {
1126
- if (DEBUG_MODE) {
1127
- console.log(
1128
- `Unable to identify the version for '${pkg.name}' from the string '${origVersion}'. Spec file: ${gemspecFile}`,
1129
- );
1130
- }
1131
- } else {
1132
- console.log(
1133
- `Unable to identify the version for '${pkg.name}'. Spec file: ${gemspecFile}`,
1134
- );
1734
+ if (!endsRubyStatement(literal.rest)) {
1735
+ continue;
1736
+ }
1737
+ if (field === "name") {
1738
+ if (!literal.interpolated && literal.value.trim().length) {
1739
+ pkg.name = literal.value.trim();
1135
1740
  }
1741
+ } else {
1742
+ assignText(field, literal.value);
1136
1743
  }
1137
1744
  }
1138
- for (const aprop of ["authors", "licenses"]) {
1139
- if (l.includes(`.${aprop} = `)) {
1140
- try {
1141
- const pline = l
1142
- .split(`.${aprop} = `)
1143
- .pop()
1144
- .replaceAll(".freeze", "")
1145
- .replaceAll("%w", "")
1146
- .replaceAll("'", '"')
1147
- .replaceAll(']"', "");
1148
- const apropList = JSON.parse(pline);
1149
- if (apropList) {
1150
- if (Array.isArray(apropList)) {
1151
- pkg[aprop] = apropList;
1152
- } else if (
1153
- typeof apropList === "string" ||
1154
- apropList instanceof String
1155
- ) {
1156
- pkg[aprop] = apropList.split(",");
1157
- }
1158
- }
1159
- } catch (_err) {
1160
- const alist = l
1161
- .replace(/[[\]'"]/g, "")
1162
- .replaceAll("%w", "")
1163
- .split(", ");
1164
- if (alist?.length) {
1165
- pkg[aprop] = alist;
1166
- }
1745
+ // The singular spellings are aliases of the plural attributes, and a
1746
+ // gemspec may spread an array over several lines
1747
+ for (const [attribute, field] of [
1748
+ ["authors", "authors"],
1749
+ ["author", "authors"],
1750
+ ["email", "email"],
1751
+ ["licenses", "licenses"],
1752
+ ["license", "licenses"],
1753
+ ["executables", "executables"],
1754
+ ["executable", "executables"],
1755
+ ]) {
1756
+ const value = readGemspecAssignment(line, attribute);
1757
+ if (value === undefined) {
1758
+ continue;
1759
+ }
1760
+ const list = readRubyStringList(value);
1761
+ if (!list) {
1762
+ // Computed executables, such as the `bundle gem` template's
1763
+ // `spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }`, are read
1764
+ // from the bindir once the whole gemspec is known
1765
+ if (field === "executables") {
1766
+ computedExecutables = true;
1167
1767
  }
1768
+ continue;
1769
+ }
1770
+ if (!list.complete) {
1771
+ pending = { kind: "list", field, text: value, lineCount: 0 };
1772
+ return;
1168
1773
  }
1774
+ lists[field] = list.values;
1169
1775
  }
1170
- if (l.includes(".executables = ")) {
1171
- try {
1172
- const exeList = JSON.parse(
1173
- l
1174
- .split(".executables = ")
1175
- .pop()
1176
- .replaceAll(".freeze", "")
1177
- .replaceAll("'", '"')
1178
- .replaceAll(']"', ""),
1179
- );
1180
- if (exeList && Array.isArray(exeList)) {
1181
- pkg.properties.push({
1182
- name: "cdx:gem:executables",
1183
- value: exeList.join(", "),
1184
- });
1185
- }
1186
- } catch (_err) {
1187
- // pass
1776
+ const bindirValue = readGemspecAssignment(line, "bindir");
1777
+ if (bindirValue !== undefined) {
1778
+ const literal = readRubyStringLiteral(bindirValue);
1779
+ if (
1780
+ literal?.complete &&
1781
+ !literal.interpolated &&
1782
+ literal.value.trim().length
1783
+ ) {
1784
+ bindir = literal.value.trim();
1188
1785
  }
1189
1786
  }
1190
1787
  // A gem with extensions compiles native code at install time, which is a
@@ -1215,22 +1812,87 @@ export async function parseGemspecData(gemspecData, gemspecFile) {
1215
1812
  // The free form metadata hash carries the well known project URIs and the
1216
1813
  // `rubygems_mfa_required` supply chain signal. It may be written inline as
1217
1814
  // `s.metadata = { "a" => "b", "c" => "d" }`, spread over several lines, or
1218
- // assigned a key at a time with `s.metadata["a"] = "b"`.
1219
- if (l.includes(".metadata")) {
1220
- metadataBlock = true;
1221
- }
1222
- if (metadataBlock) {
1223
- for (const [, key, value] of l.matchAll(
1224
- /["']([a-z_]+)["']\s*(?:=>|\]\s*=)\s*["']([^"']*)["']/g,
1815
+ // assigned a key at a time with `s.metadata["a"] = "b"`. A multi-line hash
1816
+ // is followed until its braces balance, so a `#{...}` interpolation or an
1817
+ // unrelated hash further down cannot end or extend it.
1818
+ if (metadataDepth > 0 || line.includes(".metadata")) {
1819
+ for (const [, key, value, refVar, refField] of line.matchAll(
1820
+ GEM_METADATA_PAIR,
1225
1821
  )) {
1226
- gemMetadata[key] = value;
1822
+ gemMetadata[key] =
1823
+ value !== undefined ? { value } : { refVar, refField };
1227
1824
  }
1228
- if (l.includes("}") || (!l.includes(".metadata") && !l.includes("=>"))) {
1229
- metadataBlock = false;
1825
+ if (metadataDepth > 0) {
1826
+ metadataDepth = Math.max(0, metadataDepth + hashBraceDelta(line));
1827
+ } else if (readGemspecAssignment(line, "metadata") !== undefined) {
1828
+ metadataDepth = Math.max(0, hashBraceDelta(line));
1230
1829
  }
1231
1830
  }
1232
1831
  });
1233
- for (const [key, value] of Object.entries(gemMetadata)) {
1832
+ if (literalVersion) {
1833
+ pkg.version = literalVersion;
1834
+ } else if (versionExpression !== undefined) {
1835
+ // Installed gems live in a `<name>-<version>` directory
1836
+ if (gemspecFile) {
1837
+ const parentDir = basename(dirname(gemspecFile));
1838
+ const prefix = `${pkg.name}-`;
1839
+ if (parentDir.startsWith(prefix)) {
1840
+ const versionFromDir = parentDir.slice(prefix.length).split("-")[0];
1841
+ if (looksLikeGemVersion(versionFromDir)) {
1842
+ pkg.version = versionFromDir;
1843
+ versionHackMatch = true;
1844
+ }
1845
+ }
1846
+ }
1847
+ // Most gems built from source assign a constant defined in the
1848
+ // `lib/<gem>/version.rb` file the gemspec requires
1849
+ const constantName = gemVersionConstantName(versionExpression);
1850
+ if (!pkg.version) {
1851
+ pkg.version =
1852
+ constantName && versionFiles.length
1853
+ ? resolveGemVersionConstant(gemspecFile, versionFiles, constantName)
1854
+ : undefined;
1855
+ }
1856
+ }
1857
+ // Fall back to the release Bundler evaluated the gemspec to
1858
+ const lockedVersion =
1859
+ options.lockedVersions && Object.hasOwn(options.lockedVersions, pkg.name)
1860
+ ? options.lockedVersions[pkg.name]
1861
+ : undefined;
1862
+ if (!pkg.version && looksLikeGemVersion(lockedVersion)) {
1863
+ pkg.version = lockedVersion;
1864
+ }
1865
+ if (!pkg.version && versionExpression !== undefined && DEBUG_MODE) {
1866
+ console.log(
1867
+ `Unable to identify the version for '${pkg.name}' from '${versionExpression}'. Spec file: ${gemspecFile}`,
1868
+ );
1869
+ }
1870
+ const specFields = { name: pkg.name, version: pkg.version };
1871
+ const expand = (value) =>
1872
+ expandGemspecInterpolation(value, specVar, specFields);
1873
+ // Gems that only set `summary` still deserve a description in the BOM
1874
+ const description = texts.description || texts.summary;
1875
+ if (description) {
1876
+ pkg.description = expand(description);
1877
+ }
1878
+ // A URL whose interpolation cannot be resolved would be a wrong link
1879
+ const homepage = texts.homepage ? expand(texts.homepage) : undefined;
1880
+ specFields.homepage = homepage?.includes("#{") ? undefined : homepage;
1881
+ for (const [key, entry] of Object.entries(gemMetadata)) {
1882
+ const value =
1883
+ entry.value !== undefined
1884
+ ? expand(entry.value)
1885
+ : !specVar || entry.refVar === specVar
1886
+ ? specFields[entry.refField]
1887
+ : undefined;
1888
+ if (!value?.length || value.includes("#{")) {
1889
+ if (DEBUG_MODE && entry.value !== undefined) {
1890
+ console.log(
1891
+ `Ignoring the metadata '${key}' of '${pkg.name}' with the unresolved value '${entry.value}'. Spec file: ${gemspecFile}`,
1892
+ );
1893
+ }
1894
+ continue;
1895
+ }
1234
1896
  if (key === "rubygems_mfa_required") {
1235
1897
  pkg.properties.push({
1236
1898
  name: "cdx:gem:mfaRequired",
@@ -1238,21 +1900,25 @@ export async function parseGemspecData(gemspecData, gemspecFile) {
1238
1900
  });
1239
1901
  continue;
1240
1902
  }
1241
- const refType = GEM_METADATA_REFERENCE_TYPES[key];
1903
+ const refType = Object.hasOwn(GEM_METADATA_REFERENCE_TYPES, key)
1904
+ ? GEM_METADATA_REFERENCE_TYPES[key]
1905
+ : undefined;
1242
1906
  if (refType) {
1243
1907
  pkg.externalReferences = pkg.externalReferences || [];
1244
1908
  pkg.externalReferences.push({ type: refType, url: value, comment: key });
1245
1909
  }
1246
1910
  }
1247
- // `s.homepage = "..."` is the project website. Keep it as an external
1248
- // reference; the transient `homepage` key is a plain string that never
1249
- // reaches the BOM because component conversion expects the `{ url }` shape.
1250
- if (pkg.homepage) {
1911
+ // `s.homepage = "..."` is the project website
1912
+ if (specFields.homepage) {
1251
1913
  pkg.externalReferences = pkg.externalReferences || [];
1252
- if (!pkg.externalReferences.some((ref) => ref.url === pkg.homepage)) {
1253
- pkg.externalReferences.push({ type: "website", url: pkg.homepage });
1914
+ if (
1915
+ !pkg.externalReferences.some((ref) => ref.url === specFields.homepage)
1916
+ ) {
1917
+ pkg.externalReferences.push({
1918
+ type: "website",
1919
+ url: specFields.homepage,
1920
+ });
1254
1921
  }
1255
- delete pkg.homepage;
1256
1922
  }
1257
1923
  if (pkg.name) {
1258
1924
  const purlString = toGemPurl(pkg.name, pkg.version, gemPlatform);
@@ -1276,16 +1942,42 @@ export async function parseGemspecData(gemspecData, gemspecFile) {
1276
1942
  },
1277
1943
  };
1278
1944
  }
1279
- if (pkg.authors) {
1280
- pkg.authors = pkg.authors.map((a) => {
1281
- return { name: a };
1945
+ if (lists.authors?.length) {
1946
+ // `spec.email` holds the contact addresses of the gem, not of an author.
1947
+ // An address can only be attributed when there is one per author, in the
1948
+ // same order, or when the gem has a single author.
1949
+ const emails = (lists.email || []).filter(
1950
+ (email) => email.includes("@") && !email.includes(" "),
1951
+ );
1952
+ pkg.authors = lists.authors.map((name, i) => {
1953
+ let email;
1954
+ if (emails.length === lists.authors.length) {
1955
+ email = emails[i];
1956
+ } else if (lists.authors.length === 1) {
1957
+ email = emails[0];
1958
+ }
1959
+ return email ? { name, email } : { name };
1282
1960
  });
1283
1961
  }
1284
- if (pkg.licenses) {
1285
- pkg.licenses = pkg.licenses.map((l) => {
1962
+ if (lists.licenses?.length) {
1963
+ pkg.licenses = lists.licenses.map((l) => {
1286
1964
  return { license: { name: l } };
1287
1965
  });
1288
1966
  }
1967
+ // The executables a gem ships make it an application (discussions 4407 and
1968
+ // 4408). A literal list names them; computed ones are the files the gem
1969
+ // installs from its bindir, which a project has next to its gemspec.
1970
+ const executables = lists.executables?.length
1971
+ ? lists.executables
1972
+ : computedExecutables
1973
+ ? listGemBindirFiles(gemspecFile, bindir)
1974
+ : [];
1975
+ if (executables.length) {
1976
+ pkg.properties.push({
1977
+ name: "cdx:gem:executables",
1978
+ value: executables.join(", "),
1979
+ });
1980
+ }
1289
1981
  if (pkg.name) {
1290
1982
  pkgList = [pkg];
1291
1983
  } else {
@@ -1300,6 +1992,11 @@ export async function parseGemspecData(gemspecData, gemspecFile) {
1300
1992
  /**
1301
1993
  * Method to parse Gemfile.lock
1302
1994
  *
1995
+ * Besides the components and the dependency tree, the result lists as
1996
+ * `projectGemRefs` the bom-refs of the gems served from the lockfile's own
1997
+ * directory (`PATH` with `remote: .`), which is how Bundler records the gem a
1998
+ * project builds when its Gemfile uses the `gemspec` directive.
1999
+ *
1303
2000
  * @param {object} gemLockData Gemfile.lock data
1304
2001
  * @param {string} lockFile Lock file
1305
2002
  */
@@ -1373,14 +2070,24 @@ export async function parseGemfileLockData(gemLockData, lockFile) {
1373
2070
  let lastBranch;
1374
2071
  let lastTag;
1375
2072
  let lastParentPlatform;
2073
+ // The section (`PATH`, `GIT`, `GEM`) and the remote the specs come from
2074
+ let lastSection;
2075
+ let lastSectionRemote;
2076
+ // Source of each gem, recorded from its own top-level line in a specs
2077
+ // section. A nested line such as `opentelemetry-api (~> 1.7)` under a `PATH`
2078
+ // remote is only a dependency constraint: the gem itself is resolved — and
2079
+ // described by its own `remote:` — in another section (discussion 4404).
2080
+ const gemSourceInfo = {};
1376
2081
  // Dependencies block would begin with DEPENDENCIES
1377
2082
  let dependenciesBlock = false;
1378
2083
  const rootList = [];
2084
+ const projectGemRefs = [];
1379
2085
  // In the second pass, we use the space in the prefix to figure out the dependency tree
1380
2086
  gemLockData.split("\n").forEach((l) => {
1381
2087
  l = l.replaceAll("\r", "");
1382
2088
  if (l.trim().startsWith("remote:")) {
1383
2089
  lastRemote = l.trim().split(" ")[1];
2090
+ lastSectionRemote = lastRemote;
1384
2091
  if (lastRemote.length < 3) {
1385
2092
  lastRemote = undefined;
1386
2093
  }
@@ -1406,6 +2113,8 @@ export async function parseGemfileLockData(gemLockData, lockFile) {
1406
2113
  lastBranch = undefined;
1407
2114
  lastTag = undefined;
1408
2115
  lastParentPlatform = undefined;
2116
+ lastSection = l.trim() || undefined;
2117
+ lastSectionRemote = undefined;
1409
2118
  }
1410
2119
  if (l.trim() === "specs:") {
1411
2120
  specsFound = true;
@@ -1489,6 +2198,24 @@ export async function parseGemfileLockData(gemLockData, lockFile) {
1489
2198
  const bomRef = decodeURIComponent(purlString);
1490
2199
  if (level === 1) {
1491
2200
  lastParent = bomRef;
2201
+ if (
2202
+ lastSection === "PATH" &&
2203
+ lastSectionRemote === "." &&
2204
+ !projectGemRefs.includes(bomRef)
2205
+ ) {
2206
+ projectGemRefs.push(bomRef);
2207
+ }
2208
+ // Bundler locks every gem against exactly one source, so the gem's
2209
+ // own top-level line is where its remote belongs. A later duplicate
2210
+ // sighting of the same name would be the same release.
2211
+ if (!gemSourceInfo[name]) {
2212
+ gemSourceInfo[name] = {
2213
+ remote: lastRemote,
2214
+ revision: lastRevision,
2215
+ branch: lastBranch,
2216
+ tag: lastTag,
2217
+ };
2218
+ }
1492
2219
  }
1493
2220
  const properties = [
1494
2221
  {
@@ -1496,30 +2223,6 @@ export async function parseGemfileLockData(gemLockData, lockFile) {
1496
2223
  value: lockFile,
1497
2224
  },
1498
2225
  ];
1499
- if (lastRemote) {
1500
- properties.push({
1501
- name: "cdx:gem:remote",
1502
- value: lastRemote,
1503
- });
1504
- }
1505
- if (lastRevision) {
1506
- properties.push({
1507
- name: "cdx:gem:remoteRevision",
1508
- value: lastRevision,
1509
- });
1510
- }
1511
- if (lastBranch) {
1512
- properties.push({
1513
- name: "cdx:gem:remoteBranch",
1514
- value: lastBranch,
1515
- });
1516
- }
1517
- if (lastTag) {
1518
- properties.push({
1519
- name: "cdx:gem:remoteTag",
1520
- value: lastTag,
1521
- });
1522
- }
1523
2226
  const apkg = {
1524
2227
  name,
1525
2228
  version,
@@ -1628,6 +2331,37 @@ export async function parseGemfileLockData(gemLockData, lockFile) {
1628
2331
  }
1629
2332
  }
1630
2333
  });
2334
+ // Attach each gem's own source to it, wherever in the lockfile it was first
2335
+ // sighted. The source properties follow the SrcFile entry to keep the order
2336
+ // the inline construction used to produce.
2337
+ for (const apkg of pkgList) {
2338
+ const info = gemSourceInfo[apkg.name];
2339
+ if (!info || !apkg.properties?.length) {
2340
+ continue;
2341
+ }
2342
+ const sourceProps = [];
2343
+ for (const [propName, value] of [
2344
+ ["cdx:gem:remote", info.remote],
2345
+ ["cdx:gem:remoteRevision", info.revision],
2346
+ ["cdx:gem:remoteBranch", info.branch],
2347
+ ["cdx:gem:remoteTag", info.tag],
2348
+ ]) {
2349
+ if (value && !apkg.properties.some((prop) => prop.name === propName)) {
2350
+ sourceProps.push({ name: propName, value });
2351
+ }
2352
+ }
2353
+ if (!sourceProps.length) {
2354
+ continue;
2355
+ }
2356
+ const srcFileIndex = apkg.properties.findIndex(
2357
+ (prop) => prop.name === "internal:SrcFile",
2358
+ );
2359
+ apkg.properties.splice(
2360
+ srcFileIndex < 0 ? 0 : srcFileIndex + 1,
2361
+ 0,
2362
+ ...sourceProps,
2363
+ );
2364
+ }
1631
2365
  for (const k of Object.keys(dependenciesMap)) {
1632
2366
  dependenciesList.push({
1633
2367
  ref: k,
@@ -1636,7 +2370,533 @@ export async function parseGemfileLockData(gemLockData, lockFile) {
1636
2370
  }
1637
2371
  if (shouldFetchLicense()) {
1638
2372
  pkgList = await getRubyGemsMetadata(pkgList);
1639
- return { pkgList, dependenciesList, rootList };
1640
2373
  }
1641
- return { pkgList, dependenciesList, rootList };
2374
+ return { pkgList, dependenciesList, rootList, projectGemRefs };
2375
+ }
2376
+
2377
+ // Fields that describe a gem, carried over to the parent component
2378
+ const GEM_DESCRIPTIVE_FIELDS = [
2379
+ "description",
2380
+ "authors",
2381
+ "licenses",
2382
+ "externalReferences",
2383
+ ];
2384
+
2385
+ /**
2386
+ * Describe the gem a project builds in its parent component.
2387
+ *
2388
+ * A gem project sees its own gem more than once: in its gemspec, in the `PATH`
2389
+ * entry the Gemfile `gemspec` directive adds to its lockfile, and possibly as
2390
+ * an installed copy. Each of them describes the project itself, so all are
2391
+ * folded into the parent (discussion 4388). The parent takes the gem's
2392
+ * identity, its descriptive metadata, and its `cdx:gem:*` properties; the
2393
+ * dependency edges of the sightings move to the parent; and the sightings
2394
+ * leave the component list, so the gem is never a dependency of itself.
2395
+ *
2396
+ * An explicit `--project-version` names the version of the parent. Otherwise
2397
+ * the version of the gemspec, which may have come from the lockfile, is used.
2398
+ *
2399
+ * @param {object} parentComponent Parent component, updated in place
2400
+ * @param {object} projectGem Component parsed from the root gemspec
2401
+ * @param {object[]} pkgList Components
2402
+ * @param {object[]} dependencies Dependency edges
2403
+ * @param {string[]} rootList bom-refs of the direct dependencies
2404
+ * @param {string[]} projectGemRefs bom-refs of the gems the lockfiles serve from the project directory
2405
+ * @param {object} options CLI options
2406
+ * @returns {{pkgList: object[], dependencies: object[], rootList: string[]}}
2407
+ * The components, edges, and direct dependencies with the gem folded in
2408
+ */
2409
+ export function describeProjectGem(
2410
+ parentComponent,
2411
+ projectGem,
2412
+ pkgList,
2413
+ dependencies,
2414
+ rootList,
2415
+ projectGemRefs,
2416
+ options = {},
2417
+ ) {
2418
+ const version = options.projectVersion
2419
+ ? `${options.projectVersion}`
2420
+ : projectGem.version || "latest";
2421
+ const parentPurl = build({
2422
+ type: "gem",
2423
+ namespace: parentComponent.group || null,
2424
+ name: projectGem.name,
2425
+ version,
2426
+ });
2427
+ parentComponent.name = projectGem.name;
2428
+ parentComponent.version = version;
2429
+ parentComponent.purl = parentPurl;
2430
+ parentComponent["bom-ref"] = decodeURIComponent(parentPurl);
2431
+ // A gem that ships executables is an application; every other gem is a
2432
+ // library. The gemspec decides, so the same gem keeps the same type whether
2433
+ // it is the project being scanned or a dependency of another one
2434
+ // (discussion 4407). After trimComponents the root gemspec sighting carries
2435
+ // the properties of every sighting it absorbed, so checking it covers the
2436
+ // gem's lockfile and installed copies as well.
2437
+ const parentComponentType = projectGem.properties?.some(
2438
+ (prop) => prop.name === "cdx:gem:executables" && prop.value?.trim().length,
2439
+ )
2440
+ ? "application"
2441
+ : "library";
2442
+ parentComponent.type = parentComponentType;
2443
+ const parentRef = parentComponent["bom-ref"];
2444
+ const selfRefs = new Set([parentRef, projectGem["bom-ref"]]);
2445
+ for (const ref of projectGemRefs || []) {
2446
+ if (
2447
+ pkgList.some((p) => p["bom-ref"] === ref && p.name === projectGem.name)
2448
+ ) {
2449
+ selfRefs.add(ref);
2450
+ }
2451
+ }
2452
+ // After trimComponents, the retained sighting holds the metadata of all the
2453
+ // duplicates it absorbed, so every retained sighting is consulted
2454
+ const sightings = [
2455
+ projectGem,
2456
+ ...pkgList.filter((p) => p !== projectGem && selfRefs.has(p["bom-ref"])),
2457
+ ];
2458
+ const hasValue = (value) =>
2459
+ Array.isArray(value) ? value.length > 0 : !!value;
2460
+ for (const field of GEM_DESCRIPTIVE_FIELDS) {
2461
+ if (hasValue(parentComponent[field])) {
2462
+ continue;
2463
+ }
2464
+ const source = sightings.find((s) => hasValue(s[field]));
2465
+ if (source) {
2466
+ parentComponent[field] = source[field];
2467
+ }
2468
+ }
2469
+ for (const sighting of sightings) {
2470
+ for (const prop of sighting.properties || []) {
2471
+ // The manifests the parent's metadata came from belong on the parent
2472
+ // too, so they survive as `cdx:bom:componentSrcFiles` (discussion 4410).
2473
+ // Unlike the single-valued `cdx:gem:*` properties, a SrcFile is unique
2474
+ // by name and value: a gem is routinely described by both its gemspec
2475
+ // and its lockfile.
2476
+ const isSourceFile = prop.name === "internal:SrcFile";
2477
+ if (
2478
+ (prop.name.startsWith("cdx:gem:") || isSourceFile) &&
2479
+ !parentComponent.properties?.some(
2480
+ (p) =>
2481
+ p.name === prop.name && (!isSourceFile || p.value === prop.value),
2482
+ )
2483
+ ) {
2484
+ parentComponent.properties = parentComponent.properties || [];
2485
+ parentComponent.properties.push(prop);
2486
+ }
2487
+ }
2488
+ // Checksums identify an artifact, so only the parent's own release counts
2489
+ if (sighting["bom-ref"] === parentRef && sighting.hashes?.length) {
2490
+ parentComponent.hashes = parentComponent.hashes || [];
2491
+ for (const ahash of sighting.hashes) {
2492
+ if (
2493
+ !parentComponent.hashes.some(
2494
+ (h) => h.alg === ahash.alg && h.content === ahash.content,
2495
+ )
2496
+ ) {
2497
+ parentComponent.hashes.push(ahash);
2498
+ }
2499
+ }
2500
+ }
2501
+ }
2502
+ const rekey = (ref) => (selfRefs.has(ref) ? parentRef : ref);
2503
+ return {
2504
+ pkgList: pkgList.filter((p) => !selfRefs.has(p["bom-ref"])),
2505
+ // mergeDependencies unites the entries that now share the parent's ref and
2506
+ // drops the edges pointing back at the parent
2507
+ dependencies: mergeDependencies(
2508
+ [],
2509
+ dependencies.map((d) => ({
2510
+ ...d,
2511
+ ref: rekey(d.ref),
2512
+ dependsOn: (d.dependsOn || []).map(rekey),
2513
+ })),
2514
+ parentComponent,
2515
+ ),
2516
+ rootList: [
2517
+ ...new Set(rootList.map(rekey).filter((ref) => ref !== parentRef)),
2518
+ ],
2519
+ };
2520
+ }
2521
+
2522
+ // The public RubyGems registry, whose download URL for a gem release is
2523
+ // `https://rubygems.org/gems/<name>-<version>[-<platform>].gem`
2524
+ const RUBYGEMS_ORG_REMOTE = "https://rubygems.org";
2525
+
2526
+ /**
2527
+ * Point each gem resolved from the public RubyGems registry at its `.gem`
2528
+ * tarball with a `distribution` external reference (discussion 4406).
2529
+ *
2530
+ * The registry API supplies `gem_uri` when it is reachable, but an offline
2531
+ * scan — a dry run, an air-gapped host, or one that only consulted Bundler's
2532
+ * local caches — has no way to learn it. For the public registry the URL is
2533
+ * deterministic, so it is derived from the recorded `cdx:gem:remote` and the
2534
+ * release identity instead. Gems from any other remote are left alone: a
2535
+ * private mirror's layout is its own business.
2536
+ *
2537
+ * @param {Array} pkgList Gem components, enriched in place
2538
+ * @returns {Array} The package list
2539
+ */
2540
+ export function addRubyGemsDistributionUrls(pkgList) {
2541
+ for (const p of pkgList) {
2542
+ if (!p?.name || !p.version) {
2543
+ continue;
2544
+ }
2545
+ const remote = p.properties?.find(
2546
+ (prop) => prop.name === "cdx:gem:remote",
2547
+ )?.value;
2548
+ // Older lockfiles name the registry over plain http, which it redirects
2549
+ if (
2550
+ !remote ||
2551
+ !["https://rubygems.org", "http://rubygems.org"].includes(
2552
+ remote.replace(/\/$/, ""),
2553
+ )
2554
+ ) {
2555
+ continue;
2556
+ }
2557
+ if (
2558
+ p.externalReferences?.some((ref) => ref.type === "distribution") ||
2559
+ p.properties?.some((prop) => prop.name === "cdx:gem:gemUri")
2560
+ ) {
2561
+ continue;
2562
+ }
2563
+ const platform = gemComponentPlatform(p);
2564
+ const gemFile =
2565
+ platform && platform !== "ruby"
2566
+ ? `${p.name}-${p.version}-${platform}.gem`
2567
+ : `${p.name}-${p.version}.gem`;
2568
+ p.externalReferences = p.externalReferences || [];
2569
+ p.externalReferences.push({
2570
+ type: "distribution",
2571
+ url: `${RUBYGEMS_ORG_REMOTE}/gems/${gemFile}`,
2572
+ });
2573
+ }
2574
+ return pkgList;
2575
+ }
2576
+
2577
+ // Gem::Requirement operators, longest first so `>=` is not read as `>`
2578
+ const GEM_REQUIREMENT_OPERATORS = ["~>", ">=", "<=", "!=", ">", "<", "="];
2579
+
2580
+ /**
2581
+ * Split a RubyGems version into the segments Gem::Version compares: a run of
2582
+ * digits is a number and a run of letters is a string, so `3.3.0.preview1`
2583
+ * reads as `[3, 3, 0, "preview", 1]`. RubyGems treats `-` as `.pre.`.
2584
+ *
2585
+ * @param {string} version Version string
2586
+ * @returns {Array<number|string>} Segments
2587
+ */
2588
+ function gemVersionSegments(version) {
2589
+ const segments = [];
2590
+ let token = "";
2591
+ let tokenIsDigits = false;
2592
+ const flush = () => {
2593
+ if (token.length) {
2594
+ segments.push(tokenIsDigits ? Number.parseInt(token, 10) : token);
2595
+ }
2596
+ token = "";
2597
+ };
2598
+ for (const ch of version.replaceAll("-", ".pre.")) {
2599
+ const isDigit = ch >= "0" && ch <= "9";
2600
+ const isLetter = (ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z");
2601
+ if (!isDigit && !isLetter) {
2602
+ flush();
2603
+ continue;
2604
+ }
2605
+ if (token.length && isDigit !== tokenIsDigits) {
2606
+ flush();
2607
+ }
2608
+ tokenIsDigits = isDigit;
2609
+ token += ch;
2610
+ }
2611
+ flush();
2612
+ return segments;
2613
+ }
2614
+
2615
+ /**
2616
+ * Compare two RubyGems versions the way Gem::Version does. Missing segments
2617
+ * count as zero, so `2.3` equals `2.3.0`, and a string segment sorts before a
2618
+ * number, so `3.0.0.pre` comes before `3.0.0`.
2619
+ *
2620
+ * @param {string} a Version
2621
+ * @param {string} b Version
2622
+ * @returns {number} Negative, zero, or positive
2623
+ */
2624
+ function compareGemVersions(a, b) {
2625
+ const lhs = gemVersionSegments(a);
2626
+ const rhs = gemVersionSegments(b);
2627
+ const limit = Math.max(lhs.length, rhs.length);
2628
+ for (let i = 0; i < limit; i++) {
2629
+ const l = lhs[i] ?? 0;
2630
+ const r = rhs[i] ?? 0;
2631
+ if (l === r) {
2632
+ continue;
2633
+ }
2634
+ if (typeof l !== typeof r) {
2635
+ return typeof l === "string" ? -1 : 1;
2636
+ }
2637
+ return l < r ? -1 : 1;
2638
+ }
2639
+ return 0;
2640
+ }
2641
+
2642
+ /**
2643
+ * The exclusive upper bound of the pessimistic operator, as Gem::Version#bump
2644
+ * computes it: `~> 3.1` allows `< 4`, and `~> 3.1.2` allows `< 3.2`.
2645
+ *
2646
+ * @param {string} version Version the operator was given
2647
+ * @returns {string} Upper bound
2648
+ */
2649
+ function bumpGemVersion(version) {
2650
+ const segments = gemVersionSegments(version);
2651
+ while (segments.some((segment) => typeof segment === "string")) {
2652
+ segments.pop();
2653
+ }
2654
+ if (segments.length > 1) {
2655
+ segments.pop();
2656
+ }
2657
+ segments[segments.length - 1] += 1;
2658
+ return segments.join(".");
2659
+ }
2660
+
2661
+ /**
2662
+ * Read one Gem::Requirement constraint, such as `>= 3.3` or a bare `3.3.5`.
2663
+ *
2664
+ * @param {string} text Constraint
2665
+ * @returns {{operator: string, version: string}|undefined} The constraint, or
2666
+ * undefined when it is not one, such as an interpolated expression
2667
+ */
2668
+ function parseGemConstraint(text) {
2669
+ let rest = text.trim();
2670
+ const operator =
2671
+ GEM_REQUIREMENT_OPERATORS.find((op) => rest.startsWith(op)) || "=";
2672
+ if (rest.startsWith(operator)) {
2673
+ rest = rest.slice(operator.length).trim();
2674
+ }
2675
+ if (
2676
+ !/^[0-9][0-9A-Za-z.-]*$/.test(rest) ||
2677
+ rest.includes("..") ||
2678
+ rest.endsWith(".")
2679
+ ) {
2680
+ return undefined;
2681
+ }
2682
+ return { operator, version: rest };
2683
+ }
2684
+
2685
+ /**
2686
+ * Intersect Gem::Requirement strings into one `vers` range with the RubyGems
2687
+ * versioning scheme, as the CycloneDX `versionRange` requires. `>= 2.3` and
2688
+ * `>= 3.3` intersect to `vers:gem/>=3.3`, and `~> 3.1` becomes
2689
+ * `vers:gem/>=3.1|<4`, since vers has no pessimistic operator.
2690
+ *
2691
+ * @param {string[]} requirements Requirement strings, each possibly holding
2692
+ * several comma separated constraints
2693
+ * @returns {string|null|undefined} The range, `null` when the requirements
2694
+ * exclude each other, or undefined when none of them could be read
2695
+ */
2696
+ export function rubyRequirementToVers(requirements) {
2697
+ let lower;
2698
+ let upper;
2699
+ let pinned;
2700
+ const excluded = [];
2701
+ let understood = false;
2702
+ const raiseLower = (version, inclusive) => {
2703
+ const cmp = lower ? compareGemVersions(version, lower.version) : 1;
2704
+ if (cmp > 0 || (cmp === 0 && !inclusive)) {
2705
+ lower = { version, inclusive };
2706
+ }
2707
+ };
2708
+ const dropUpper = (version, inclusive) => {
2709
+ const cmp = upper ? compareGemVersions(version, upper.version) : -1;
2710
+ if (cmp < 0 || (cmp === 0 && !inclusive)) {
2711
+ upper = { version, inclusive };
2712
+ }
2713
+ };
2714
+ for (const requirement of requirements) {
2715
+ for (const part of `${requirement}`.split(",")) {
2716
+ const constraint = parseGemConstraint(part);
2717
+ if (!constraint) {
2718
+ continue;
2719
+ }
2720
+ understood = true;
2721
+ const { operator, version } = constraint;
2722
+ if (operator === ">=" || operator === ">") {
2723
+ raiseLower(version, operator === ">=");
2724
+ } else if (operator === "<=" || operator === "<") {
2725
+ dropUpper(version, operator === "<=");
2726
+ } else if (operator === "~>") {
2727
+ raiseLower(version, true);
2728
+ dropUpper(bumpGemVersion(version), false);
2729
+ } else if (operator === "!=") {
2730
+ excluded.push(version);
2731
+ } else if (pinned && compareGemVersions(pinned, version) !== 0) {
2732
+ return null;
2733
+ } else {
2734
+ pinned = version;
2735
+ }
2736
+ }
2737
+ }
2738
+ if (!understood) {
2739
+ return undefined;
2740
+ }
2741
+ // `>= 0` is the requirement of a gem that does not care
2742
+ if (lower?.inclusive && compareGemVersions(lower.version, "0") === 0) {
2743
+ lower = undefined;
2744
+ }
2745
+ const withinBounds = (version) => {
2746
+ const aboveLower = lower
2747
+ ? compareGemVersions(version, lower.version)
2748
+ : Number.POSITIVE_INFINITY;
2749
+ const belowUpper = upper
2750
+ ? compareGemVersions(upper.version, version)
2751
+ : Number.POSITIVE_INFINITY;
2752
+ return (
2753
+ (aboveLower > 0 || (aboveLower === 0 && lower.inclusive)) &&
2754
+ (belowUpper > 0 || (belowUpper === 0 && upper.inclusive))
2755
+ );
2756
+ };
2757
+ const isExcluded = (version) =>
2758
+ excluded.some((aversion) => compareGemVersions(aversion, version) === 0);
2759
+ if (
2760
+ lower &&
2761
+ upper &&
2762
+ compareGemVersions(lower.version, upper.version) === 0
2763
+ ) {
2764
+ if (!lower.inclusive || !upper.inclusive) {
2765
+ return null;
2766
+ }
2767
+ pinned = pinned || lower.version;
2768
+ }
2769
+ if (pinned) {
2770
+ return withinBounds(pinned) && !isExcluded(pinned)
2771
+ ? `vers:gem/${pinned}`
2772
+ : null;
2773
+ }
2774
+ if (lower && upper && compareGemVersions(lower.version, upper.version) > 0) {
2775
+ return null;
2776
+ }
2777
+ const constraints = [];
2778
+ for (const version of excluded) {
2779
+ // An excluded bound turns the bound exclusive, and an exclusion outside
2780
+ // the bounds says nothing
2781
+ if (lower && compareGemVersions(version, lower.version) === 0) {
2782
+ lower.inclusive = false;
2783
+ } else if (upper && compareGemVersions(version, upper.version) === 0) {
2784
+ upper.inclusive = false;
2785
+ } else if (
2786
+ withinBounds(version) &&
2787
+ !constraints.some((c) => compareGemVersions(c.version, version) === 0)
2788
+ ) {
2789
+ constraints.push({ version, text: `!=${version}` });
2790
+ }
2791
+ }
2792
+ if (lower) {
2793
+ constraints.push({
2794
+ version: lower.version,
2795
+ text: `${lower.inclusive ? ">=" : ">"}${lower.version}`,
2796
+ });
2797
+ }
2798
+ if (upper) {
2799
+ constraints.push({
2800
+ version: upper.version,
2801
+ text: `${upper.inclusive ? "<=" : "<"}${upper.version}`,
2802
+ });
2803
+ }
2804
+ if (!constraints.length) {
2805
+ return "vers:gem/*";
2806
+ }
2807
+ constraints.sort((a, b) => compareGemVersions(a.version, b.version));
2808
+ return `vers:gem/${constraints.map((c) => c.text).join("|")}`;
2809
+ }
2810
+
2811
+ /**
2812
+ * Describe the Ruby runtime the gems require as one external `platform`
2813
+ * component, and make the components that declare a `required_ruby_version`
2814
+ * depend on it (discussion 4409).
2815
+ *
2816
+ * The component stands for the Ruby the bundle as a whole runs on, so its
2817
+ * `versionRange` is the intersection of every requirement that could be read,
2818
+ * in the `vers` syntax CycloneDX mandates for the field. Each component's own
2819
+ * requirement stays in its `cdx:gem:rubyVersionSpecifiers` property. One
2820
+ * runtime component per distinct requirement would describe the same
2821
+ * interpreter many times over: a modest bundle spells `>= 2.x` a dozen ways.
2822
+ *
2823
+ * The type is `platform`, the CycloneDX type for a runtime environment that
2824
+ * interprets software, and the one cdxgen gives the Ruby it finds in the
2825
+ * build environment. The purl names no version, since a range is not one.
2826
+ *
2827
+ * `isExternal` and `versionRange` are CycloneDX 1.7 fields, and without them
2828
+ * the component would claim the product bundles its interpreter, so the caller
2829
+ * decides whether the BOM should carry it at all.
2830
+ *
2831
+ * @param {Array} pkgList Components
2832
+ * @param {object} parentComponent The metadata.component, when the project is
2833
+ * itself a gem
2834
+ * @param {Array} dependencies Dependency edges
2835
+ * @returns {{pkgList: Array, dependencies: Array}} Components and edges with
2836
+ * the runtime component added
2837
+ */
2838
+ export function addRubyRuntimeComponent(
2839
+ pkgList,
2840
+ parentComponent,
2841
+ dependencies,
2842
+ ) {
2843
+ const requirements = [];
2844
+ const declaringRefs = new Set();
2845
+ for (const component of [...pkgList, parentComponent]) {
2846
+ const requirement = component?.["bom-ref"]
2847
+ ? component.properties
2848
+ ?.find((prop) => prop.name === "cdx:gem:rubyVersionSpecifiers")
2849
+ ?.value?.trim()
2850
+ : undefined;
2851
+ // A requirement that cannot be read, such as an interpolated expression,
2852
+ // neither narrows the range nor links its gem to the runtime
2853
+ if (requirement && rubyRequirementToVers([requirement]) !== undefined) {
2854
+ requirements.push(requirement);
2855
+ declaringRefs.add(component["bom-ref"]);
2856
+ }
2857
+ }
2858
+ if (!declaringRefs.size) {
2859
+ return { pkgList, dependencies };
2860
+ }
2861
+ const runtimeRef = genericPurl("ruby");
2862
+ const runtimeComponent = {
2863
+ type: "platform",
2864
+ name: "ruby",
2865
+ scope: "required",
2866
+ // The interpreter comes with the deployment environment, not with the
2867
+ // product the BOM describes
2868
+ isExternal: true,
2869
+ purl: runtimeRef,
2870
+ "bom-ref": runtimeRef,
2871
+ };
2872
+ const versionRange = rubyRequirementToVers(requirements);
2873
+ if (versionRange) {
2874
+ runtimeComponent.versionRange = versionRange;
2875
+ } else {
2876
+ thoughtLog(
2877
+ "The Ruby version requirements of the gems exclude each other, so the Ruby runtime component carries no version range.",
2878
+ { requirements },
2879
+ );
2880
+ }
2881
+ const describedRefs = new Set(dependencies.map((d) => d.ref));
2882
+ const outDependencies = dependencies.map((d) =>
2883
+ declaringRefs.has(d.ref)
2884
+ ? {
2885
+ ...d,
2886
+ dependsOn: [...new Set([...(d.dependsOn || []), runtimeRef])].sort(),
2887
+ }
2888
+ : d,
2889
+ );
2890
+ for (const ref of declaringRefs) {
2891
+ if (!describedRefs.has(ref)) {
2892
+ outDependencies.push({ ref, dependsOn: [runtimeRef] });
2893
+ }
2894
+ }
2895
+ if (!describedRefs.has(runtimeRef)) {
2896
+ outDependencies.push({ ref: runtimeRef, dependsOn: [] });
2897
+ }
2898
+ return {
2899
+ pkgList: [...pkgList, runtimeComponent],
2900
+ dependencies: outDependencies,
2901
+ };
1642
2902
  }