@cdxgen/cdxgen 13.2.0 → 13.4.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 (311) hide show
  1. package/README.md +33 -14
  2. package/bin/cdxgen.js +310 -138
  3. package/bin/evinse.js +22 -0
  4. package/bin/repl.js +86 -0
  5. package/bin/sign.js +119 -15
  6. package/bin/tracebom.js +69 -5
  7. package/bin/validate.js +107 -19
  8. package/bin/verify.js +172 -63
  9. package/data/helpers/init.gradle +46 -21
  10. package/data/remediations.json +85 -0
  11. package/data/rules/dependency-sources.yaml +67 -0
  12. package/data/rules/scala-evidence.yaml +115 -0
  13. package/lib/audit/index.js +38 -6
  14. package/lib/cli/bomAssembly.js +105 -15
  15. package/lib/cli/cliOptions.js +93 -1
  16. package/lib/cli/index.js +156 -58
  17. package/lib/cli/jsBom.js +99 -53
  18. package/lib/cli/jvmBom.js +708 -147
  19. package/lib/cli/managedBom.js +415 -224
  20. package/lib/cli/nativeBom.js +264 -44
  21. package/lib/core/activity.js +12 -0
  22. package/lib/core/env.js +22 -15
  23. package/lib/core/fs.js +176 -7
  24. package/lib/core/httpClient.js +159 -24
  25. package/lib/core/metadataProperties.js +97 -0
  26. package/lib/core/pluginRun.js +184 -0
  27. package/lib/core/processTree.js +100 -0
  28. package/lib/core/projectConfig.js +31 -4
  29. package/lib/core/runState.js +39 -0
  30. package/lib/core/toml.js +72 -0
  31. package/lib/ecosystems/asarutils.js +4 -2
  32. package/lib/ecosystems/bunutils.js +274 -108
  33. package/lib/ecosystems/cargoCache.js +231 -8
  34. package/lib/ecosystems/caxa.js +434 -2
  35. package/lib/ecosystems/cmakeResolver.js +76 -21
  36. package/lib/ecosystems/cppBuildContext.js +631 -0
  37. package/lib/ecosystems/cppEvidence.js +399 -51
  38. package/lib/ecosystems/cppIncludeResolver.js +294 -0
  39. package/lib/ecosystems/denoutils.js +93 -66
  40. package/lib/ecosystems/ecosystems.js +1983 -793
  41. package/lib/ecosystems/golem.js +28 -12
  42. package/lib/ecosystems/gradleutils.js +356 -37
  43. package/lib/ecosystems/jsEvidence.js +104 -44
  44. package/lib/ecosystems/kosi.js +31 -7
  45. package/lib/ecosystems/npmutils.js +174 -9
  46. package/lib/ecosystems/parsers-dotnet.js +616 -159
  47. package/lib/ecosystems/parsers-elm.js +46 -28
  48. package/lib/ecosystems/parsers-gleam.js +3 -4
  49. package/lib/ecosystems/parsers-go.js +371 -70
  50. package/lib/ecosystems/parsers-js.js +263 -76
  51. package/lib/ecosystems/parsers-julia.js +2 -3
  52. package/lib/ecosystems/parsers-jvm.js +204 -12
  53. package/lib/ecosystems/parsers-misc.js +929 -216
  54. package/lib/ecosystems/parsers-python.js +167 -24
  55. package/lib/ecosystems/parsers-rust.js +28 -22
  56. package/lib/ecosystems/parsers-terraform.js +1573 -19
  57. package/lib/ecosystems/pythonutils.js +102 -6
  58. package/lib/ecosystems/remote/huggingface.js +3 -0
  59. package/lib/ecosystems/rubyutils.js +1492 -182
  60. package/lib/ecosystems/rusi.js +54 -10
  61. package/lib/ecosystems/sbtutils.js +243 -187
  62. package/lib/ecosystems/scalacliutils.js +342 -0
  63. package/lib/ecosystems/scalasem.js +2618 -0
  64. package/lib/ecosystems/terraformRegistry.js +288 -0
  65. package/lib/ecosystems/utils.js +2 -0
  66. package/lib/ecosystems/vendoredCode.js +156 -0
  67. package/lib/evinser/evinser.js +1008 -191
  68. package/lib/evinser/swiftsem.js +1914 -302
  69. package/lib/helpers/agenticToolutils.js +2 -2
  70. package/lib/helpers/bomSigner.js +1101 -222
  71. package/lib/inventory/aiCollector.js +4 -1
  72. package/lib/inventory/analyzer.js +110 -127
  73. package/lib/inventory/atomUtils.js +703 -43
  74. package/lib/inventory/auditCategories.js +7 -0
  75. package/lib/inventory/buildToolRateLimit.js +144 -0
  76. package/lib/inventory/cacheCommands.js +2 -1
  77. package/lib/inventory/cdxrs.js +103 -12
  78. package/lib/inventory/cmakeBuildDirs.js +254 -0
  79. package/lib/inventory/deps.js +501 -124
  80. package/lib/inventory/depsUtils.js +183 -11
  81. package/lib/inventory/display.js +4 -5
  82. package/lib/inventory/dosai.js +798 -57
  83. package/lib/inventory/dosaiParsers.js +310 -15
  84. package/lib/inventory/dynamic.js +90 -6
  85. package/lib/inventory/evidenceUtils.js +166 -113
  86. package/lib/inventory/fetchBatch.js +690 -55
  87. package/lib/inventory/fetchDiskCache.js +597 -0
  88. package/lib/inventory/forgeEnricher.js +14 -5
  89. package/lib/inventory/jvmLocalRepos.js +917 -0
  90. package/lib/inventory/mcpConfigParser.js +2 -3
  91. package/lib/inventory/parentSelection.js +146 -0
  92. package/lib/inventory/protobom.js +8 -1
  93. package/lib/inventory/purl.js +152 -3
  94. package/lib/inventory/rustFormulationParser.js +3 -4
  95. package/lib/inventory/sbomDocument.js +2 -70
  96. package/lib/inventory/scalaCoords.js +153 -0
  97. package/lib/inventory/source.js +45 -27
  98. package/lib/inventory/spdx.js +101 -0
  99. package/lib/inventory/traceRunner.js +295 -25
  100. package/lib/inventory/tracedCrypto.js +294 -0
  101. package/lib/managers/binary.js +115 -24
  102. package/lib/managers/docker.js +467 -46
  103. package/lib/managers/oci.js +63 -3
  104. package/lib/parsers/cmakeCache.js +69 -0
  105. package/lib/parsers/cmakeLists.js +466 -0
  106. package/lib/parsers/cmakePresets.js +400 -0
  107. package/lib/parsers/compileCommands.js +557 -0
  108. package/lib/parsers/largeJson.js +454 -0
  109. package/lib/parsers/npmrc.js +19 -1
  110. package/lib/parsers/strictJson.js +322 -0
  111. package/lib/parsers/terraformConfig.js +1073 -0
  112. package/lib/parsers/terraformSource.js +652 -0
  113. package/lib/server/server.js +23 -0
  114. package/lib/stages/postgen/introspection/markers.js +1 -1
  115. package/lib/stages/postgen/postgen.js +83 -16
  116. package/lib/stages/postgen/specVersionCompat.js +32 -0
  117. package/lib/validator/bomValidator.js +6 -16
  118. package/lib/validator/index.js +64 -11
  119. package/lib/validator/reporters/console.js +3 -0
  120. package/lib/validator/reporters/json.js +2 -0
  121. package/lib/validator/reporters/sarif.js +7 -0
  122. package/package.json +23 -23
  123. package/types/lib/audit/index.d.ts.map +1 -1
  124. package/types/lib/cli/bomAssembly.d.ts +2 -1
  125. package/types/lib/cli/bomAssembly.d.ts.map +1 -1
  126. package/types/lib/cli/cliOptions.d.ts +33 -1
  127. package/types/lib/cli/cliOptions.d.ts.map +1 -1
  128. package/types/lib/cli/index.d.ts.map +1 -1
  129. package/types/lib/cli/jsBom.d.ts.map +1 -1
  130. package/types/lib/cli/jvmBom.d.ts +24 -1
  131. package/types/lib/cli/jvmBom.d.ts.map +1 -1
  132. package/types/lib/cli/managedBom.d.ts +7 -4
  133. package/types/lib/cli/managedBom.d.ts.map +1 -1
  134. package/types/lib/cli/nativeBom.d.ts +3 -1
  135. package/types/lib/cli/nativeBom.d.ts.map +1 -1
  136. package/types/lib/core/activity.d.ts.map +1 -1
  137. package/types/lib/core/env.d.ts +9 -0
  138. package/types/lib/core/env.d.ts.map +1 -1
  139. package/types/lib/core/fs.d.ts +55 -0
  140. package/types/lib/core/fs.d.ts.map +1 -1
  141. package/types/lib/core/httpClient.d.ts +13 -8
  142. package/types/lib/core/httpClient.d.ts.map +1 -1
  143. package/types/lib/core/metadataProperties.d.ts +39 -0
  144. package/types/lib/core/metadataProperties.d.ts.map +1 -0
  145. package/types/lib/core/pluginRun.d.ts +97 -0
  146. package/types/lib/core/pluginRun.d.ts.map +1 -0
  147. package/types/lib/core/processTree.d.ts +36 -0
  148. package/types/lib/core/processTree.d.ts.map +1 -0
  149. package/types/lib/core/projectConfig.d.ts +6 -2
  150. package/types/lib/core/projectConfig.d.ts.map +1 -1
  151. package/types/lib/core/runState.d.ts +29 -0
  152. package/types/lib/core/runState.d.ts.map +1 -0
  153. package/types/lib/core/toml.d.ts +20 -0
  154. package/types/lib/core/toml.d.ts.map +1 -0
  155. package/types/lib/ecosystems/asarutils.d.ts.map +1 -1
  156. package/types/lib/ecosystems/bunutils.d.ts +12 -5
  157. package/types/lib/ecosystems/bunutils.d.ts.map +1 -1
  158. package/types/lib/ecosystems/cargoCache.d.ts +12 -0
  159. package/types/lib/ecosystems/cargoCache.d.ts.map +1 -1
  160. package/types/lib/ecosystems/caxa.d.ts +14 -0
  161. package/types/lib/ecosystems/caxa.d.ts.map +1 -1
  162. package/types/lib/ecosystems/cmakeResolver.d.ts +28 -3
  163. package/types/lib/ecosystems/cmakeResolver.d.ts.map +1 -1
  164. package/types/lib/ecosystems/cppBuildContext.d.ts +55 -0
  165. package/types/lib/ecosystems/cppBuildContext.d.ts.map +1 -0
  166. package/types/lib/ecosystems/cppEvidence.d.ts +17 -1
  167. package/types/lib/ecosystems/cppEvidence.d.ts.map +1 -1
  168. package/types/lib/ecosystems/cppIncludeResolver.d.ts +92 -0
  169. package/types/lib/ecosystems/cppIncludeResolver.d.ts.map +1 -0
  170. package/types/lib/ecosystems/denoutils.d.ts +9 -3
  171. package/types/lib/ecosystems/denoutils.d.ts.map +1 -1
  172. package/types/lib/ecosystems/ecosystems.d.ts +62 -13
  173. package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
  174. package/types/lib/ecosystems/golem.d.ts.map +1 -1
  175. package/types/lib/ecosystems/gradleutils.d.ts +70 -4
  176. package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
  177. package/types/lib/ecosystems/jsEvidence.d.ts +5 -2
  178. package/types/lib/ecosystems/jsEvidence.d.ts.map +1 -1
  179. package/types/lib/ecosystems/kosi.d.ts +13 -0
  180. package/types/lib/ecosystems/kosi.d.ts.map +1 -1
  181. package/types/lib/ecosystems/npmutils.d.ts +57 -0
  182. package/types/lib/ecosystems/npmutils.d.ts.map +1 -1
  183. package/types/lib/ecosystems/parsers-dotnet.d.ts +118 -4
  184. package/types/lib/ecosystems/parsers-dotnet.d.ts.map +1 -1
  185. package/types/lib/ecosystems/parsers-elm.d.ts.map +1 -1
  186. package/types/lib/ecosystems/parsers-gleam.d.ts.map +1 -1
  187. package/types/lib/ecosystems/parsers-go.d.ts +54 -2
  188. package/types/lib/ecosystems/parsers-go.d.ts.map +1 -1
  189. package/types/lib/ecosystems/parsers-js.d.ts +16 -5
  190. package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
  191. package/types/lib/ecosystems/parsers-julia.d.ts.map +1 -1
  192. package/types/lib/ecosystems/parsers-jvm.d.ts +46 -1
  193. package/types/lib/ecosystems/parsers-jvm.d.ts.map +1 -1
  194. package/types/lib/ecosystems/parsers-misc.d.ts +70 -4
  195. package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
  196. package/types/lib/ecosystems/parsers-python.d.ts +53 -3
  197. package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
  198. package/types/lib/ecosystems/parsers-rust.d.ts.map +1 -1
  199. package/types/lib/ecosystems/parsers-terraform.d.ts +38 -21
  200. package/types/lib/ecosystems/parsers-terraform.d.ts.map +1 -1
  201. package/types/lib/ecosystems/pythonutils.d.ts +27 -2
  202. package/types/lib/ecosystems/pythonutils.d.ts.map +1 -1
  203. package/types/lib/ecosystems/remote/huggingface.d.ts.map +1 -1
  204. package/types/lib/ecosystems/rubyutils.d.ts +164 -3
  205. package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
  206. package/types/lib/ecosystems/rusi.d.ts +13 -0
  207. package/types/lib/ecosystems/rusi.d.ts.map +1 -1
  208. package/types/lib/ecosystems/sbtutils.d.ts +88 -2
  209. package/types/lib/ecosystems/sbtutils.d.ts.map +1 -1
  210. package/types/lib/ecosystems/scalacliutils.d.ts +56 -0
  211. package/types/lib/ecosystems/scalacliutils.d.ts.map +1 -0
  212. package/types/lib/ecosystems/scalasem.d.ts +311 -0
  213. package/types/lib/ecosystems/scalasem.d.ts.map +1 -0
  214. package/types/lib/ecosystems/terraformRegistry.d.ts +9 -0
  215. package/types/lib/ecosystems/terraformRegistry.d.ts.map +1 -0
  216. package/types/lib/ecosystems/utils.d.ts +2 -2
  217. package/types/lib/ecosystems/utils.d.ts.map +1 -1
  218. package/types/lib/ecosystems/vendoredCode.d.ts +32 -0
  219. package/types/lib/ecosystems/vendoredCode.d.ts.map +1 -0
  220. package/types/lib/evinser/evinser.d.ts +132 -15
  221. package/types/lib/evinser/evinser.d.ts.map +1 -1
  222. package/types/lib/evinser/swiftsem.d.ts +280 -15
  223. package/types/lib/evinser/swiftsem.d.ts.map +1 -1
  224. package/types/lib/helpers/bomSigner.d.ts +182 -13
  225. package/types/lib/helpers/bomSigner.d.ts.map +1 -1
  226. package/types/lib/inventory/aiCollector.d.ts.map +1 -1
  227. package/types/lib/inventory/analyzer.d.ts.map +1 -1
  228. package/types/lib/inventory/atomUtils.d.ts +219 -5
  229. package/types/lib/inventory/atomUtils.d.ts.map +1 -1
  230. package/types/lib/inventory/auditCategories.d.ts +3 -0
  231. package/types/lib/inventory/auditCategories.d.ts.map +1 -1
  232. package/types/lib/inventory/buildToolRateLimit.d.ts +38 -0
  233. package/types/lib/inventory/buildToolRateLimit.d.ts.map +1 -0
  234. package/types/lib/inventory/cacheCommands.d.ts +2 -1
  235. package/types/lib/inventory/cacheCommands.d.ts.map +1 -1
  236. package/types/lib/inventory/cdxrs.d.ts +8 -3
  237. package/types/lib/inventory/cdxrs.d.ts.map +1 -1
  238. package/types/lib/inventory/cmakeBuildDirs.d.ts +58 -0
  239. package/types/lib/inventory/cmakeBuildDirs.d.ts.map +1 -0
  240. package/types/lib/inventory/deps.d.ts +59 -1
  241. package/types/lib/inventory/deps.d.ts.map +1 -1
  242. package/types/lib/inventory/depsUtils.d.ts +15 -0
  243. package/types/lib/inventory/depsUtils.d.ts.map +1 -1
  244. package/types/lib/inventory/display.d.ts.map +1 -1
  245. package/types/lib/inventory/dosai.d.ts +121 -9
  246. package/types/lib/inventory/dosai.d.ts.map +1 -1
  247. package/types/lib/inventory/dosaiParsers.d.ts +53 -7
  248. package/types/lib/inventory/dosaiParsers.d.ts.map +1 -1
  249. package/types/lib/inventory/dynamic.d.ts +10 -0
  250. package/types/lib/inventory/dynamic.d.ts.map +1 -1
  251. package/types/lib/inventory/evidenceUtils.d.ts +19 -4
  252. package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
  253. package/types/lib/inventory/fetchBatch.d.ts +83 -14
  254. package/types/lib/inventory/fetchBatch.d.ts.map +1 -1
  255. package/types/lib/inventory/fetchDiskCache.d.ts +169 -0
  256. package/types/lib/inventory/fetchDiskCache.d.ts.map +1 -0
  257. package/types/lib/inventory/forgeEnricher.d.ts.map +1 -1
  258. package/types/lib/inventory/jvmLocalRepos.d.ts +178 -0
  259. package/types/lib/inventory/jvmLocalRepos.d.ts.map +1 -0
  260. package/types/lib/inventory/mcpConfigParser.d.ts.map +1 -1
  261. package/types/lib/inventory/parentSelection.d.ts +34 -0
  262. package/types/lib/inventory/parentSelection.d.ts.map +1 -0
  263. package/types/lib/inventory/plugins.d.ts.map +1 -1
  264. package/types/lib/inventory/protobom.d.ts.map +1 -1
  265. package/types/lib/inventory/purl.d.ts +71 -2
  266. package/types/lib/inventory/purl.d.ts.map +1 -1
  267. package/types/lib/inventory/rustFormulationParser.d.ts.map +1 -1
  268. package/types/lib/inventory/sbomDocument.d.ts.map +1 -1
  269. package/types/lib/inventory/scalaCoords.d.ts +80 -0
  270. package/types/lib/inventory/scalaCoords.d.ts.map +1 -0
  271. package/types/lib/inventory/source.d.ts.map +1 -1
  272. package/types/lib/inventory/spdx.d.ts +9 -0
  273. package/types/lib/inventory/spdx.d.ts.map +1 -1
  274. package/types/lib/inventory/traceRunner.d.ts +59 -1
  275. package/types/lib/inventory/traceRunner.d.ts.map +1 -1
  276. package/types/lib/inventory/tracedCrypto.d.ts +42 -0
  277. package/types/lib/inventory/tracedCrypto.d.ts.map +1 -0
  278. package/types/lib/managers/binary.d.ts +30 -1
  279. package/types/lib/managers/binary.d.ts.map +1 -1
  280. package/types/lib/managers/docker.d.ts +29 -0
  281. package/types/lib/managers/docker.d.ts.map +1 -1
  282. package/types/lib/managers/oci.d.ts +17 -1
  283. package/types/lib/managers/oci.d.ts.map +1 -1
  284. package/types/lib/parsers/cmakeCache.d.ts +16 -0
  285. package/types/lib/parsers/cmakeCache.d.ts.map +1 -1
  286. package/types/lib/parsers/cmakeLists.d.ts +77 -0
  287. package/types/lib/parsers/cmakeLists.d.ts.map +1 -0
  288. package/types/lib/parsers/cmakePresets.d.ts +78 -0
  289. package/types/lib/parsers/cmakePresets.d.ts.map +1 -0
  290. package/types/lib/parsers/compileCommands.d.ts +115 -0
  291. package/types/lib/parsers/compileCommands.d.ts.map +1 -0
  292. package/types/lib/parsers/largeJson.d.ts +52 -0
  293. package/types/lib/parsers/largeJson.d.ts.map +1 -0
  294. package/types/lib/parsers/npmrc.d.ts +5 -0
  295. package/types/lib/parsers/npmrc.d.ts.map +1 -1
  296. package/types/lib/parsers/strictJson.d.ts +46 -0
  297. package/types/lib/parsers/strictJson.d.ts.map +1 -0
  298. package/types/lib/parsers/terraformConfig.d.ts +70 -0
  299. package/types/lib/parsers/terraformConfig.d.ts.map +1 -0
  300. package/types/lib/parsers/terraformSource.d.ts +66 -0
  301. package/types/lib/parsers/terraformSource.d.ts.map +1 -0
  302. package/types/lib/server/server.d.ts.map +1 -1
  303. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  304. package/types/lib/stages/postgen/specVersionCompat.d.ts.map +1 -1
  305. package/types/lib/validator/bomValidator.d.ts.map +1 -1
  306. package/types/lib/validator/index.d.ts +18 -1
  307. package/types/lib/validator/index.d.ts.map +1 -1
  308. package/types/lib/validator/reporters/console.d.ts.map +1 -1
  309. package/types/lib/validator/reporters/json.d.ts.map +1 -1
  310. package/types/lib/validator/reporters/sarif.d.ts.map +1 -1
  311. package/lib/third-party/arborist/snapshots/calc-dep-flags.test.cjs +0 -462
