@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.
- package/README.md +32 -15
- package/bin/cdxgen.js +243 -122
- package/bin/evinse.js +7 -0
- package/bin/sign.js +88 -14
- package/bin/tracebom.js +64 -4
- package/bin/validate.js +29 -7
- package/bin/verify.js +79 -39
- package/data/crypto-oid.json +88 -12
- package/data/remediations.json +47 -0
- package/data/rules/ai-bom.yaml +78 -0
- package/data/rules/cargo-rust.yaml +132 -0
- package/data/rules/mcp-servers.yaml +122 -0
- package/lib/audit/targets.js +6 -1
- package/lib/cli/bomAssembly.js +117 -15
- package/lib/cli/cliOptions.js +93 -1
- package/lib/cli/index.js +235 -5
- package/lib/cli/jsBom.js +108 -26
- package/lib/cli/jvmBom.js +68 -12
- package/lib/cli/managedBom.js +264 -41
- package/lib/cli/nativeBom.js +923 -15
- package/lib/core/activity.js +120 -2
- package/lib/core/env.js +44 -7
- package/lib/core/fs.js +174 -6
- package/lib/core/metadataProperties.js +97 -0
- package/lib/core/projectConfig.js +31 -4
- package/lib/core/userAgent.js +151 -0
- package/lib/ecosystems/bunutils.js +269 -101
- package/lib/ecosystems/cargoCache.js +364 -0
- package/lib/ecosystems/caxa.js +447 -7
- package/lib/ecosystems/cmakeResolver.js +66 -0
- package/lib/ecosystems/cppEvidence.js +104 -33
- package/lib/ecosystems/ecosystems.js +260 -99
- package/lib/ecosystems/golem.js +11 -1
- package/lib/ecosystems/gradleutils.js +185 -9
- package/lib/ecosystems/jsEvidence.js +19 -20
- package/lib/ecosystems/kosi.js +1263 -0
- package/lib/ecosystems/npmutils.js +110 -9
- package/lib/ecosystems/parsers-crystal.js +179 -0
- package/lib/ecosystems/parsers-dotnet.js +120 -49
- package/lib/ecosystems/parsers-elm.js +816 -0
- package/lib/ecosystems/parsers-gleam.js +1 -1
- package/lib/ecosystems/parsers-go.js +47 -9
- package/lib/ecosystems/parsers-js.js +171 -50
- package/lib/ecosystems/parsers-julia.js +268 -0
- package/lib/ecosystems/parsers-lua.js +218 -0
- package/lib/ecosystems/parsers-misc.js +919 -161
- package/lib/ecosystems/parsers-nim.js +221 -0
- package/lib/ecosystems/parsers-opam.js +180 -0
- package/lib/ecosystems/parsers-perl.js +277 -0
- package/lib/ecosystems/parsers-python.js +27 -15
- package/lib/ecosystems/parsers-r.js +148 -0
- package/lib/ecosystems/parsers-rebar.js +411 -0
- package/lib/ecosystems/parsers-rust.js +954 -33
- package/lib/ecosystems/parsers-spack.js +204 -0
- package/lib/ecosystems/parsers-stack.js +264 -0
- package/lib/ecosystems/parsers-terraform.js +194 -0
- package/lib/ecosystems/rubyutils.js +1426 -166
- package/lib/ecosystems/rusi.js +158 -7
- package/lib/evinser/evinser.js +547 -129
- package/lib/evinser/swiftsem.js +1914 -302
- package/lib/helpers/agenticToolutils.js +925 -0
- package/lib/helpers/bomSigner.js +652 -221
- package/lib/inventory/agentFormulationParser.js +57 -4
- package/lib/inventory/aiCollector.js +504 -10
- package/lib/inventory/aiInventory.js +12 -0
- package/lib/inventory/analyzer.js +488 -30
- package/lib/inventory/astWorker.js +6 -0
- package/lib/inventory/atomUtils.js +568 -31
- package/lib/inventory/auditCategories.js +8 -0
- package/lib/inventory/cbomutils.js +30 -0
- package/lib/inventory/cdxrs.js +110 -14
- package/lib/inventory/communityAiConfigParser.js +215 -2
- package/lib/inventory/depsUtils.js +183 -11
- package/lib/inventory/display.js +4 -5
- package/lib/inventory/dosai.js +627 -13
- package/lib/inventory/dynamic.js +39 -1
- package/lib/inventory/envcontext.js +9 -6
- package/lib/inventory/evidenceUtils.js +26 -5
- package/lib/inventory/fetchBatch.js +190 -19
- package/lib/inventory/fetchRate.js +74 -11
- package/lib/inventory/forgeEnricher.js +3 -2
- package/lib/inventory/jvmToolEnv.js +6 -5
- package/lib/inventory/mcpConfigParser.js +166 -2
- package/lib/inventory/mcpDiscovery.js +11 -0
- package/lib/inventory/mcpServerManifest.js +389 -0
- package/lib/inventory/mcpbBundle.js +419 -0
- package/lib/inventory/plugins.js +3 -0
- package/lib/inventory/protobom.js +62 -89
- package/lib/inventory/protobomLoader.js +4 -0
- package/lib/inventory/purl.js +152 -3
- package/lib/inventory/rustFormulationParser.js +1 -1
- package/lib/inventory/sbomDocument.js +2 -70
- package/lib/inventory/source.js +47 -23
- package/lib/inventory/stylePackageRefs.js +41 -0
- package/lib/inventory/svelteUtils.js +524 -0
- package/lib/inventory/traceRunner.js +210 -21
- package/lib/managers/binary.js +115 -24
- package/lib/managers/docker.js +440 -20
- package/lib/managers/oci.js +39 -1
- package/lib/parsers/cmakeLists.js +452 -0
- package/lib/parsers/huggingfaceUtils.js +15 -7
- package/lib/parsers/largeJson.js +454 -0
- package/lib/parsers/npmrc.js +19 -1
- package/lib/parsers/onnx.js +294 -0
- package/lib/parsers/safetensors.js +114 -0
- package/lib/stages/postgen/introspection/markers.js +29 -6
- package/lib/stages/postgen/postgen.js +237 -16
- package/lib/third-party/arborist/snapshots/calc-dep-flags.test.cjs +462 -0
- package/lib/validator/bomValidator.js +6 -16
- package/lib/validator/index.js +48 -10
- package/lib/validator/reporters/console.js +3 -0
- package/lib/validator/reporters/json.js +1 -0
- package/lib/validator/reporters/sarif.js +7 -0
- package/package.json +27 -27
- package/types/lib/audit/targets.d.ts.map +1 -1
- package/types/lib/cli/bomAssembly.d.ts +2 -1
- package/types/lib/cli/bomAssembly.d.ts.map +1 -1
- package/types/lib/cli/cliOptions.d.ts +33 -1
- package/types/lib/cli/cliOptions.d.ts.map +1 -1
- package/types/lib/cli/index.d.ts +2 -2
- package/types/lib/cli/index.d.ts.map +1 -1
- package/types/lib/cli/jsBom.d.ts +23 -0
- package/types/lib/cli/jsBom.d.ts.map +1 -1
- package/types/lib/cli/jvmBom.d.ts.map +1 -1
- package/types/lib/cli/managedBom.d.ts +33 -0
- package/types/lib/cli/managedBom.d.ts.map +1 -1
- package/types/lib/cli/nativeBom.d.ts +88 -8
- package/types/lib/cli/nativeBom.d.ts.map +1 -1
- package/types/lib/core/activity.d.ts.map +1 -1
- package/types/lib/core/env.d.ts +21 -0
- package/types/lib/core/env.d.ts.map +1 -1
- package/types/lib/core/fs.d.ts +55 -0
- package/types/lib/core/fs.d.ts.map +1 -1
- package/types/lib/core/metadataProperties.d.ts +39 -0
- package/types/lib/core/metadataProperties.d.ts.map +1 -0
- package/types/lib/core/projectConfig.d.ts +6 -2
- package/types/lib/core/projectConfig.d.ts.map +1 -1
- package/types/lib/core/userAgent.d.ts +56 -0
- package/types/lib/core/userAgent.d.ts.map +1 -0
- package/types/lib/ecosystems/bunutils.d.ts +12 -5
- package/types/lib/ecosystems/bunutils.d.ts.map +1 -1
- package/types/lib/ecosystems/cargoCache.d.ts +75 -0
- package/types/lib/ecosystems/cargoCache.d.ts.map +1 -0
- package/types/lib/ecosystems/caxa.d.ts +14 -0
- package/types/lib/ecosystems/caxa.d.ts.map +1 -1
- package/types/lib/ecosystems/cmakeResolver.d.ts +23 -0
- package/types/lib/ecosystems/cmakeResolver.d.ts.map +1 -1
- package/types/lib/ecosystems/cppEvidence.d.ts +2 -2
- package/types/lib/ecosystems/cppEvidence.d.ts.map +1 -1
- package/types/lib/ecosystems/ecosystems.d.ts +15 -1
- package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
- package/types/lib/ecosystems/golem.d.ts.map +1 -1
- package/types/lib/ecosystems/gradleutils.d.ts +40 -4
- package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
- package/types/lib/ecosystems/jsEvidence.d.ts +5 -2
- package/types/lib/ecosystems/jsEvidence.d.ts.map +1 -1
- package/types/lib/ecosystems/kosi.d.ts +86 -0
- package/types/lib/ecosystems/kosi.d.ts.map +1 -0
- package/types/lib/ecosystems/npmutils.d.ts +42 -0
- package/types/lib/ecosystems/npmutils.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-crystal.d.ts +27 -0
- package/types/lib/ecosystems/parsers-crystal.d.ts.map +1 -0
- package/types/lib/ecosystems/parsers-dotnet.d.ts +19 -0
- package/types/lib/ecosystems/parsers-dotnet.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-elm.d.ts +79 -0
- package/types/lib/ecosystems/parsers-elm.d.ts.map +1 -0
- package/types/lib/ecosystems/parsers-go.d.ts +10 -0
- package/types/lib/ecosystems/parsers-go.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-js.d.ts +10 -4
- package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-julia.d.ts +57 -0
- package/types/lib/ecosystems/parsers-julia.d.ts.map +1 -0
- package/types/lib/ecosystems/parsers-lua.d.ts +50 -0
- package/types/lib/ecosystems/parsers-lua.d.ts.map +1 -0
- package/types/lib/ecosystems/parsers-misc.d.ts +90 -3
- package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-nim.d.ts +43 -0
- package/types/lib/ecosystems/parsers-nim.d.ts.map +1 -0
- package/types/lib/ecosystems/parsers-opam.d.ts +31 -0
- package/types/lib/ecosystems/parsers-opam.d.ts.map +1 -0
- package/types/lib/ecosystems/parsers-perl.d.ts +36 -0
- package/types/lib/ecosystems/parsers-perl.d.ts.map +1 -0
- package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-r.d.ts +35 -0
- package/types/lib/ecosystems/parsers-r.d.ts.map +1 -0
- package/types/lib/ecosystems/parsers-rebar.d.ts +42 -0
- package/types/lib/ecosystems/parsers-rebar.d.ts.map +1 -0
- package/types/lib/ecosystems/parsers-rust.d.ts +199 -0
- package/types/lib/ecosystems/parsers-rust.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-spack.d.ts +33 -0
- package/types/lib/ecosystems/parsers-spack.d.ts.map +1 -0
- package/types/lib/ecosystems/parsers-stack.d.ts +35 -0
- package/types/lib/ecosystems/parsers-stack.d.ts.map +1 -0
- package/types/lib/ecosystems/parsers-terraform.d.ts +34 -0
- package/types/lib/ecosystems/parsers-terraform.d.ts.map +1 -0
- package/types/lib/ecosystems/pythonutils.d.ts +1 -1
- package/types/lib/ecosystems/rubyutils.d.ts +143 -2
- package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
- package/types/lib/ecosystems/rusi.d.ts +13 -0
- package/types/lib/ecosystems/rusi.d.ts.map +1 -1
- package/types/lib/evinser/evinser.d.ts +81 -2
- package/types/lib/evinser/evinser.d.ts.map +1 -1
- package/types/lib/evinser/swiftsem.d.ts +280 -15
- package/types/lib/evinser/swiftsem.d.ts.map +1 -1
- package/types/lib/helpers/agenticToolutils.d.ts +87 -0
- package/types/lib/helpers/agenticToolutils.d.ts.map +1 -0
- package/types/lib/helpers/bomSigner.d.ts +106 -13
- package/types/lib/helpers/bomSigner.d.ts.map +1 -1
- package/types/lib/inventory/agentFormulationParser.d.ts.map +1 -1
- package/types/lib/inventory/aiCollector.d.ts.map +1 -1
- package/types/lib/inventory/aiInventory.d.ts.map +1 -1
- package/types/lib/inventory/analyzer.d.ts.map +1 -1
- package/types/lib/inventory/atomUtils.d.ts +145 -3
- package/types/lib/inventory/atomUtils.d.ts.map +1 -1
- package/types/lib/inventory/auditCategories.d.ts +4 -0
- package/types/lib/inventory/auditCategories.d.ts.map +1 -1
- package/types/lib/inventory/cbomutils.d.ts.map +1 -1
- package/types/lib/inventory/cdxrs.d.ts +8 -3
- package/types/lib/inventory/cdxrs.d.ts.map +1 -1
- package/types/lib/inventory/communityAiConfigParser.d.ts.map +1 -1
- package/types/lib/inventory/depsUtils.d.ts +15 -0
- package/types/lib/inventory/depsUtils.d.ts.map +1 -1
- package/types/lib/inventory/display.d.ts.map +1 -1
- package/types/lib/inventory/dosai.d.ts +87 -1
- package/types/lib/inventory/dosai.d.ts.map +1 -1
- package/types/lib/inventory/dynamic.d.ts.map +1 -1
- package/types/lib/inventory/envcontext.d.ts.map +1 -1
- package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
- package/types/lib/inventory/fetchBatch.d.ts +34 -0
- package/types/lib/inventory/fetchBatch.d.ts.map +1 -1
- package/types/lib/inventory/fetchRate.d.ts +9 -6
- package/types/lib/inventory/fetchRate.d.ts.map +1 -1
- package/types/lib/inventory/forgeEnricher.d.ts.map +1 -1
- package/types/lib/inventory/jvmToolEnv.d.ts +5 -4
- package/types/lib/inventory/jvmToolEnv.d.ts.map +1 -1
- package/types/lib/inventory/mcpConfigParser.d.ts.map +1 -1
- package/types/lib/inventory/mcpDiscovery.d.ts.map +1 -1
- package/types/lib/inventory/mcpServerManifest.d.ts +19 -0
- package/types/lib/inventory/mcpServerManifest.d.ts.map +1 -0
- package/types/lib/inventory/mcpbBundle.d.ts +19 -0
- package/types/lib/inventory/mcpbBundle.d.ts.map +1 -0
- package/types/lib/inventory/plugins.d.ts.map +1 -1
- package/types/lib/inventory/protobom.d.ts +18 -13
- package/types/lib/inventory/protobom.d.ts.map +1 -1
- package/types/lib/inventory/protobomLoader.d.ts.map +1 -1
- package/types/lib/inventory/purl.d.ts +71 -2
- package/types/lib/inventory/purl.d.ts.map +1 -1
- package/types/lib/inventory/sbomDocument.d.ts.map +1 -1
- package/types/lib/inventory/source.d.ts.map +1 -1
- package/types/lib/inventory/stylePackageRefs.d.ts +17 -0
- package/types/lib/inventory/stylePackageRefs.d.ts.map +1 -0
- package/types/lib/inventory/svelteUtils.d.ts +124 -0
- package/types/lib/inventory/svelteUtils.d.ts.map +1 -0
- package/types/lib/inventory/traceRunner.d.ts +47 -1
- package/types/lib/inventory/traceRunner.d.ts.map +1 -1
- package/types/lib/managers/binary.d.ts +30 -1
- package/types/lib/managers/binary.d.ts.map +1 -1
- package/types/lib/managers/docker.d.ts +29 -0
- package/types/lib/managers/docker.d.ts.map +1 -1
- package/types/lib/managers/oci.d.ts +14 -0
- package/types/lib/managers/oci.d.ts.map +1 -1
- package/types/lib/parsers/cmakeLists.d.ts +76 -0
- package/types/lib/parsers/cmakeLists.d.ts.map +1 -0
- package/types/lib/parsers/huggingfaceUtils.d.ts +8 -1
- package/types/lib/parsers/huggingfaceUtils.d.ts.map +1 -1
- package/types/lib/parsers/largeJson.d.ts +52 -0
- package/types/lib/parsers/largeJson.d.ts.map +1 -0
- package/types/lib/parsers/npmrc.d.ts +5 -0
- package/types/lib/parsers/npmrc.d.ts.map +1 -1
- package/types/lib/parsers/onnx.d.ts +28 -0
- package/types/lib/parsers/onnx.d.ts.map +1 -0
- package/types/lib/parsers/safetensors.d.ts +22 -0
- package/types/lib/parsers/safetensors.d.ts.map +1 -0
- package/types/lib/stages/postgen/introspection/markers.d.ts +3 -2
- package/types/lib/stages/postgen/introspection/markers.d.ts.map +1 -1
- package/types/lib/stages/postgen/postgen.d.ts +33 -0
- package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
- package/types/lib/validator/bomValidator.d.ts.map +1 -1
- package/types/lib/validator/index.d.ts +10 -1
- package/types/lib/validator/index.d.ts.map +1 -1
- package/types/lib/validator/reporters/console.d.ts.map +1 -1
- package/types/lib/validator/reporters/json.d.ts.map +1 -1
- 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 {
|
|
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: "
|
|
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
|
-
|
|
600
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
1046
|
-
let
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
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
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
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
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
);
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
if (
|
|
1117
|
-
|
|
1118
|
-
|
|
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 (!
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
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
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
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
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
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
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
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] =
|
|
1822
|
+
gemMetadata[key] =
|
|
1823
|
+
value !== undefined ? { value } : { refVar, refField };
|
|
1227
1824
|
}
|
|
1228
|
-
if (
|
|
1229
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1248
|
-
|
|
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 (
|
|
1253
|
-
pkg.externalReferences.
|
|
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 (
|
|
1280
|
-
|
|
1281
|
-
|
|
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 (
|
|
1285
|
-
pkg.licenses =
|
|
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
|
}
|