@@ -1,26 +1,34 @@
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";
7
15
 
8
16
  // utils.js does not import rubyutils.js, so importing from it here is safe and
9
17
  // does not introduce a cyclic dependency.
10
- import {
11
- cdxgenAgent,
12
- DEBUG_MODE,
13
- readEnvironmentVariable,
14
- } from "../core/activity.js";
18
+ import { DEBUG_MODE, readEnvironmentVariable } from "../core/activity.js";
15
19
  import { shouldFetchLicense } from "../core/env.js";
16
20
  import { getAllFiles, safeExistsSync, safeSpawnSync } from "../core/fs.js";
21
+ import { thoughtLog } from "../core/logger.js";
17
22
  import { dirNameStr, isWin } from "../core/paths.js";
23
+ import { mergeDependencies } from "../inventory/depsUtils.js";
18
24
  import {
25
+ gatedGet,
19
26
  prefetchEnabled,
20
27
  prefetchedResponse,
21
28
  prefetchJson,
22
29
  recordPolicyDegradationFromError,
23
30
  } from "../inventory/fetchBatch.js";
31
+ import { genericPurl } from "../inventory/purl.js";
24
32
 
25
33
  // FIXME. This has to get removed, once we improve the module detection one-liner.
26
34
  // If you're a Rubyist, please help us improve this code.
@@ -168,7 +176,7 @@ const GEM_METADATA_REFERENCE_TYPES = {
168
176
  documentation_uri: "documentation",
169
177
  funding_uri: "other",
170
178
  homepage_uri: "website",
171
- mailing_list_uri: "other",
179
+ mailing_list_uri: "mailing-list",
172
180
  source_code_uri: "vcs",
173
181
  wiki_uri: "documentation",
174
182
  };
@@ -219,6 +227,430 @@ function parseGemRequirement(value) {
219
227
  return parts.length ? parts.join(", ") : undefined;
220
228
  }
221
229
 
230
+ // Closing delimiters of the bracketing Ruby percent literals, such as `%q{}`.
231
+ // Any other punctuation closes itself, as in `%q|...|`.
232
+ const RUBY_CLOSING_DELIMITERS = { "(": ")", "[": "]", "{": "}", "<": ">" };
233
+
234
+ // Longest heredoc or multi-line array a gemspec assignment is followed across
235
+ const GEMSPEC_MAX_CONTINUATION_LINES = 200;
236
+
237
+ /**
238
+ * Tells whether the source that follows a literal ends the statement, so the
239
+ * literal is the whole value: nothing, a `.freeze`, a comment, or an `if` /
240
+ * `unless` modifier. Anything else, such as `+ suffix`, makes the value an
241
+ * expression that cannot be read statically.
242
+ *
243
+ * @param {string} rest Source after the literal
244
+ * @returns {boolean} true when the literal is the complete value
245
+ */
246
+ function endsRubyStatement(rest) {
247
+ let tail = rest.trim();
248
+ while (tail.startsWith(".freeze")) {
249
+ tail = tail.slice(".freeze".length).trim();
250
+ }
251
+ return (
252
+ !tail.length ||
253
+ tail.startsWith("#") ||
254
+ tail.startsWith(";") ||
255
+ tail.startsWith("if ") ||
256
+ tail.startsWith("unless ")
257
+ );
258
+ }
259
+
260
+ /**
261
+ * Read the Ruby string literal at the start of `text`: a double or single
262
+ * quoted string, or a `%q`/`%Q`/`%` percent literal. Escaped delimiters and
263
+ * backslashes are unescaped; every other escape sequence is kept as written.
264
+ * An interpolation such as `#{spec.name}` is kept verbatim, including any
265
+ * quotes inside it.
266
+ *
267
+ * @param {string} text Source starting with the literal
268
+ * @returns {{value: string, interpolated: boolean, complete: boolean, rest: string} | undefined}
269
+ * The literal's value, whether it holds an interpolation, whether it was
270
+ * closed within `text`, and the source after it. Undefined when `text` does
271
+ * not start with a string literal.
272
+ */
273
+ export function readRubyStringLiteral(text) {
274
+ if (!text?.length) {
275
+ return undefined;
276
+ }
277
+ let opener;
278
+ let interpolates;
279
+ let start;
280
+ if (text[0] === '"' || text[0] === "'") {
281
+ opener = text[0];
282
+ interpolates = opener === '"';
283
+ start = 1;
284
+ } else if (text[0] === "%") {
285
+ const kind = text[1] === "q" || text[1] === "Q" ? text[1] : "";
286
+ opener = text[1 + kind.length];
287
+ // `%w` and `%i` arrays, or a `%` that is the modulo operator
288
+ if (!opener || /[\w\s]/.test(opener)) {
289
+ return undefined;
290
+ }
291
+ interpolates = kind !== "q";
292
+ start = 2 + kind.length;
293
+ } else {
294
+ return undefined;
295
+ }
296
+ const closer = RUBY_CLOSING_DELIMITERS[opener] || opener;
297
+ let depth = 0;
298
+ let value = "";
299
+ let interpolated = false;
300
+ for (let i = start; i < text.length; i++) {
301
+ const ch = text[i];
302
+ if (ch === "\\" && i + 1 < text.length) {
303
+ const next = text[i + 1];
304
+ value += [opener, closer, "\\"].includes(next) ? next : `${ch}${next}`;
305
+ i++;
306
+ continue;
307
+ }
308
+ if (interpolates && ch === "#" && text[i + 1] === "{") {
309
+ const end = text.indexOf("}", i + 2);
310
+ if (end < 0) {
311
+ value += text.slice(i);
312
+ break;
313
+ }
314
+ value += text.slice(i, end + 1);
315
+ interpolated = true;
316
+ i = end;
317
+ continue;
318
+ }
319
+ if (ch === closer && depth === 0) {
320
+ return {
321
+ value,
322
+ interpolated,
323
+ complete: true,
324
+ rest: text.slice(i + 1),
325
+ };
326
+ }
327
+ if (closer !== opener) {
328
+ if (ch === opener) {
329
+ depth++;
330
+ } else if (ch === closer) {
331
+ depth--;
332
+ }
333
+ }
334
+ value += ch;
335
+ }
336
+ return { value, interpolated, complete: false, rest: "" };
337
+ }
338
+
339
+ /**
340
+ * Read a Ruby list of strings as written for `authors`, `email`, or `licenses`:
341
+ * an array of string literals, a `%w[]` word array, or a single string. Array
342
+ * elements that are not string literals, such as constants, are skipped.
343
+ *
344
+ * @param {string} text Source of the value
345
+ * @returns {{values: string[], complete: boolean} | undefined} The strings,
346
+ * and whether the array was closed within `text`. Undefined when the value
347
+ * is an expression that cannot be read statically.
348
+ */
349
+ export function readRubyStringList(text) {
350
+ const source = text?.trim();
351
+ if (!source?.length) {
352
+ return undefined;
353
+ }
354
+ if (/^%[wW][^\w\s]/.test(source)) {
355
+ const closer = RUBY_CLOSING_DELIMITERS[source[2]] || source[2];
356
+ const end = source.indexOf(closer, 3);
357
+ const words = source.slice(3, end < 0 ? undefined : end);
358
+ return {
359
+ values: words.split(" ").filter((word) => word.length),
360
+ complete: end >= 0,
361
+ };
362
+ }
363
+ if (source.startsWith("[")) {
364
+ const values = [];
365
+ let rest = source.slice(1).trim();
366
+ while (rest.length) {
367
+ if (rest.startsWith("]")) {
368
+ return { values, complete: true };
369
+ }
370
+ const literal = readRubyStringLiteral(rest);
371
+ if (literal) {
372
+ if (!literal.complete) {
373
+ return { values, complete: false };
374
+ }
375
+ if (!literal.interpolated && literal.value.trim().length) {
376
+ values.push(literal.value.trim());
377
+ }
378
+ rest = literal.rest.trim();
379
+ while (rest.startsWith(".freeze")) {
380
+ rest = rest.slice(".freeze".length).trim();
381
+ }
382
+ } else {
383
+ // Skip an element that is not a string literal
384
+ const comma = rest.indexOf(",");
385
+ const close = rest.indexOf("]");
386
+ if (close >= 0 && (comma < 0 || close < comma)) {
387
+ return { values, complete: true };
388
+ }
389
+ if (comma < 0) {
390
+ return { values, complete: false };
391
+ }
392
+ rest = rest.slice(comma);
393
+ }
394
+ if (rest.startsWith(",")) {
395
+ rest = rest.slice(1).trim();
396
+ }
397
+ }
398
+ return { values, complete: false };
399
+ }
400
+ const literal = readRubyStringLiteral(source);
401
+ if (
402
+ !literal?.complete ||
403
+ literal.interpolated ||
404
+ !endsRubyStatement(literal.rest)
405
+ ) {
406
+ return undefined;
407
+ }
408
+ return {
409
+ values: literal.value
410
+ .split(",")
411
+ .map((value) => value.trim())
412
+ .filter((value) => value.length),
413
+ complete: true,
414
+ };
415
+ }
416
+
417
+ /**
418
+ * Find the value assigned to a gemspec attribute on a line, such as the
419
+ * `"1.0".freeze` of `s.version = "1.0".freeze`.
420
+ *
421
+ * @param {string} line Whitespace-normalized gemspec line
422
+ * @param {string} attribute Attribute name, such as `version`
423
+ * @returns {string | undefined} Source of the assigned value
424
+ */
425
+ function readGemspecAssignment(line, attribute) {
426
+ const target = `.${attribute}`;
427
+ let idx = line.indexOf(target);
428
+ while (idx >= 0) {
429
+ const after = line.slice(idx + target.length).trimStart();
430
+ // `.version =` but neither `.versions`, `.version ==`, nor `.version =>`
431
+ if (
432
+ after.startsWith("=") &&
433
+ !["=", "~", ">"].includes(after[1]) &&
434
+ !/\w/.test(line[idx + target.length] || "")
435
+ ) {
436
+ return after.slice(1).trim();
437
+ }
438
+ idx = line.indexOf(target, idx + target.length);
439
+ }
440
+ return undefined;
441
+ }
442
+
443
+ /**
444
+ * Read the heredoc terminator of a value such as `<<~DESC` or `<<-'EOS'.strip`.
445
+ *
446
+ * @param {string} value Source of an assigned value
447
+ * @returns {string | undefined} The terminator, or undefined for any other value
448
+ */
449
+ function readHeredocTerminator(value) {
450
+ if (!value.startsWith("<<")) {
451
+ return undefined;
452
+ }
453
+ let rest = value.slice(2);
454
+ if (rest[0] === "~" || rest[0] === "-") {
455
+ rest = rest.slice(1);
456
+ }
457
+ const quote = rest[0] === '"' || rest[0] === "'" ? rest[0] : "";
458
+ rest = rest.slice(quote.length);
459
+ let terminator = "";
460
+ while (
461
+ terminator.length < rest.length &&
462
+ /\w/.test(rest[terminator.length])
463
+ ) {
464
+ terminator += rest[terminator.length];
465
+ }
466
+ return /^[A-Za-z_]\w*$/.test(terminator) ? terminator : undefined;
467
+ }
468
+
469
+ /**
470
+ * Change in the nesting depth of hash braces on a line. Braces inside string
471
+ * literals, including the ones of a `#{...}` interpolation, are not counted.
472
+ *
473
+ * @param {string} line Gemspec line
474
+ * @returns {number} Opened minus closed braces
475
+ */
476
+ function hashBraceDelta(line) {
477
+ let delta = 0;
478
+ for (let i = 0; i < line.length; i++) {
479
+ const ch = line[i];
480
+ if (ch === "#" && line[i + 1] !== "{") {
481
+ // A comment
482
+ break;
483
+ }
484
+ if (ch === '"' || ch === "'") {
485
+ const literal = readRubyStringLiteral(line.slice(i));
486
+ if (!literal?.complete) {
487
+ break;
488
+ }
489
+ i = line.length - literal.rest.length - 1;
490
+ continue;
491
+ }
492
+ if (ch === "{") {
493
+ delta++;
494
+ } else if (ch === "}") {
495
+ delta--;
496
+ }
497
+ }
498
+ return delta;
499
+ }
500
+
501
+ /**
502
+ * Expand the interpolations of a gemspec string that refer to the gem's own
503
+ * fields, such as `#{spec.name}` and `#{spec.version}`. Gems routinely build
504
+ * their changelog and documentation URIs this way.
505
+ *
506
+ * @param {string} value String value with interpolations
507
+ * @param {string | undefined} specVar Block variable of `Gem::Specification.new`
508
+ * @param {Object<string, string | undefined>} fields Known gem fields by name
509
+ * @returns {string} The value with every resolvable interpolation expanded
510
+ */
511
+ function expandGemspecInterpolation(value, specVar, fields) {
512
+ return value.replace(/#\{([^{}]*)\}/g, (token, expression) => {
513
+ const [receiver, field, ...extra] = expression.trim().split(".");
514
+ if (
515
+ extra.length ||
516
+ !(specVar ? receiver === specVar : /^\w+$/.test(receiver)) ||
517
+ !Object.hasOwn(fields, field) ||
518
+ !fields[field]
519
+ ) {
520
+ return token;
521
+ }
522
+ return fields[field];
523
+ });
524
+ }
525
+
526
+ /**
527
+ * Tells whether a string is usable as a RubyGems version.
528
+ *
529
+ * @param {string | undefined} version Candidate version
530
+ * @returns {boolean} true for values such as `0.8.1` or `2.0.0.rc1`
531
+ */
532
+ function looksLikeGemVersion(version) {
533
+ return !!version && /^\d[\w.+-]*$/.test(version);
534
+ }
535
+
536
+ /**
537
+ * Collect the local file a gemspec `require` line loads, when the file looks
538
+ * like the one defining the gem version, such as `lib/<gem>/version.rb`.
539
+ *
540
+ * Supported forms are `require "<gem>/version"`, `require_relative
541
+ * "lib/<gem>/version"`, and `require File.expand_path("../lib/<gem>/version",
542
+ * __FILE__)` or its `__dir__` variant.
543
+ *
544
+ * @param {string} line Whitespace-normalized gemspec line
545
+ * @param {string} gemspecFile Path of the gemspec
546
+ * @returns {string[]} Candidate file paths, most likely first
547
+ */
548
+ function gemspecVersionRequireCandidates(line, gemspecFile) {
549
+ const keyword = ["require_relative", "require"].find(
550
+ (akeyword) =>
551
+ line.startsWith(`${akeyword} `) || line.startsWith(`${akeyword}(`),
552
+ );
553
+ if (!keyword) {
554
+ return [];
555
+ }
556
+ let argument = line.slice(keyword.length).trim();
557
+ if (argument.startsWith("(")) {
558
+ argument = argument.slice(1).trim();
559
+ }
560
+ const baseDir = dirname(gemspecFile);
561
+ let requirePath;
562
+ let candidates;
563
+ const direct = readRubyStringLiteral(argument);
564
+ if (direct?.complete && !direct.interpolated) {
565
+ requirePath = direct.value;
566
+ candidates =
567
+ keyword === "require_relative"
568
+ ? [join(baseDir, requirePath)]
569
+ : [join(baseDir, "lib", requirePath), join(baseDir, requirePath)];
570
+ } else if (argument.startsWith("File.expand_path(")) {
571
+ const expanded = readRubyStringLiteral(
572
+ argument.slice("File.expand_path(".length).trim(),
573
+ );
574
+ if (!expanded?.complete || expanded.interpolated) {
575
+ return [];
576
+ }
577
+ requirePath = expanded.value;
578
+ const anchor = expanded.rest.replace(/^\s*,\s*/, "");
579
+ // `File.expand_path(path, __FILE__)` resolves against the gemspec itself,
580
+ // so its leading `..` steps out of the file name, not the directory
581
+ if (anchor.startsWith("__FILE__")) {
582
+ candidates = [join(gemspecFile, requirePath)];
583
+ } else if (
584
+ anchor.startsWith("__dir__") ||
585
+ anchor.startsWith("File.dirname(__FILE__)")
586
+ ) {
587
+ candidates = [join(baseDir, requirePath)];
588
+ } else {
589
+ return [];
590
+ }
591
+ } else {
592
+ return [];
593
+ }
594
+ if (!/version$/i.test(basename(requirePath).replace(/\.rb$/, ""))) {
595
+ return [];
596
+ }
597
+ return candidates.map((candidate) =>
598
+ candidate.endsWith(".rb") ? candidate : `${candidate}.rb`,
599
+ );
600
+ }
601
+
602
+ /**
603
+ * Resolve a version constant such as `OpenTelemetry::Instrumentation::VERSION`
604
+ * from the version file the gemspec requires. Only files inside the gemspec
605
+ * directory are read, and only a plain string assignment to the constant is
606
+ * accepted.
607
+ *
608
+ * @param {string} gemspecFile Path of the gemspec
609
+ * @param {string[]} candidates Version files the gemspec requires
610
+ * @param {string} constantName Last segment of the constant, such as `VERSION`
611
+ * @returns {string | undefined} Resolved version
612
+ */
613
+ function resolveGemVersionConstant(gemspecFile, candidates, constantName) {
614
+ const root = resolve(dirname(gemspecFile));
615
+ for (const candidate of candidates) {
616
+ const relativePath = relative(root, resolve(candidate));
617
+ if (
618
+ !relativePath.length ||
619
+ relativePath.split(sep)[0] === ".." ||
620
+ isAbsolute(relativePath) ||
621
+ !safeExistsSync(candidate)
622
+ ) {
623
+ continue;
624
+ }
625
+ let content;
626
+ try {
627
+ content = readFileSync(candidate, { encoding: "utf-8" });
628
+ } catch (_err) {
629
+ continue;
630
+ }
631
+ for (const aline of content.split("\n")) {
632
+ const trimmed = aline.trim();
633
+ if (!trimmed.startsWith(constantName)) {
634
+ continue;
635
+ }
636
+ const after = trimmed.slice(constantName.length).trimStart();
637
+ if (!after.startsWith("=") || after[1] === "=") {
638
+ continue;
639
+ }
640
+ const literal = readRubyStringLiteral(after.slice(1).trim());
641
+ if (
642
+ literal?.complete &&
643
+ !literal.interpolated &&
644
+ endsRubyStatement(literal.rest) &&
645
+ looksLikeGemVersion(literal.value)
646
+ ) {
647
+ return literal.value;
648
+ }
649
+ }
650
+ }
651
+ return undefined;
652
+ }
653
+
222
654
  // Digest algorithms Bundler may record in the CHECKSUMS section, mapped to the
223
655
  // CycloneDX hash algorithm names.
224
656
  const GEM_CHECKSUM_ALGOS = {
@@ -478,6 +910,9 @@ export async function enrichGemsFromLocalCache(pkgList, options = {}) {
478
910
  installedPkgs = await parseGemspecData(
479
911
  readFileSync(gemspecFile, { encoding: "utf-8" }),
480
912
  gemspecFile,
913
+ // The gemspec is read only for the fields it holds; the registry
914
+ // round the caller is about to make answers the rest.
915
+ { noFetch: true },
481
916
  );
482
917
  } catch (_err) {
483
918
  // pass
@@ -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
@@ -543,16 +983,21 @@ function applyGemMetadata(p, body) {
543
983
  p.properties.push({ name, value: `${value}` });
544
984
  }
545
985
  };
546
- p.description = body.description || body.summary || "";
547
- if (body.licenses) {
986
+ // The registry fills only what the lockfile, the gemspec and the caches on
987
+ // this machine left open, so a local answer survives the round.
988
+ if (!p.description?.length && (body.description || body.summary)) {
989
+ p.description = body.description || body.summary;
990
+ }
991
+ if (body.licenses && !p.license) {
548
992
  p.license = body.licenses;
549
993
  }
550
994
  if (body.metadata) {
551
- if (body.metadata.source_code_uri) {
995
+ if (body.metadata.source_code_uri && !p.repository) {
552
996
  p.repository = { url: body.metadata.source_code_uri };
553
997
  if (
554
998
  body.homepage_uri &&
555
- body.homepage_uri !== body.metadata.source_code_uri
999
+ body.homepage_uri !== body.metadata.source_code_uri &&
1000
+ !p.homepage
556
1001
  ) {
557
1002
  p.homepage = { url: body.homepage_uri };
558
1003
  }
@@ -593,11 +1038,20 @@ function applyGemMetadata(p, body) {
593
1038
  if (!p.externalReferences.length) {
594
1039
  p.externalReferences = undefined;
595
1040
  }
596
- if (body.sha) {
1041
+ if (body.sha && !p._integrity && !p.hashes?.length) {
597
1042
  p._integrity = `sha256-${body.sha}`;
598
1043
  }
599
- if (body.authors) {
600
- p.author = body.authors;
1044
+ // RubyGems reports the authors as a comma separated string. It is split into
1045
+ // the `authors` array CycloneDX expects, so a gemspec sighting that already
1046
+ // resolved the authors with their email addresses keeps the richer value.
1047
+ if (typeof body.authors === "string" && !p.authors?.length) {
1048
+ const names = body.authors
1049
+ .split(",")
1050
+ .map((name) => name.trim())
1051
+ .filter((name) => name.length);
1052
+ if (names.length) {
1053
+ p.authors = names.map((name) => ({ name }));
1054
+ }
601
1055
  }
602
1056
  // `ruby_version` is the `required_ruby_version` requirement. Note that the
603
1057
  // sibling `rubygems_version` field is the version of RubyGems that packaged
@@ -669,14 +1123,18 @@ function gemDirectUrl(p, v2Url, v1Url) {
669
1123
  * @param {Array} pkgList List of packages with metadata
670
1124
  */
671
1125
  export async function getRubyGemsMetadata(pkgList) {
1126
+ // The endpoints are joined by plain concatenation, so an override without
1127
+ // the trailing slash must not turn into a malformed URL
1128
+ const withTrailingSlash = (url) =>
1129
+ url?.length ? (url.endsWith("/") ? url : `${url}/`) : undefined;
672
1130
  const RUBYGEMS_V2_URL =
673
- readEnvironmentVariable("RUBYGEMS_V2_URL") ||
1131
+ withTrailingSlash(readEnvironmentVariable("RUBYGEMS_V2_URL")) ||
674
1132
  "https://rubygems.org/api/v2/rubygems/";
675
1133
  const RUBYGEMS_V1_URL =
676
- readEnvironmentVariable("RUBYGEMS_V1_URL") ||
1134
+ withTrailingSlash(readEnvironmentVariable("RUBYGEMS_V1_URL")) ||
677
1135
  "https://rubygems.org/api/v1/gems/";
678
1136
  const RUBYGEMS_V1_VERSIONS_URL =
679
- readEnvironmentVariable("RUBYGEMS_V1_VERSIONS_URL") ||
1137
+ withTrailingSlash(readEnvironmentVariable("RUBYGEMS_V1_VERSIONS_URL")) ||
680
1138
  "https://rubygems.org/api/v1/versions/";
681
1139
  const rdepList = [];
682
1140
  const apiOptions = {
@@ -720,7 +1178,17 @@ export async function getRubyGemsMetadata(pkgList) {
720
1178
  for (const [name, pkgs] of Object.entries(pkgsByName)) {
721
1179
  const versionedPkgs = pkgs.filter((p) => p.version);
722
1180
  if (versionedPkgs.length > 1) {
1181
+ // The listing answers every versioned variant of this gem in one
1182
+ // request. A direct URL is asked for only an entry the listing does
1183
+ // not answer, so it is not prefetched here: the serial path below
1184
+ // requests one when the listing misses.
723
1185
  addBatchUrl(`${RUBYGEMS_V1_VERSIONS_URL}${name}.json`);
1186
+ for (const p of pkgs) {
1187
+ if (!p.version) {
1188
+ addBatchUrl(gemDirectUrl(p, RUBYGEMS_V2_URL, RUBYGEMS_V1_URL));
1189
+ }
1190
+ }
1191
+ continue;
724
1192
  }
725
1193
  for (const p of pkgs) {
726
1194
  addBatchUrl(gemDirectUrl(p, RUBYGEMS_V2_URL, RUBYGEMS_V1_URL));
@@ -748,7 +1216,7 @@ export async function getRubyGemsMetadata(pkgList) {
748
1216
  }
749
1217
  const res =
750
1218
  prefetchedResponse(prefetched, versionsUrl) ||
751
- (await cdxgenAgent.get(versionsUrl, apiOptions));
1219
+ (await gatedGet(versionsUrl, apiOptions));
752
1220
  versions = res.body;
753
1221
  }
754
1222
  if (Array.isArray(versions) && versions.length) {
@@ -827,7 +1295,7 @@ async function enrichGemFromVersionEndpoint(
827
1295
  }
828
1296
  const res =
829
1297
  prefetchedResponse(prefetched, fullUrl) ||
830
- (await cdxgenAgent.get(fullUrl, apiOptions));
1298
+ (await gatedGet(fullUrl, apiOptions));
831
1299
  let body = res.body;
832
1300
  if (body?.length) {
833
1301
  body = body[0];
@@ -1025,13 +1493,101 @@ export function collectGemModuleNames(
1025
1493
  : Array.from(simpleModuleNames).sort();
1026
1494
  }
1027
1495
 
1496
+ // One `"key" => value` pair of a gemspec metadata hash, or one
1497
+ // `metadata["key"] = value` assignment. The value is a string literal or a
1498
+ // reference to another spec field, as in `spec.metadata["homepage_uri"] =
1499
+ // spec.homepage`.
1500
+ const GEM_METADATA_PAIR =
1501
+ /["']([a-z_]+)["']\s*(?:=>|\]\s*=)\s*(?:["']([^"']*)["']|(\w+)\.(homepage|name|version)\b)/g;
1502
+
1503
+ /**
1504
+ * Last segment of a version constant such as `Foo::Bar::VERSION`.
1505
+ *
1506
+ * @param {string} expression Source assigned to `spec.version`
1507
+ * @returns {string | undefined} The constant name, such as `VERSION`
1508
+ */
1509
+ function gemVersionConstantName(expression) {
1510
+ let constantPath = expression.split(" ")[0];
1511
+ let suffix;
1512
+ while (
1513
+ (suffix = [".freeze", ".to_s", ".dup"].find((s) =>
1514
+ constantPath.endsWith(s),
1515
+ ))
1516
+ ) {
1517
+ constantPath = constantPath.slice(0, -suffix.length);
1518
+ }
1519
+ if (constantPath.startsWith("::")) {
1520
+ constantPath = constantPath.slice(2);
1521
+ }
1522
+ const segments = constantPath.split("::");
1523
+ if (!segments.every((segment) => /^[A-Z]\w*$/.test(segment))) {
1524
+ return undefined;
1525
+ }
1526
+ return segments[segments.length - 1];
1527
+ }
1528
+
1529
+ /**
1530
+ * List the executables a gem computes from its bindir, such as the `bundle
1531
+ * gem` template's `spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }`.
1532
+ * RubyGems installs every file of the bindir, `bin` unless the gemspec says
1533
+ * otherwise, so the plain files there are the executables. A bindir outside
1534
+ * the gemspec's directory is not followed.
1535
+ *
1536
+ * @param {string} gemspecFile Path of the gemspec
1537
+ * @param {string} [bindir] The gemspec's `bindir`
1538
+ * @returns {string[]} Executable names, sorted
1539
+ */
1540
+ function listGemBindirFiles(gemspecFile, bindir = "bin") {
1541
+ if (!gemspecFile) {
1542
+ return [];
1543
+ }
1544
+ const gemDir = resolve(dirname(gemspecFile));
1545
+ const binPath = resolve(gemDir, bindir);
1546
+ const relativeBinPath = relative(gemDir, binPath);
1547
+ if (
1548
+ !relativeBinPath.length ||
1549
+ relativeBinPath.startsWith("..") ||
1550
+ isAbsolute(relativeBinPath) ||
1551
+ !safeExistsSync(binPath)
1552
+ ) {
1553
+ return [];
1554
+ }
1555
+ try {
1556
+ return readdirSync(binPath, { withFileTypes: true })
1557
+ .filter((entry) => entry.isFile() && !entry.name.startsWith("."))
1558
+ .map((entry) => entry.name)
1559
+ .sort();
1560
+ } catch (_err) {
1561
+ return [];
1562
+ }
1563
+ }
1564
+
1028
1565
  /**
1029
- * Method to parse Gemspec file contents
1566
+ * Method to parse Gemspec file contents.
1567
+ *
1568
+ * A source gemspec is Ruby code. The values that can be read without running
1569
+ * it are string literals, lists of them, heredocs, and a version constant
1570
+ * defined in the version file the gemspec requires. Anything else is left
1571
+ * unresolved instead of being guessed.
1572
+ *
1573
+ * Bundler evaluates the gemspec of a project that uses the Gemfile `gemspec`
1574
+ * directive, and locks the gem under `PATH remote: .`. Such a locked version
1575
+ * can be supplied through `options.lockedVersions` and is used when the
1576
+ * gemspec computes its version in a way that cannot be read statically.
1030
1577
  *
1031
1578
  * @param {string} gemspecData Gemspec data
1032
1579
  * @param {string} gemspecFile File name for evidence.
1580
+ * @param {Object} [options] Parse options
1581
+ * @param {Object<string, string>} [options.lockedVersions] Locked versions of
1582
+ * the gems the project directory serves, by gem name
1583
+ * @param {boolean} [options.projectGemspec] The gemspec describes the project
1584
+ * being scanned. No registry holds the project, so its gem is never looked
1585
+ * up and keeps what the gemspec says.
1586
+ * @param {boolean} [options.noFetch] Read the gemspec for the fields it holds
1587
+ * without asking the registry about the gem. The local cache enricher uses
1588
+ * this, since the caller is responsible for the registry round.
1033
1589
  */
1034
- export async function parseGemspecData(gemspecData, gemspecFile) {
1590
+ export async function parseGemspecData(gemspecData, gemspecFile, options = {}) {
1035
1591
  let pkgList = [];
1036
1592
  const pkg = { properties: [] };
1037
1593
  if (gemspecFile) {
@@ -1042,15 +1598,93 @@ export async function parseGemspecData(gemspecData, gemspecFile) {
1042
1598
  }
1043
1599
  let versionHackMatch = false;
1044
1600
  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("#")) {
1601
+ let specVar;
1602
+ let literalVersion;
1603
+ let versionExpression;
1604
+ let bindir;
1605
+ let computedExecutables = false;
1606
+ const texts = {};
1607
+ const lists = {};
1608
+ const versionFiles = [];
1609
+ // Keyed by metadata keys taken from the gemspec, so it has no prototype
1610
+ const gemMetadata = Object.create(null);
1611
+ let metadataDepth = 0;
1612
+ // A heredoc, string, or array whose value continues on the following lines
1613
+ let pending;
1614
+ const assignText = (field, value) => {
1615
+ const text = value.trim();
1616
+ if (text.length) {
1617
+ texts[field] = text;
1618
+ }
1619
+ };
1620
+ gemspecData.split("\n").forEach((rawLine) => {
1621
+ const line = rawLine.replaceAll("\r", "").replace(/\s+/g, " ").trim();
1622
+ // A string or array that seems to run into the next spec attribute was
1623
+ // misread, so keep what it had and parse the line as usual
1624
+ if (
1625
+ pending &&
1626
+ pending.kind !== "heredoc" &&
1627
+ specVar &&
1628
+ line.startsWith(`${specVar}.`)
1629
+ ) {
1630
+ if (pending.kind === "list") {
1631
+ const partialList = readRubyStringList(pending.text);
1632
+ if (partialList?.values.length) {
1633
+ lists[pending.field] = partialList.values;
1634
+ }
1635
+ } else {
1636
+ assignText(pending.field, readRubyStringLiteral(pending.text).value);
1637
+ }
1638
+ pending = undefined;
1639
+ }
1640
+ if (pending) {
1641
+ pending.lineCount++;
1642
+ if (pending.kind === "heredoc") {
1643
+ if (line === pending.terminator) {
1644
+ assignText(pending.field, pending.text);
1645
+ pending = undefined;
1646
+ } else {
1647
+ pending.text = `${pending.text} ${line}`;
1648
+ }
1649
+ } else {
1650
+ pending.text = `${pending.text} ${line}`;
1651
+ if (pending.kind === "list") {
1652
+ const list = readRubyStringList(pending.text);
1653
+ if (list?.complete) {
1654
+ lists[pending.field] = list.values;
1655
+ pending = undefined;
1656
+ }
1657
+ } else {
1658
+ const literal = readRubyStringLiteral(pending.text);
1659
+ if (literal?.complete) {
1660
+ assignText(pending.field, literal.value);
1661
+ pending = undefined;
1662
+ }
1663
+ }
1664
+ }
1665
+ if (pending && pending.lineCount > GEMSPEC_MAX_CONTINUATION_LINES) {
1666
+ pending = undefined;
1667
+ }
1052
1668
  return;
1053
1669
  }
1670
+ if (line.startsWith("#")) {
1671
+ return;
1672
+ }
1673
+ const l = line.replaceAll("%q{", "").replace(/}$/, "");
1674
+ if (!specVar && line.includes("Gem::Specification.new")) {
1675
+ const opening = line.indexOf("|");
1676
+ const closing = line.indexOf("|", opening + 1);
1677
+ const candidate =
1678
+ opening >= 0 && closing > opening
1679
+ ? line.slice(opening + 1, closing).trim()
1680
+ : "";
1681
+ if (/^\w+$/.test(candidate)) {
1682
+ specVar = candidate;
1683
+ }
1684
+ }
1685
+ if (gemspecFile) {
1686
+ versionFiles.push(...gemspecVersionRequireCandidates(line, gemspecFile));
1687
+ }
1054
1688
  // Native gems declare a platform. Installed gemspecs use either a plain
1055
1689
  // string (`s.platform = "java".freeze`) or the marshalled array form
1056
1690
  // (`s.platform = Gem::Platform.new(["x86_64", "linux", nil])`).
@@ -1075,116 +1709,100 @@ export async function parseGemspecData(gemspecData, gemspecFile) {
1075
1709
  }
1076
1710
  }
1077
1711
  }
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
- }
1712
+ const versionValue = readGemspecAssignment(line, "version");
1713
+ if (versionValue !== undefined) {
1714
+ const literal = readRubyStringLiteral(versionValue);
1715
+ if (
1716
+ literal?.complete &&
1717
+ !literal.interpolated &&
1718
+ endsRubyStatement(literal.rest) &&
1719
+ looksLikeGemVersion(literal.value.trim())
1720
+ ) {
1721
+ literalVersion = literal.value.trim();
1722
+ versionExpression = undefined;
1723
+ } else {
1724
+ literalVersion = undefined;
1725
+ versionExpression = versionValue;
1093
1726
  }
1094
1727
  }
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
- }
1728
+ for (const field of ["name", "description", "summary", "homepage"]) {
1729
+ const value = readGemspecAssignment(line, field);
1730
+ if (value === undefined) {
1731
+ continue;
1732
+ }
1733
+ const terminator = readHeredocTerminator(value);
1734
+ if (terminator && field !== "name") {
1735
+ pending = {
1736
+ kind: "heredoc",
1737
+ field,
1738
+ terminator,
1739
+ text: "",
1740
+ lineCount: 0,
1741
+ };
1742
+ return;
1743
+ }
1744
+ const literal = readRubyStringLiteral(value);
1745
+ if (!literal) {
1746
+ continue;
1747
+ }
1748
+ if (!literal.complete) {
1749
+ if (field !== "name") {
1750
+ pending = { kind: "literal", field, text: value, lineCount: 0 };
1751
+ return;
1122
1752
  }
1753
+ continue;
1123
1754
  }
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
- );
1755
+ if (!endsRubyStatement(literal.rest)) {
1756
+ continue;
1757
+ }
1758
+ if (field === "name") {
1759
+ if (!literal.interpolated && literal.value.trim().length) {
1760
+ pkg.name = literal.value.trim();
1135
1761
  }
1762
+ } else {
1763
+ assignText(field, literal.value);
1136
1764
  }
1137
1765
  }
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
- }
1766
+ // The singular spellings are aliases of the plural attributes, and a
1767
+ // gemspec may spread an array over several lines
1768
+ for (const [attribute, field] of [
1769
+ ["authors", "authors"],
1770
+ ["author", "authors"],
1771
+ ["email", "email"],
1772
+ ["licenses", "licenses"],
1773
+ ["license", "licenses"],
1774
+ ["executables", "executables"],
1775
+ ["executable", "executables"],
1776
+ ]) {
1777
+ const value = readGemspecAssignment(line, attribute);
1778
+ if (value === undefined) {
1779
+ continue;
1780
+ }
1781
+ const list = readRubyStringList(value);
1782
+ if (!list) {
1783
+ // Computed executables, such as the `bundle gem` template's
1784
+ // `spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }`, are read
1785
+ // from the bindir once the whole gemspec is known
1786
+ if (field === "executables") {
1787
+ computedExecutables = true;
1167
1788
  }
1789
+ continue;
1168
1790
  }
1791
+ if (!list.complete) {
1792
+ pending = { kind: "list", field, text: value, lineCount: 0 };
1793
+ return;
1794
+ }
1795
+ lists[field] = list.values;
1169
1796
  }
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
1797
+ const bindirValue = readGemspecAssignment(line, "bindir");
1798
+ if (bindirValue !== undefined) {
1799
+ const literal = readRubyStringLiteral(bindirValue);
1800
+ if (
1801
+ literal?.complete &&
1802
+ !literal.interpolated &&
1803
+ literal.value.trim().length
1804
+ ) {
1805
+ bindir = literal.value.trim();
1188
1806
  }
1189
1807
  }
1190
1808
  // A gem with extensions compiles native code at install time, which is a
@@ -1215,22 +1833,87 @@ export async function parseGemspecData(gemspecData, gemspecFile) {
1215
1833
  // The free form metadata hash carries the well known project URIs and the
1216
1834
  // `rubygems_mfa_required` supply chain signal. It may be written inline as
1217
1835
  // `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,
1836
+ // assigned a key at a time with `s.metadata["a"] = "b"`. A multi-line hash
1837
+ // is followed until its braces balance, so a `#{...}` interpolation or an
1838
+ // unrelated hash further down cannot end or extend it.
1839
+ if (metadataDepth > 0 || line.includes(".metadata")) {
1840
+ for (const [, key, value, refVar, refField] of line.matchAll(
1841
+ GEM_METADATA_PAIR,
1225
1842
  )) {
1226
- gemMetadata[key] = value;
1843
+ gemMetadata[key] =
1844
+ value !== undefined ? { value } : { refVar, refField };
1227
1845
  }
1228
- if (l.includes("}") || (!l.includes(".metadata") && !l.includes("=>"))) {
1229
- metadataBlock = false;
1846
+ if (metadataDepth > 0) {
1847
+ metadataDepth = Math.max(0, metadataDepth + hashBraceDelta(line));
1848
+ } else if (readGemspecAssignment(line, "metadata") !== undefined) {
1849
+ metadataDepth = Math.max(0, hashBraceDelta(line));
1230
1850
  }
1231
1851
  }
1232
1852
  });
1233
- for (const [key, value] of Object.entries(gemMetadata)) {
1853
+ if (literalVersion) {
1854
+ pkg.version = literalVersion;
1855
+ } else if (versionExpression !== undefined) {
1856
+ // Installed gems live in a `<name>-<version>` directory
1857
+ if (gemspecFile) {
1858
+ const parentDir = basename(dirname(gemspecFile));
1859
+ const prefix = `${pkg.name}-`;
1860
+ if (parentDir.startsWith(prefix)) {
1861
+ const versionFromDir = parentDir.slice(prefix.length).split("-")[0];
1862
+ if (looksLikeGemVersion(versionFromDir)) {
1863
+ pkg.version = versionFromDir;
1864
+ versionHackMatch = true;
1865
+ }
1866
+ }
1867
+ }
1868
+ // Most gems built from source assign a constant defined in the
1869
+ // `lib/<gem>/version.rb` file the gemspec requires
1870
+ const constantName = gemVersionConstantName(versionExpression);
1871
+ if (!pkg.version) {
1872
+ pkg.version =
1873
+ constantName && versionFiles.length
1874
+ ? resolveGemVersionConstant(gemspecFile, versionFiles, constantName)
1875
+ : undefined;
1876
+ }
1877
+ }
1878
+ // Fall back to the release Bundler evaluated the gemspec to
1879
+ const lockedVersion =
1880
+ options.lockedVersions && Object.hasOwn(options.lockedVersions, pkg.name)
1881
+ ? options.lockedVersions[pkg.name]
1882
+ : undefined;
1883
+ if (!pkg.version && looksLikeGemVersion(lockedVersion)) {
1884
+ pkg.version = lockedVersion;
1885
+ }
1886
+ if (!pkg.version && versionExpression !== undefined && DEBUG_MODE) {
1887
+ console.log(
1888
+ `Unable to identify the version for '${pkg.name}' from '${versionExpression}'. Spec file: ${gemspecFile}`,
1889
+ );
1890
+ }
1891
+ const specFields = { name: pkg.name, version: pkg.version };
1892
+ const expand = (value) =>
1893
+ expandGemspecInterpolation(value, specVar, specFields);
1894
+ // Gems that only set `summary` still deserve a description in the BOM
1895
+ const description = texts.description || texts.summary;
1896
+ if (description) {
1897
+ pkg.description = expand(description);
1898
+ }
1899
+ // A URL whose interpolation cannot be resolved would be a wrong link
1900
+ const homepage = texts.homepage ? expand(texts.homepage) : undefined;
1901
+ specFields.homepage = homepage?.includes("#{") ? undefined : homepage;
1902
+ for (const [key, entry] of Object.entries(gemMetadata)) {
1903
+ const value =
1904
+ entry.value !== undefined
1905
+ ? expand(entry.value)
1906
+ : !specVar || entry.refVar === specVar
1907
+ ? specFields[entry.refField]
1908
+ : undefined;
1909
+ if (!value?.length || value.includes("#{")) {
1910
+ if (DEBUG_MODE && entry.value !== undefined) {
1911
+ console.log(
1912
+ `Ignoring the metadata '${key}' of '${pkg.name}' with the unresolved value '${entry.value}'. Spec file: ${gemspecFile}`,
1913
+ );
1914
+ }
1915
+ continue;
1916
+ }
1234
1917
  if (key === "rubygems_mfa_required") {
1235
1918
  pkg.properties.push({
1236
1919
  name: "cdx:gem:mfaRequired",
@@ -1238,21 +1921,25 @@ export async function parseGemspecData(gemspecData, gemspecFile) {
1238
1921
  });
1239
1922
  continue;
1240
1923
  }
1241
- const refType = GEM_METADATA_REFERENCE_TYPES[key];
1924
+ const refType = Object.hasOwn(GEM_METADATA_REFERENCE_TYPES, key)
1925
+ ? GEM_METADATA_REFERENCE_TYPES[key]
1926
+ : undefined;
1242
1927
  if (refType) {
1243
1928
  pkg.externalReferences = pkg.externalReferences || [];
1244
1929
  pkg.externalReferences.push({ type: refType, url: value, comment: key });
1245
1930
  }
1246
1931
  }
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) {
1932
+ // `s.homepage = "..."` is the project website
1933
+ if (specFields.homepage) {
1251
1934
  pkg.externalReferences = pkg.externalReferences || [];
1252
- if (!pkg.externalReferences.some((ref) => ref.url === pkg.homepage)) {
1253
- pkg.externalReferences.push({ type: "website", url: pkg.homepage });
1935
+ if (
1936
+ !pkg.externalReferences.some((ref) => ref.url === specFields.homepage)
1937
+ ) {
1938
+ pkg.externalReferences.push({
1939
+ type: "website",
1940
+ url: specFields.homepage,
1941
+ });
1254
1942
  }
1255
- delete pkg.homepage;
1256
1943
  }
1257
1944
  if (pkg.name) {
1258
1945
  const purlString = toGemPurl(pkg.name, pkg.version, gemPlatform);
@@ -1276,22 +1963,48 @@ export async function parseGemspecData(gemspecData, gemspecFile) {
1276
1963
  },
1277
1964
  };
1278
1965
  }
1279
- if (pkg.authors) {
1280
- pkg.authors = pkg.authors.map((a) => {
1281
- return { name: a };
1966
+ if (lists.authors?.length) {
1967
+ // `spec.email` holds the contact addresses of the gem, not of an author.
1968
+ // An address can only be attributed when there is one per author, in the
1969
+ // same order, or when the gem has a single author.
1970
+ const emails = (lists.email || []).filter(
1971
+ (email) => email.includes("@") && !email.includes(" "),
1972
+ );
1973
+ pkg.authors = lists.authors.map((name, i) => {
1974
+ let email;
1975
+ if (emails.length === lists.authors.length) {
1976
+ email = emails[i];
1977
+ } else if (lists.authors.length === 1) {
1978
+ email = emails[0];
1979
+ }
1980
+ return email ? { name, email } : { name };
1282
1981
  });
1283
1982
  }
1284
- if (pkg.licenses) {
1285
- pkg.licenses = pkg.licenses.map((l) => {
1983
+ if (lists.licenses?.length) {
1984
+ pkg.licenses = lists.licenses.map((l) => {
1286
1985
  return { license: { name: l } };
1287
1986
  });
1288
1987
  }
1988
+ // The executables a gem ships make it an application (discussions 4407 and
1989
+ // 4408). A literal list names them; computed ones are the files the gem
1990
+ // installs from its bindir, which a project has next to its gemspec.
1991
+ const executables = lists.executables?.length
1992
+ ? lists.executables
1993
+ : computedExecutables
1994
+ ? listGemBindirFiles(gemspecFile, bindir)
1995
+ : [];
1996
+ if (executables.length) {
1997
+ pkg.properties.push({
1998
+ name: "cdx:gem:executables",
1999
+ value: executables.join(", "),
2000
+ });
2001
+ }
1289
2002
  if (pkg.name) {
1290
2003
  pkgList = [pkg];
1291
2004
  } else {
1292
2005
  console.log("Unable to parse", gemspecData, gemspecFile);
1293
2006
  }
1294
- if (shouldFetchLicense()) {
2007
+ if (shouldFetchLicense() && !options.projectGemspec && !options.noFetch) {
1295
2008
  return await getRubyGemsMetadata(pkgList);
1296
2009
  }
1297
2010
  return pkgList;
@@ -1300,11 +2013,29 @@ export async function parseGemspecData(gemspecData, gemspecFile) {
1300
2013
  /**
1301
2014
  * Method to parse Gemfile.lock
1302
2015
  *
2016
+ * Besides the components and the dependency tree, the result lists as
2017
+ * `projectGemRefs` the bom-refs of the gems served from the lockfile's own
2018
+ * directory (`PATH` with `remote: .`), which is how Bundler records the gem a
2019
+ * project builds when its Gemfile uses the `gemspec` directive.
2020
+ *
2021
+ * Gems the lockfile serves from a git remote or from a path outside the
2022
+ * project are never sent to rubygems.org: no public registry holds the release
2023
+ * they pinned. Before the registry round, the caches on this machine are read
2024
+ * first, so a gem whose installed gemspec or compact index entry is present
2025
+ * keeps those answers.
2026
+ *
1303
2027
  * @param {object} gemLockData Gemfile.lock data
1304
2028
  * @param {string} lockFile Lock file
2029
+ * @param {object} [options] Options
2030
+ * @param {string} [options.gemHome] GEM_HOME to read installed gemspecs from
2031
+ * @param {string} [options.compactIndexCacheDir] Bundler compact index cache
1305
2032
  */
1306
- export async function parseGemfileLockData(gemLockData, lockFile) {
1307
- let pkgList = [];
2033
+ export async function parseGemfileLockData(
2034
+ gemLockData,
2035
+ lockFile,
2036
+ options = {},
2037
+ ) {
2038
+ const pkgList = [];
1308
2039
  const pkgnames = {};
1309
2040
  const dependenciesList = [];
1310
2041
  const dependenciesMap = {};
@@ -1373,14 +2104,24 @@ export async function parseGemfileLockData(gemLockData, lockFile) {
1373
2104
  let lastBranch;
1374
2105
  let lastTag;
1375
2106
  let lastParentPlatform;
2107
+ // The section (`PATH`, `GIT`, `GEM`) and the remote the specs come from
2108
+ let lastSection;
2109
+ let lastSectionRemote;
2110
+ // Source of each gem, recorded from its own top-level line in a specs
2111
+ // section. A nested line such as `opentelemetry-api (~> 1.7)` under a `PATH`
2112
+ // remote is only a dependency constraint: the gem itself is resolved — and
2113
+ // described by its own `remote:` — in another section (discussion 4404).
2114
+ const gemSourceInfo = {};
1376
2115
  // Dependencies block would begin with DEPENDENCIES
1377
2116
  let dependenciesBlock = false;
1378
2117
  const rootList = [];
2118
+ const projectGemRefs = [];
1379
2119
  // In the second pass, we use the space in the prefix to figure out the dependency tree
1380
2120
  gemLockData.split("\n").forEach((l) => {
1381
2121
  l = l.replaceAll("\r", "");
1382
2122
  if (l.trim().startsWith("remote:")) {
1383
2123
  lastRemote = l.trim().split(" ")[1];
2124
+ lastSectionRemote = lastRemote;
1384
2125
  if (lastRemote.length < 3) {
1385
2126
  lastRemote = undefined;
1386
2127
  }
@@ -1406,6 +2147,8 @@ export async function parseGemfileLockData(gemLockData, lockFile) {
1406
2147
  lastBranch = undefined;
1407
2148
  lastTag = undefined;
1408
2149
  lastParentPlatform = undefined;
2150
+ lastSection = l.trim() || undefined;
2151
+ lastSectionRemote = undefined;
1409
2152
  }
1410
2153
  if (l.trim() === "specs:") {
1411
2154
  specsFound = true;
@@ -1489,6 +2232,27 @@ export async function parseGemfileLockData(gemLockData, lockFile) {
1489
2232
  const bomRef = decodeURIComponent(purlString);
1490
2233
  if (level === 1) {
1491
2234
  lastParent = bomRef;
2235
+ if (
2236
+ lastSection === "PATH" &&
2237
+ lastSectionRemote === "." &&
2238
+ !projectGemRefs.includes(bomRef)
2239
+ ) {
2240
+ projectGemRefs.push(bomRef);
2241
+ }
2242
+ // Bundler locks every gem against exactly one source, so the gem's
2243
+ // own top-level line is where its remote belongs. A later duplicate
2244
+ // sighting of the same name would be the same release. The section
2245
+ // is kept as well: a gem under GIT or PATH is served from a source
2246
+ // no public registry holds.
2247
+ if (!gemSourceInfo[name]) {
2248
+ gemSourceInfo[name] = {
2249
+ remote: lastRemote,
2250
+ revision: lastRevision,
2251
+ branch: lastBranch,
2252
+ tag: lastTag,
2253
+ section: lastSection,
2254
+ };
2255
+ }
1492
2256
  }
1493
2257
  const properties = [
1494
2258
  {
@@ -1496,30 +2260,6 @@ export async function parseGemfileLockData(gemLockData, lockFile) {
1496
2260
  value: lockFile,
1497
2261
  },
1498
2262
  ];
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
2263
  const apkg = {
1524
2264
  name,
1525
2265
  version,
@@ -1628,6 +2368,37 @@ export async function parseGemfileLockData(gemLockData, lockFile) {
1628
2368
  }
1629
2369
  }
1630
2370
  });
2371
+ // Attach each gem's own source to it, wherever in the lockfile it was first
2372
+ // sighted. The source properties follow the SrcFile entry to keep the order
2373
+ // the inline construction used to produce.
2374
+ for (const apkg of pkgList) {
2375
+ const info = gemSourceInfo[apkg.name];
2376
+ if (!info || !apkg.properties?.length) {
2377
+ continue;
2378
+ }
2379
+ const sourceProps = [];
2380
+ for (const [propName, value] of [
2381
+ ["cdx:gem:remote", info.remote],
2382
+ ["cdx:gem:remoteRevision", info.revision],
2383
+ ["cdx:gem:remoteBranch", info.branch],
2384
+ ["cdx:gem:remoteTag", info.tag],
2385
+ ]) {
2386
+ if (value && !apkg.properties.some((prop) => prop.name === propName)) {
2387
+ sourceProps.push({ name: propName, value });
2388
+ }
2389
+ }
2390
+ if (!sourceProps.length) {
2391
+ continue;
2392
+ }
2393
+ const srcFileIndex = apkg.properties.findIndex(
2394
+ (prop) => prop.name === "internal:SrcFile",
2395
+ );
2396
+ apkg.properties.splice(
2397
+ srcFileIndex < 0 ? 0 : srcFileIndex + 1,
2398
+ 0,
2399
+ ...sourceProps,
2400
+ );
2401
+ }
1631
2402
  for (const k of Object.keys(dependenciesMap)) {
1632
2403
  dependenciesList.push({
1633
2404
  ref: k,
@@ -1635,8 +2406,547 @@ export async function parseGemfileLockData(gemLockData, lockFile) {
1635
2406
  });
1636
2407
  }
1637
2408
  if (shouldFetchLicense()) {
1638
- pkgList = await getRubyGemsMetadata(pkgList);
1639
- return { pkgList, dependenciesList, rootList };
2409
+ // The caches on this machine answer first: the installed gemspec holds
2410
+ // the licence the registry would be asked for, and the compact index the
2411
+ // checksum, so the registry round below only fills what is still missing.
2412
+ await enrichGemsFromLocalCache(pkgList, options);
2413
+ // The gem the lockfile serves from the project's own directory is the
2414
+ // project itself, and a gem under GIT or PATH is served from a source no
2415
+ // public registry holds; only the rest are looked up.
2416
+ const lookupList = pkgList.filter(
2417
+ (p) =>
2418
+ !projectGemRefs.includes(p["bom-ref"]) &&
2419
+ gemSourceInfo[p.name]?.section !== "GIT" &&
2420
+ gemSourceInfo[p.name]?.section !== "PATH",
2421
+ );
2422
+ await getRubyGemsMetadata(lookupList);
1640
2423
  }
1641
- return { pkgList, dependenciesList, rootList };
2424
+ return { pkgList, dependenciesList, rootList, projectGemRefs };
2425
+ }
2426
+
2427
+ // Fields that describe a gem, carried over to the parent component
2428
+ const GEM_DESCRIPTIVE_FIELDS = [
2429
+ "description",
2430
+ "authors",
2431
+ "licenses",
2432
+ "externalReferences",
2433
+ ];
2434
+
2435
+ /**
2436
+ * Describe the gem a project builds in its parent component.
2437
+ *
2438
+ * A gem project sees its own gem more than once: in its gemspec, in the `PATH`
2439
+ * entry the Gemfile `gemspec` directive adds to its lockfile, and possibly as
2440
+ * an installed copy. Each of them describes the project itself, so all are
2441
+ * folded into the parent (discussion 4388). The parent takes the gem's
2442
+ * identity, its descriptive metadata, and its `cdx:gem:*` properties; the
2443
+ * dependency edges of the sightings move to the parent; and the sightings
2444
+ * leave the component list, so the gem is never a dependency of itself.
2445
+ *
2446
+ * An explicit `--project-version` names the version of the parent. Otherwise
2447
+ * the version of the gemspec, which may have come from the lockfile, is used.
2448
+ *
2449
+ * @param {object} parentComponent Parent component, updated in place
2450
+ * @param {object} projectGem Component parsed from the root gemspec
2451
+ * @param {object[]} pkgList Components
2452
+ * @param {object[]} dependencies Dependency edges
2453
+ * @param {string[]} rootList bom-refs of the direct dependencies
2454
+ * @param {string[]} projectGemRefs bom-refs of the gems the lockfiles serve from the project directory
2455
+ * @param {object} options CLI options
2456
+ * @returns {{pkgList: object[], dependencies: object[], rootList: string[]}}
2457
+ * The components, edges, and direct dependencies with the gem folded in
2458
+ */
2459
+ export function describeProjectGem(
2460
+ parentComponent,
2461
+ projectGem,
2462
+ pkgList,
2463
+ dependencies,
2464
+ rootList,
2465
+ projectGemRefs,
2466
+ options = {},
2467
+ ) {
2468
+ const version = options.projectVersion
2469
+ ? `${options.projectVersion}`
2470
+ : projectGem.version || "latest";
2471
+ const parentPurl = build({
2472
+ type: "gem",
2473
+ namespace: parentComponent.group || null,
2474
+ name: projectGem.name,
2475
+ version,
2476
+ });
2477
+ parentComponent.name = projectGem.name;
2478
+ parentComponent.version = version;
2479
+ parentComponent.purl = parentPurl;
2480
+ parentComponent["bom-ref"] = decodeURIComponent(parentPurl);
2481
+ // A gem that ships executables is an application; every other gem is a
2482
+ // library. The gemspec decides, so the same gem keeps the same type whether
2483
+ // it is the project being scanned or a dependency of another one
2484
+ // (discussion 4407). After trimComponents the root gemspec sighting carries
2485
+ // the properties of every sighting it absorbed, so checking it covers the
2486
+ // gem's lockfile and installed copies as well.
2487
+ const parentComponentType = projectGem.properties?.some(
2488
+ (prop) => prop.name === "cdx:gem:executables" && prop.value?.trim().length,
2489
+ )
2490
+ ? "application"
2491
+ : "library";
2492
+ parentComponent.type = parentComponentType;
2493
+ const parentRef = parentComponent["bom-ref"];
2494
+ const selfRefs = new Set([parentRef, projectGem["bom-ref"]]);
2495
+ for (const ref of projectGemRefs || []) {
2496
+ if (
2497
+ pkgList.some((p) => p["bom-ref"] === ref && p.name === projectGem.name)
2498
+ ) {
2499
+ selfRefs.add(ref);
2500
+ }
2501
+ }
2502
+ // After trimComponents, the retained sighting holds the metadata of all the
2503
+ // duplicates it absorbed, so every retained sighting is consulted
2504
+ const sightings = [
2505
+ projectGem,
2506
+ ...pkgList.filter((p) => p !== projectGem && selfRefs.has(p["bom-ref"])),
2507
+ ];
2508
+ const hasValue = (value) =>
2509
+ Array.isArray(value) ? value.length > 0 : !!value;
2510
+ for (const field of GEM_DESCRIPTIVE_FIELDS) {
2511
+ if (hasValue(parentComponent[field])) {
2512
+ continue;
2513
+ }
2514
+ const source = sightings.find((s) => hasValue(s[field]));
2515
+ if (source) {
2516
+ parentComponent[field] = source[field];
2517
+ }
2518
+ }
2519
+ for (const sighting of sightings) {
2520
+ for (const prop of sighting.properties || []) {
2521
+ // The manifests the parent's metadata came from belong on the parent
2522
+ // too, so they survive as `cdx:bom:componentSrcFiles` (discussion 4410).
2523
+ // Unlike the single-valued `cdx:gem:*` properties, a SrcFile is unique
2524
+ // by name and value: a gem is routinely described by both its gemspec
2525
+ // and its lockfile.
2526
+ const isSourceFile = prop.name === "internal:SrcFile";
2527
+ if (
2528
+ (prop.name.startsWith("cdx:gem:") || isSourceFile) &&
2529
+ !parentComponent.properties?.some(
2530
+ (p) =>
2531
+ p.name === prop.name && (!isSourceFile || p.value === prop.value),
2532
+ )
2533
+ ) {
2534
+ parentComponent.properties = parentComponent.properties || [];
2535
+ parentComponent.properties.push(prop);
2536
+ }
2537
+ }
2538
+ // Checksums identify an artifact, so only the parent's own release counts
2539
+ if (sighting["bom-ref"] === parentRef && sighting.hashes?.length) {
2540
+ parentComponent.hashes = parentComponent.hashes || [];
2541
+ for (const ahash of sighting.hashes) {
2542
+ if (
2543
+ !parentComponent.hashes.some(
2544
+ (h) => h.alg === ahash.alg && h.content === ahash.content,
2545
+ )
2546
+ ) {
2547
+ parentComponent.hashes.push(ahash);
2548
+ }
2549
+ }
2550
+ }
2551
+ }
2552
+ const rekey = (ref) => (selfRefs.has(ref) ? parentRef : ref);
2553
+ return {
2554
+ pkgList: pkgList.filter((p) => !selfRefs.has(p["bom-ref"])),
2555
+ // mergeDependencies unites the entries that now share the parent's ref and
2556
+ // drops the edges pointing back at the parent
2557
+ dependencies: mergeDependencies(
2558
+ [],
2559
+ dependencies.map((d) => ({
2560
+ ...d,
2561
+ ref: rekey(d.ref),
2562
+ dependsOn: (d.dependsOn || []).map(rekey),
2563
+ })),
2564
+ parentComponent,
2565
+ ),
2566
+ rootList: [
2567
+ ...new Set(rootList.map(rekey).filter((ref) => ref !== parentRef)),
2568
+ ],
2569
+ };
2570
+ }
2571
+
2572
+ // The public RubyGems registry, whose download URL for a gem release is
2573
+ // `https://rubygems.org/gems/<name>-<version>[-<platform>].gem`
2574
+ const RUBYGEMS_ORG_REMOTE = "https://rubygems.org";
2575
+
2576
+ /**
2577
+ * Point each gem resolved from the public RubyGems registry at its `.gem`
2578
+ * tarball with a `distribution` external reference (discussion 4406).
2579
+ *
2580
+ * The registry API supplies `gem_uri` when it is reachable, but an offline
2581
+ * scan — a dry run, an air-gapped host, or one that only consulted Bundler's
2582
+ * local caches — has no way to learn it. For the public registry the URL is
2583
+ * deterministic, so it is derived from the recorded `cdx:gem:remote` and the
2584
+ * release identity instead. Gems from any other remote are left alone: a
2585
+ * private mirror's layout is its own business.
2586
+ *
2587
+ * @param {Array} pkgList Gem components, enriched in place
2588
+ * @returns {Array} The package list
2589
+ */
2590
+ export function addRubyGemsDistributionUrls(pkgList) {
2591
+ for (const p of pkgList) {
2592
+ if (!p?.name || !p.version) {
2593
+ continue;
2594
+ }
2595
+ const remote = p.properties?.find(
2596
+ (prop) => prop.name === "cdx:gem:remote",
2597
+ )?.value;
2598
+ // Older lockfiles name the registry over plain http, which it redirects
2599
+ if (
2600
+ !remote ||
2601
+ !["https://rubygems.org", "http://rubygems.org"].includes(
2602
+ remote.replace(/\/$/, ""),
2603
+ )
2604
+ ) {
2605
+ continue;
2606
+ }
2607
+ if (
2608
+ p.externalReferences?.some((ref) => ref.type === "distribution") ||
2609
+ p.properties?.some((prop) => prop.name === "cdx:gem:gemUri")
2610
+ ) {
2611
+ continue;
2612
+ }
2613
+ const platform = gemComponentPlatform(p);
2614
+ const gemFile =
2615
+ platform && platform !== "ruby"
2616
+ ? `${p.name}-${p.version}-${platform}.gem`
2617
+ : `${p.name}-${p.version}.gem`;
2618
+ p.externalReferences = p.externalReferences || [];
2619
+ p.externalReferences.push({
2620
+ type: "distribution",
2621
+ url: `${RUBYGEMS_ORG_REMOTE}/gems/${gemFile}`,
2622
+ });
2623
+ }
2624
+ return pkgList;
2625
+ }
2626
+
2627
+ // Gem::Requirement operators, longest first so `>=` is not read as `>`
2628
+ const GEM_REQUIREMENT_OPERATORS = ["~>", ">=", "<=", "!=", ">", "<", "="];
2629
+
2630
+ /**
2631
+ * Split a RubyGems version into the segments Gem::Version compares: a run of
2632
+ * digits is a number and a run of letters is a string, so `3.3.0.preview1`
2633
+ * reads as `[3, 3, 0, "preview", 1]`. RubyGems treats `-` as `.pre.`.
2634
+ *
2635
+ * @param {string} version Version string
2636
+ * @returns {Array<number|string>} Segments
2637
+ */
2638
+ function gemVersionSegments(version) {
2639
+ const segments = [];
2640
+ let token = "";
2641
+ let tokenIsDigits = false;
2642
+ const flush = () => {
2643
+ if (token.length) {
2644
+ segments.push(tokenIsDigits ? Number.parseInt(token, 10) : token);
2645
+ }
2646
+ token = "";
2647
+ };
2648
+ for (const ch of version.replaceAll("-", ".pre.")) {
2649
+ const isDigit = ch >= "0" && ch <= "9";
2650
+ const isLetter = (ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z");
2651
+ if (!isDigit && !isLetter) {
2652
+ flush();
2653
+ continue;
2654
+ }
2655
+ if (token.length && isDigit !== tokenIsDigits) {
2656
+ flush();
2657
+ }
2658
+ tokenIsDigits = isDigit;
2659
+ token += ch;
2660
+ }
2661
+ flush();
2662
+ return segments;
2663
+ }
2664
+
2665
+ /**
2666
+ * Compare two RubyGems versions the way Gem::Version does. Missing segments
2667
+ * count as zero, so `2.3` equals `2.3.0`, and a string segment sorts before a
2668
+ * number, so `3.0.0.pre` comes before `3.0.0`.
2669
+ *
2670
+ * @param {string} a Version
2671
+ * @param {string} b Version
2672
+ * @returns {number} Negative, zero, or positive
2673
+ */
2674
+ function compareGemVersions(a, b) {
2675
+ const lhs = gemVersionSegments(a);
2676
+ const rhs = gemVersionSegments(b);
2677
+ const limit = Math.max(lhs.length, rhs.length);
2678
+ for (let i = 0; i < limit; i++) {
2679
+ const l = lhs[i] ?? 0;
2680
+ const r = rhs[i] ?? 0;
2681
+ if (l === r) {
2682
+ continue;
2683
+ }
2684
+ if (typeof l !== typeof r) {
2685
+ return typeof l === "string" ? -1 : 1;
2686
+ }
2687
+ return l < r ? -1 : 1;
2688
+ }
2689
+ return 0;
2690
+ }
2691
+
2692
+ /**
2693
+ * The exclusive upper bound of the pessimistic operator, as Gem::Version#bump
2694
+ * computes it: `~> 3.1` allows `< 4`, and `~> 3.1.2` allows `< 3.2`.
2695
+ *
2696
+ * @param {string} version Version the operator was given
2697
+ * @returns {string} Upper bound
2698
+ */
2699
+ function bumpGemVersion(version) {
2700
+ const segments = gemVersionSegments(version);
2701
+ while (segments.some((segment) => typeof segment === "string")) {
2702
+ segments.pop();
2703
+ }
2704
+ if (segments.length > 1) {
2705
+ segments.pop();
2706
+ }
2707
+ segments[segments.length - 1] += 1;
2708
+ return segments.join(".");
2709
+ }
2710
+
2711
+ /**
2712
+ * Read one Gem::Requirement constraint, such as `>= 3.3` or a bare `3.3.5`.
2713
+ *
2714
+ * @param {string} text Constraint
2715
+ * @returns {{operator: string, version: string}|undefined} The constraint, or
2716
+ * undefined when it is not one, such as an interpolated expression
2717
+ */
2718
+ function parseGemConstraint(text) {
2719
+ let rest = text.trim();
2720
+ const operator =
2721
+ GEM_REQUIREMENT_OPERATORS.find((op) => rest.startsWith(op)) || "=";
2722
+ if (rest.startsWith(operator)) {
2723
+ rest = rest.slice(operator.length).trim();
2724
+ }
2725
+ if (
2726
+ !/^[0-9][0-9A-Za-z.-]*$/.test(rest) ||
2727
+ rest.includes("..") ||
2728
+ rest.endsWith(".")
2729
+ ) {
2730
+ return undefined;
2731
+ }
2732
+ return { operator, version: rest };
2733
+ }
2734
+
2735
+ /**
2736
+ * Intersect Gem::Requirement strings into one `vers` range with the RubyGems
2737
+ * versioning scheme, as the CycloneDX `versionRange` requires. `>= 2.3` and
2738
+ * `>= 3.3` intersect to `vers:gem/>=3.3`, and `~> 3.1` becomes
2739
+ * `vers:gem/>=3.1|<4`, since vers has no pessimistic operator.
2740
+ *
2741
+ * @param {string[]} requirements Requirement strings, each possibly holding
2742
+ * several comma separated constraints
2743
+ * @returns {string|null|undefined} The range, `null` when the requirements
2744
+ * exclude each other, or undefined when none of them could be read
2745
+ */
2746
+ export function rubyRequirementToVers(requirements) {
2747
+ let lower;
2748
+ let upper;
2749
+ let pinned;
2750
+ const excluded = [];
2751
+ let understood = false;
2752
+ const raiseLower = (version, inclusive) => {
2753
+ const cmp = lower ? compareGemVersions(version, lower.version) : 1;
2754
+ if (cmp > 0 || (cmp === 0 && !inclusive)) {
2755
+ lower = { version, inclusive };
2756
+ }
2757
+ };
2758
+ const dropUpper = (version, inclusive) => {
2759
+ const cmp = upper ? compareGemVersions(version, upper.version) : -1;
2760
+ if (cmp < 0 || (cmp === 0 && !inclusive)) {
2761
+ upper = { version, inclusive };
2762
+ }
2763
+ };
2764
+ for (const requirement of requirements) {
2765
+ for (const part of `${requirement}`.split(",")) {
2766
+ const constraint = parseGemConstraint(part);
2767
+ if (!constraint) {
2768
+ continue;
2769
+ }
2770
+ understood = true;
2771
+ const { operator, version } = constraint;
2772
+ if (operator === ">=" || operator === ">") {
2773
+ raiseLower(version, operator === ">=");
2774
+ } else if (operator === "<=" || operator === "<") {
2775
+ dropUpper(version, operator === "<=");
2776
+ } else if (operator === "~>") {
2777
+ raiseLower(version, true);
2778
+ dropUpper(bumpGemVersion(version), false);
2779
+ } else if (operator === "!=") {
2780
+ excluded.push(version);
2781
+ } else if (pinned && compareGemVersions(pinned, version) !== 0) {
2782
+ return null;
2783
+ } else {
2784
+ pinned = version;
2785
+ }
2786
+ }
2787
+ }
2788
+ if (!understood) {
2789
+ return undefined;
2790
+ }
2791
+ // `>= 0` is the requirement of a gem that does not care
2792
+ if (lower?.inclusive && compareGemVersions(lower.version, "0") === 0) {
2793
+ lower = undefined;
2794
+ }
2795
+ const withinBounds = (version) => {
2796
+ const aboveLower = lower
2797
+ ? compareGemVersions(version, lower.version)
2798
+ : Number.POSITIVE_INFINITY;
2799
+ const belowUpper = upper
2800
+ ? compareGemVersions(upper.version, version)
2801
+ : Number.POSITIVE_INFINITY;
2802
+ return (
2803
+ (aboveLower > 0 || (aboveLower === 0 && lower.inclusive)) &&
2804
+ (belowUpper > 0 || (belowUpper === 0 && upper.inclusive))
2805
+ );
2806
+ };
2807
+ const isExcluded = (version) =>
2808
+ excluded.some((aversion) => compareGemVersions(aversion, version) === 0);
2809
+ if (
2810
+ lower &&
2811
+ upper &&
2812
+ compareGemVersions(lower.version, upper.version) === 0
2813
+ ) {
2814
+ if (!lower.inclusive || !upper.inclusive) {
2815
+ return null;
2816
+ }
2817
+ pinned = pinned || lower.version;
2818
+ }
2819
+ if (pinned) {
2820
+ return withinBounds(pinned) && !isExcluded(pinned)
2821
+ ? `vers:gem/${pinned}`
2822
+ : null;
2823
+ }
2824
+ if (lower && upper && compareGemVersions(lower.version, upper.version) > 0) {
2825
+ return null;
2826
+ }
2827
+ const constraints = [];
2828
+ for (const version of excluded) {
2829
+ // An excluded bound turns the bound exclusive, and an exclusion outside
2830
+ // the bounds says nothing
2831
+ if (lower && compareGemVersions(version, lower.version) === 0) {
2832
+ lower.inclusive = false;
2833
+ } else if (upper && compareGemVersions(version, upper.version) === 0) {
2834
+ upper.inclusive = false;
2835
+ } else if (
2836
+ withinBounds(version) &&
2837
+ !constraints.some((c) => compareGemVersions(c.version, version) === 0)
2838
+ ) {
2839
+ constraints.push({ version, text: `!=${version}` });
2840
+ }
2841
+ }
2842
+ if (lower) {
2843
+ constraints.push({
2844
+ version: lower.version,
2845
+ text: `${lower.inclusive ? ">=" : ">"}${lower.version}`,
2846
+ });
2847
+ }
2848
+ if (upper) {
2849
+ constraints.push({
2850
+ version: upper.version,
2851
+ text: `${upper.inclusive ? "<=" : "<"}${upper.version}`,
2852
+ });
2853
+ }
2854
+ if (!constraints.length) {
2855
+ return "vers:gem/*";
2856
+ }
2857
+ constraints.sort((a, b) => compareGemVersions(a.version, b.version));
2858
+ return `vers:gem/${constraints.map((c) => c.text).join("|")}`;
2859
+ }
2860
+
2861
+ /**
2862
+ * Describe the Ruby runtime the gems require as one external `platform`
2863
+ * component, and make the components that declare a `required_ruby_version`
2864
+ * depend on it (discussion 4409).
2865
+ *
2866
+ * The component stands for the Ruby the bundle as a whole runs on, so its
2867
+ * `versionRange` is the intersection of every requirement that could be read,
2868
+ * in the `vers` syntax CycloneDX mandates for the field. Each component's own
2869
+ * requirement stays in its `cdx:gem:rubyVersionSpecifiers` property. One
2870
+ * runtime component per distinct requirement would describe the same
2871
+ * interpreter many times over: a modest bundle spells `>= 2.x` a dozen ways.
2872
+ *
2873
+ * The type is `platform`, the CycloneDX type for a runtime environment that
2874
+ * interprets software, and the one cdxgen gives the Ruby it finds in the
2875
+ * build environment. The purl names no version, since a range is not one.
2876
+ *
2877
+ * `isExternal` and `versionRange` are CycloneDX 1.7 fields, and without them
2878
+ * the component would claim the product bundles its interpreter, so the caller
2879
+ * decides whether the BOM should carry it at all.
2880
+ *
2881
+ * @param {Array} pkgList Components
2882
+ * @param {object} parentComponent The metadata.component, when the project is
2883
+ * itself a gem
2884
+ * @param {Array} dependencies Dependency edges
2885
+ * @returns {{pkgList: Array, dependencies: Array}} Components and edges with
2886
+ * the runtime component added
2887
+ */
2888
+ export function addRubyRuntimeComponent(
2889
+ pkgList,
2890
+ parentComponent,
2891
+ dependencies,
2892
+ ) {
2893
+ const requirements = [];
2894
+ const declaringRefs = new Set();
2895
+ for (const component of [...pkgList, parentComponent]) {
2896
+ const requirement = component?.["bom-ref"]
2897
+ ? component.properties
2898
+ ?.find((prop) => prop.name === "cdx:gem:rubyVersionSpecifiers")
2899
+ ?.value?.trim()
2900
+ : undefined;
2901
+ // A requirement that cannot be read, such as an interpolated expression,
2902
+ // neither narrows the range nor links its gem to the runtime
2903
+ if (requirement && rubyRequirementToVers([requirement]) !== undefined) {
2904
+ requirements.push(requirement);
2905
+ declaringRefs.add(component["bom-ref"]);
2906
+ }
2907
+ }
2908
+ if (!declaringRefs.size) {
2909
+ return { pkgList, dependencies };
2910
+ }
2911
+ const runtimeRef = genericPurl("ruby");
2912
+ const runtimeComponent = {
2913
+ type: "platform",
2914
+ name: "ruby",
2915
+ scope: "required",
2916
+ // The interpreter comes with the deployment environment, not with the
2917
+ // product the BOM describes
2918
+ isExternal: true,
2919
+ purl: runtimeRef,
2920
+ "bom-ref": runtimeRef,
2921
+ };
2922
+ const versionRange = rubyRequirementToVers(requirements);
2923
+ if (versionRange) {
2924
+ runtimeComponent.versionRange = versionRange;
2925
+ } else {
2926
+ thoughtLog(
2927
+ "The Ruby version requirements of the gems exclude each other, so the Ruby runtime component carries no version range.",
2928
+ { requirements },
2929
+ );
2930
+ }
2931
+ const describedRefs = new Set(dependencies.map((d) => d.ref));
2932
+ const outDependencies = dependencies.map((d) =>
2933
+ declaringRefs.has(d.ref)
2934
+ ? {
2935
+ ...d,
2936
+ dependsOn: [...new Set([...(d.dependsOn || []), runtimeRef])].sort(),
2937
+ }
2938
+ : d,
2939
+ );
2940
+ for (const ref of declaringRefs) {
2941
+ if (!describedRefs.has(ref)) {
2942
+ outDependencies.push({ ref, dependsOn: [runtimeRef] });
2943
+ }
2944
+ }
2945
+ if (!describedRefs.has(runtimeRef)) {
2946
+ outDependencies.push({ ref: runtimeRef, dependsOn: [] });
2947
+ }
2948
+ return {
2949
+ pkgList: [...pkgList, runtimeComponent],
2950
+ dependencies: outDependencies,
2951
+ };
1642
2952
  }