@trustify-da/trustify-da-javascript-client 0.3.0-ea.e12bc82 → 0.3.0-ea.e258fba

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 (97) hide show
  1. package/README.md +367 -13
  2. package/dist/package.json +48 -28
  3. package/dist/src/analysis.d.ts +28 -5
  4. package/dist/src/analysis.js +99 -89
  5. package/dist/src/batch_opts.d.ts +24 -0
  6. package/dist/src/batch_opts.js +35 -0
  7. package/dist/src/cli.js +346 -9
  8. package/dist/src/cyclone_dx_sbom.d.ts +30 -3
  9. package/dist/src/cyclone_dx_sbom.js +95 -9
  10. package/dist/src/index.d.ts +202 -12
  11. package/dist/src/index.js +365 -30
  12. package/dist/src/license/index.d.ts +28 -0
  13. package/dist/src/license/index.js +103 -0
  14. package/dist/src/license/license_utils.d.ts +40 -0
  15. package/dist/src/license/license_utils.js +137 -0
  16. package/dist/src/license/licenses_api.d.ts +34 -0
  17. package/dist/src/license/licenses_api.js +98 -0
  18. package/dist/src/license/project_license.d.ts +20 -0
  19. package/dist/src/license/project_license.js +62 -0
  20. package/dist/src/oci_image/images.d.ts +4 -5
  21. package/dist/src/oci_image/images.js +15 -1
  22. package/dist/src/oci_image/utils.d.ts +7 -7
  23. package/dist/src/oci_image/utils.js +11 -2
  24. package/dist/src/package_version.d.ts +8 -0
  25. package/dist/src/package_version.js +31 -0
  26. package/dist/src/provider.d.ts +18 -5
  27. package/dist/src/provider.js +33 -5
  28. package/dist/src/providers/base_java.d.ts +68 -15
  29. package/dist/src/providers/base_java.js +82 -61
  30. package/dist/src/providers/base_javascript.d.ts +99 -12
  31. package/dist/src/providers/base_javascript.js +243 -27
  32. package/dist/src/providers/base_pyproject.d.ts +158 -0
  33. package/dist/src/providers/base_pyproject.js +322 -0
  34. package/dist/src/providers/containerfile_parser.d.ts +5 -0
  35. package/dist/src/providers/containerfile_parser.js +20 -0
  36. package/dist/src/providers/golang_gomodules.d.ts +30 -13
  37. package/dist/src/providers/golang_gomodules.js +219 -122
  38. package/dist/src/providers/gomod_parser.d.ts +4 -0
  39. package/dist/src/providers/gomod_parser.js +16 -0
  40. package/dist/src/providers/java_gradle.d.ts +76 -3
  41. package/dist/src/providers/java_gradle.js +323 -21
  42. package/dist/src/providers/java_gradle_groovy.d.ts +1 -1
  43. package/dist/src/providers/java_gradle_kotlin.d.ts +1 -1
  44. package/dist/src/providers/java_maven.d.ts +45 -10
  45. package/dist/src/providers/java_maven.js +300 -14
  46. package/dist/src/providers/javascript_bun.d.ts +22 -0
  47. package/dist/src/providers/javascript_bun.js +141 -0
  48. package/dist/src/providers/javascript_npm.d.ts +19 -0
  49. package/dist/src/providers/javascript_npm.js +61 -1
  50. package/dist/src/providers/javascript_pnpm.d.ts +14 -1
  51. package/dist/src/providers/javascript_pnpm.js +55 -5
  52. package/dist/src/providers/javascript_yarn.d.ts +12 -0
  53. package/dist/src/providers/javascript_yarn.js +75 -2
  54. package/dist/src/providers/manifest.d.ts +2 -0
  55. package/dist/src/providers/manifest.js +28 -7
  56. package/dist/src/providers/marker_evaluator.d.ts +14 -0
  57. package/dist/src/providers/marker_evaluator.js +191 -0
  58. package/dist/src/providers/oci_dockerfile.d.ts +51 -0
  59. package/dist/src/providers/oci_dockerfile.js +177 -0
  60. package/dist/src/providers/processors/yarn_berry_processor.d.ts +6 -2
  61. package/dist/src/providers/processors/yarn_berry_processor.js +91 -7
  62. package/dist/src/providers/processors/yarn_classic_processor.d.ts +6 -2
  63. package/dist/src/providers/processors/yarn_classic_processor.js +8 -6
  64. package/dist/src/providers/python_controller.d.ts +10 -3
  65. package/dist/src/providers/python_controller.js +122 -63
  66. package/dist/src/providers/python_pip.d.ts +16 -4
  67. package/dist/src/providers/python_pip.js +51 -58
  68. package/dist/src/providers/python_pip_pyproject.d.ts +61 -0
  69. package/dist/src/providers/python_pip_pyproject.js +146 -0
  70. package/dist/src/providers/python_poetry.d.ts +98 -0
  71. package/dist/src/providers/python_poetry.js +286 -0
  72. package/dist/src/providers/python_uv.d.ts +46 -0
  73. package/dist/src/providers/python_uv.js +269 -0
  74. package/dist/src/providers/requirements_parser.d.ts +6 -0
  75. package/dist/src/providers/requirements_parser.js +24 -0
  76. package/dist/src/providers/rust_cargo.d.ts +56 -0
  77. package/dist/src/providers/rust_cargo.js +682 -0
  78. package/dist/src/providers/tree-sitter-containerfile.wasm +0 -0
  79. package/dist/src/providers/tree-sitter-gomod.wasm +0 -0
  80. package/dist/src/providers/tree-sitter-requirements.wasm +0 -0
  81. package/dist/src/remediate.d.ts +21 -0
  82. package/dist/src/remediate.js +149 -0
  83. package/dist/src/remediation.d.ts +57 -0
  84. package/dist/src/remediation.js +436 -0
  85. package/dist/src/remediation_report.d.ts +40 -0
  86. package/dist/src/remediation_report.js +159 -0
  87. package/dist/src/sbom.d.ts +28 -2
  88. package/dist/src/sbom.js +26 -4
  89. package/dist/src/tools.d.ts +48 -6
  90. package/dist/src/tools.js +138 -2
  91. package/dist/src/updaters/maven_updater.d.ts +26 -0
  92. package/dist/src/updaters/maven_updater.js +322 -0
  93. package/dist/src/updaters/toml_updater.d.ts +33 -0
  94. package/dist/src/updaters/toml_updater.js +244 -0
  95. package/dist/src/workspace.d.ts +70 -0
  96. package/dist/src/workspace.js +256 -0
  97. package/package.json +49 -29
@@ -0,0 +1,682 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { PackageURL } from 'packageurl-js';
4
+ import { parse as parseToml } from 'smol-toml';
5
+ import { getLicense } from '../license/license_utils.js';
6
+ import Sbom from '../sbom.js';
7
+ import { getCustom, getCustomPath, invokeCommand } from '../tools.js';
8
+ export default { isSupported, validateLockFile, provideComponent, provideStack, readLicenseFromManifest, packageManagerName() { return 'cargo'; } };
9
+ /** @typedef {import('../provider').Provider} */
10
+ /** @typedef {import('../provider').Provided} Provided */
11
+ /**
12
+ * @type {string} ecosystem identifier for cargo/crates packages
13
+ * @private
14
+ */
15
+ const ecosystem = 'cargo';
16
+ /**
17
+ * Ignore markers recognised in Cargo.toml comments.
18
+ * Supports both the legacy `exhortignore` marker and the new `trustify-da-ignore` marker.
19
+ * @type {string[]}
20
+ * @private
21
+ */
22
+ const IGNORE_MARKERS = ['exhortignore', 'trustify-da-ignore'];
23
+ /**
24
+ * Checks whether a line contains any of the recognised ignore markers.
25
+ * @param {string} line - the line to check
26
+ * @returns {boolean} true if the line contains at least one ignore marker
27
+ * @private
28
+ */
29
+ function hasIgnoreMarker(line) {
30
+ return IGNORE_MARKERS.some(marker => line.includes(marker));
31
+ }
32
+ /**
33
+ * Checks whether a dependency name is in the ignored set, accounting for
34
+ * Cargo's underscore/hyphen equivalence
35
+ * @param {string} name - the dependency name to check
36
+ * @param {Set<string>} ignoredDeps - set of ignored dependency names
37
+ * @returns {boolean} true if the dependency should be ignored
38
+ * @private
39
+ */
40
+ function isDepIgnored(name, ignoredDeps) {
41
+ if (ignoredDeps.has(name)) {
42
+ return true;
43
+ }
44
+ let normalized = name.replace(/_/g, '-');
45
+ if (normalized !== name && ignoredDeps.has(normalized)) {
46
+ return true;
47
+ }
48
+ normalized = name.replace(/-/g, '_');
49
+ if (normalized !== name && ignoredDeps.has(normalized)) {
50
+ return true;
51
+ }
52
+ return false;
53
+ }
54
+ /**
55
+ * Enum-like constants for Cargo project types.
56
+ * @private
57
+ */
58
+ const CrateType = {
59
+ SINGLE_CRATE: 'SINGLE_CRATE',
60
+ WORKSPACE_VIRTUAL: 'WORKSPACE_VIRTUAL',
61
+ WORKSPACE_WITH_ROOT_CRATE: 'WORKSPACE_WITH_ROOT_CRATE'
62
+ };
63
+ /**
64
+ * @param {string} manifestName - the subject manifest name-type
65
+ * @returns {boolean} - return true if `Cargo.toml` is the manifest name-type
66
+ */
67
+ function isSupported(manifestName) {
68
+ return 'Cargo.toml' === manifestName;
69
+ }
70
+ /**
71
+ * Read project license from Cargo.toml, with fallback to LICENSE file.
72
+ * Supports the `license` field under `[package]` (single crate / workspace
73
+ * with root) and under `[workspace.package]` (virtual workspaces).
74
+ * When a member crate uses `license = { workspace = true }`, the actual
75
+ * license is resolved from the workspace root Cargo.toml.
76
+ * @param {string} manifestPath - path to Cargo.toml
77
+ * @param {object} [metadata] - cargo metadata output (needed for workspace license resolution)
78
+ * @returns {string|null} SPDX identifier or null
79
+ */
80
+ function readLicenseFromManifest(manifestPath, metadata) {
81
+ let fromManifest = null;
82
+ try {
83
+ let content = fs.readFileSync(manifestPath, 'utf-8');
84
+ let parsed = parseToml(content);
85
+ let packageLicense = parsed.package?.license;
86
+ if (typeof packageLicense === 'object' && packageLicense?.workspace === true) {
87
+ packageLicense = resolveWorkspaceLicense(parsed, metadata);
88
+ }
89
+ fromManifest = packageLicense
90
+ || parsed.workspace?.package?.license
91
+ || null;
92
+ }
93
+ catch (_) {
94
+ // leave fromManifest as null
95
+ }
96
+ return getLicense(fromManifest, manifestPath);
97
+ }
98
+ /**
99
+ * Resolve a workspace-inherited license from the workspace root Cargo.toml.
100
+ * @param {object} parsed - the parsed member crate Cargo.toml
101
+ * @param {object} [metadata] - cargo metadata output with workspace_root
102
+ * @returns {string|null} resolved license string or null
103
+ */
104
+ function resolveWorkspaceLicense(parsed, metadata) {
105
+ if (metadata?.workspace_root) {
106
+ let cargoTomlPath = path.join(metadata.workspace_root, 'Cargo.toml');
107
+ try {
108
+ let content = fs.readFileSync(cargoTomlPath, 'utf-8');
109
+ let workspaceParsed = parseToml(content);
110
+ return workspaceParsed.workspace?.package?.license || null;
111
+ }
112
+ catch (_) {
113
+ // fall through
114
+ }
115
+ }
116
+ return parsed.workspace?.package?.license || null;
117
+ }
118
+ /**
119
+ * Finds the directory containing Cargo.lock, honoring TRUSTIFY_DA_WORKSPACE_DIR.
120
+ * Walks up from manifestDir looking for Cargo.lock, stopping at workspace root or filesystem root.
121
+ * @param {string} manifestDir - the directory where the manifest lies
122
+ * @param {{TRUSTIFY_DA_WORKSPACE_DIR?: string}} [opts={}] - optional workspace root
123
+ * @returns {string|null} the directory path containing Cargo.lock, or null if not found
124
+ * @private
125
+ */
126
+ function _findCargoLockDir(manifestDir, opts = {}) {
127
+ const workspaceDir = getCustom('TRUSTIFY_DA_WORKSPACE_DIR', null, opts);
128
+ if (workspaceDir) {
129
+ const dir = path.resolve(workspaceDir);
130
+ return fs.existsSync(path.join(dir, 'Cargo.lock')) ? dir : null;
131
+ }
132
+ let dir = path.resolve(manifestDir);
133
+ let parent = dir;
134
+ do {
135
+ dir = parent;
136
+ if (fs.existsSync(path.join(dir, 'Cargo.lock'))) {
137
+ return dir;
138
+ }
139
+ // If this directory has a Cargo.toml with [workspace], the lock file
140
+ // should have been here — stop searching.
141
+ let cargoToml = path.join(dir, 'Cargo.toml');
142
+ if (fs.existsSync(cargoToml)) {
143
+ try {
144
+ let content = fs.readFileSync(cargoToml, 'utf-8');
145
+ if (/\[workspace\]/.test(content)) {
146
+ return null;
147
+ }
148
+ }
149
+ catch (_) {
150
+ // ignore read errors, keep searching
151
+ }
152
+ }
153
+ parent = path.dirname(dir);
154
+ } while (parent !== dir);
155
+ return null;
156
+ }
157
+ /**
158
+ * Validates that Cargo.lock exists in the manifest directory or in a parent
159
+ * workspace root directory. In Cargo workspaces the lock file always lives at
160
+ * the workspace root, so when a member crate's Cargo.toml is provided we walk
161
+ * up the directory tree looking for Cargo.lock (stopping when we find a
162
+ * Cargo.toml that contains a [workspace] section, or when we reach the
163
+ * filesystem root).
164
+ * When TRUSTIFY_DA_WORKSPACE_DIR is provided (via env var or opts),
165
+ * checks only that directory for Cargo.lock — no walk-up.
166
+ * @param {string} manifestDir - the directory where the manifest lies
167
+ * @param {{TRUSTIFY_DA_WORKSPACE_DIR?: string}} [opts={}] - optional workspace root
168
+ * @returns {boolean} true if Cargo.lock is found
169
+ */
170
+ function validateLockFile(manifestDir, opts = {}) {
171
+ return _findCargoLockDir(manifestDir, opts) !== null;
172
+ }
173
+ /**
174
+ * Provide content and content type for Cargo stack analysis.
175
+ * @param {string} manifest - the manifest path
176
+ * @param {{}} [opts={}] - optional various options to pass along the application
177
+ * @returns {Provided}
178
+ */
179
+ function provideStack(manifest, opts = {}) {
180
+ return {
181
+ ecosystem,
182
+ content: getSBOM(manifest, opts, true),
183
+ contentType: 'application/vnd.cyclonedx+json'
184
+ };
185
+ }
186
+ /**
187
+ * Provide content and content type for Cargo component analysis.
188
+ * @param {string} manifest - path to Cargo.toml for component report
189
+ * @param {{}} [opts={}] - optional various options to pass along the application
190
+ * @returns {Provided}
191
+ */
192
+ function provideComponent(manifest, opts = {}) {
193
+ return {
194
+ ecosystem,
195
+ content: getSBOM(manifest, opts, false),
196
+ contentType: 'application/vnd.cyclonedx+json'
197
+ };
198
+ }
199
+ /**
200
+ * Create SBOM json string for a Cargo project.
201
+ * @param {string} manifest - path to Cargo.toml
202
+ * @param {{}} [opts={}] - optional various options to pass along the application
203
+ * @param {boolean} includeTransitive - whether the sbom should contain transitive dependencies
204
+ * @returns {string} the SBOM json content
205
+ * @private
206
+ */
207
+ function getSBOM(manifest, opts = {}, includeTransitive) {
208
+ let cargoBin = getCustomPath('cargo', opts);
209
+ verifyCargoAccessible(cargoBin);
210
+ let manifestDir = path.dirname(manifest);
211
+ let metadata = executeCargoMetadata(cargoBin, manifestDir);
212
+ let ignoredDeps = getIgnoredDeps(manifest, metadata);
213
+ let crateType = detectCrateType(metadata);
214
+ let license = readLicenseFromManifest(manifest, metadata);
215
+ let hashMap = parseCargoLockHashes(manifestDir, opts);
216
+ let sbom;
217
+ if (crateType === CrateType.WORKSPACE_VIRTUAL) {
218
+ sbom = handleVirtualWorkspace(manifest, metadata, ignoredDeps, includeTransitive, opts, license, hashMap);
219
+ }
220
+ else {
221
+ sbom = handleSingleCrate(metadata, ignoredDeps, includeTransitive, opts, license, hashMap);
222
+ }
223
+ return sbom;
224
+ }
225
+ /**
226
+ * Verifies that the cargo binary is accessible.
227
+ * @param {string} cargoBin - path to cargo binary
228
+ * @throws {Error} if cargo is not accessible
229
+ * @private
230
+ */
231
+ function verifyCargoAccessible(cargoBin) {
232
+ try {
233
+ invokeCommand(cargoBin, ['--version']);
234
+ }
235
+ catch (error) {
236
+ if (error.code === 'ENOENT') {
237
+ throw new Error(`cargo binary is not accessible at "${cargoBin}"`);
238
+ }
239
+ throw new Error('failed to check for cargo binary', { cause: error });
240
+ }
241
+ }
242
+ /**
243
+ * Executes `cargo metadata` and returns the parsed JSON.
244
+ * @param {string} cargoBin - path to cargo binary
245
+ * @param {string} manifestDir - directory containing Cargo.toml
246
+ * @returns {object} parsed cargo metadata JSON
247
+ * @throws {Error} if cargo metadata fails
248
+ * @private
249
+ */
250
+ function executeCargoMetadata(cargoBin, manifestDir) {
251
+ try {
252
+ let output = invokeCommand(cargoBin, ['metadata', '--format-version', '1'], { cwd: manifestDir });
253
+ return JSON.parse(output.toString().trim());
254
+ }
255
+ catch (error) {
256
+ throw new Error('failed to execute cargo metadata', { cause: error });
257
+ }
258
+ }
259
+ /**
260
+ * Parses Cargo.lock to extract SHA-256 checksums for each package.
261
+ * @param {string} manifestDir - directory containing Cargo.toml (Cargo.lock is searched upward)
262
+ * @param {{TRUSTIFY_DA_WORKSPACE_DIR?: string}} [opts={}] - optional workspace root
263
+ * @returns {Map<string, Array<{alg: string, content: string}>>} map of "name@version" to CycloneDX hashes
264
+ * @private
265
+ */
266
+ function parseCargoLockHashes(manifestDir, opts = {}) {
267
+ let hashMap = new Map();
268
+ let lockDir = _findCargoLockDir(manifestDir, opts);
269
+ if (!lockDir) {
270
+ return hashMap;
271
+ }
272
+ let lockPath = path.join(lockDir, 'Cargo.lock');
273
+ try {
274
+ let content = fs.readFileSync(lockPath, 'utf-8');
275
+ let parsed = parseToml(content);
276
+ for (let pkg of (parsed.package || [])) {
277
+ if (pkg.name && pkg.version && pkg.checksum) {
278
+ hashMap.set(`${pkg.name}@${pkg.version}`, [{ alg: 'SHA-256', content: pkg.checksum }]);
279
+ }
280
+ }
281
+ }
282
+ catch (error) {
283
+ console.warn('Failed to parse Cargo.lock for hashes, SBOM will be generated without hashes');
284
+ }
285
+ return hashMap;
286
+ }
287
+ /**
288
+ * Detects the type of Cargo project from metadata.
289
+ * @param {object} metadata - parsed cargo metadata
290
+ * @returns {string} one of CrateType values
291
+ * @private
292
+ */
293
+ function detectCrateType(metadata) {
294
+ let rootPackageId = metadata.resolve?.root;
295
+ let workspaceMembers = metadata.workspace_members || [];
296
+ if (!rootPackageId && workspaceMembers.length > 0) {
297
+ return CrateType.WORKSPACE_VIRTUAL;
298
+ }
299
+ if (rootPackageId && workspaceMembers.length > 1) {
300
+ return CrateType.WORKSPACE_WITH_ROOT_CRATE;
301
+ }
302
+ return CrateType.SINGLE_CRATE;
303
+ }
304
+ /**
305
+ * Handles SBOM generation for single crate and workspace-with-root-crate projects.
306
+ * For workspace-with-root-crate, workspace members are only included if they
307
+ * appear in the root crate's dependency graph from cargo metadata. We don't
308
+ * automatically add all members as dependencies since most workspace members
309
+ * (examples, tools, benchmarks) depend ON the root crate, not the other way around.
310
+ * @param {object} metadata - parsed cargo metadata
311
+ * @param {Set<string>} ignoredDeps - set of ignored dependency names
312
+ * @param {boolean} includeTransitive - whether to include transitive dependencies
313
+ * @param {{}} opts - options
314
+ * @param {string|null} license - SPDX license identifier for the root component
315
+ * @returns {string} SBOM json string
316
+ * @private
317
+ */
318
+ function handleSingleCrate(metadata, ignoredDeps, includeTransitive, opts, license, hashMap) {
319
+ let rootPackageId = metadata.resolve.root;
320
+ let rootPackage = findPackageById(metadata, rootPackageId);
321
+ let rootPurl = toPurl(rootPackage.name, rootPackage.version);
322
+ let sbom = new Sbom();
323
+ sbom.addRoot(rootPurl, license);
324
+ let resolveNode = findResolveNode(metadata, rootPackageId);
325
+ if (!resolveNode) {
326
+ return sbom.getAsJsonString(opts);
327
+ }
328
+ if (includeTransitive) {
329
+ addTransitiveDeps(sbom, metadata, rootPackageId, ignoredDeps, new Set(), hashMap, rootPurl);
330
+ }
331
+ else {
332
+ addDirectDeps(sbom, metadata, rootPackageId, rootPurl, ignoredDeps, hashMap);
333
+ }
334
+ return sbom.getAsJsonString(opts);
335
+ }
336
+ /**
337
+ * Handles SBOM generation for virtual workspace projects.
338
+ *
339
+ * For stack analysis (includeTransitive=true):
340
+ * Iterates all workspace members and walks their full dependency trees.
341
+ *
342
+ * For component analysis (includeTransitive=false):
343
+ * Only includes dependencies explicitly listed in [workspace.dependencies]
344
+ * of the root Cargo.toml. If that section is absent the SBOM contains only
345
+ * the synthetic workspace root with no dependencies — this matches the Java
346
+ * client behaviour where CA "just analyzes direct dependencies defined in
347
+ * Cargo.toml".
348
+ *
349
+ * @param {string} manifest - path to the root Cargo.toml
350
+ * @param {object} metadata - parsed cargo metadata
351
+ * @param {Set<string>} ignoredDeps - set of ignored dependency names
352
+ * @param {boolean} includeTransitive - whether to include transitive dependencies
353
+ * @param {{}} opts - options
354
+ * @param {string|null} license - SPDX license identifier for the root component
355
+ * @returns {string} SBOM json string
356
+ * @private
357
+ */
358
+ function handleVirtualWorkspace(manifest, metadata, ignoredDeps, includeTransitive, opts, license, hashMap) {
359
+ let workspaceRoot = metadata.workspace_root;
360
+ let rootName = path.basename(workspaceRoot);
361
+ let workspaceVersion = getWorkspaceVersion(metadata);
362
+ let rootPurl = toPurl(rootName, workspaceVersion);
363
+ let sbom = new Sbom();
364
+ sbom.addRoot(rootPurl, license);
365
+ if (includeTransitive) {
366
+ let workspaceMembers = metadata.workspace_members || [];
367
+ for (let memberId of workspaceMembers) {
368
+ let memberPackage = findPackageById(metadata, memberId);
369
+ if (!memberPackage) {
370
+ continue;
371
+ }
372
+ let memberPurl = memberPackage.source == null
373
+ ? toPathDepPurl(memberPackage.name, memberPackage.version)
374
+ : toPurl(memberPackage.name, memberPackage.version);
375
+ sbom.addDependency(rootPurl, memberPurl);
376
+ addTransitiveDeps(sbom, metadata, memberId, ignoredDeps, new Set(), hashMap, memberPurl);
377
+ }
378
+ }
379
+ else {
380
+ let workspaceDeps = getWorkspaceDepsFromManifest(manifest);
381
+ for (let depName of workspaceDeps) {
382
+ if (isDepIgnored(depName, ignoredDeps)) {
383
+ continue;
384
+ }
385
+ let pkg = metadata.packages.find(p => p.name === depName);
386
+ if (!pkg) {
387
+ let altName = depName.replace(/-/g, '_');
388
+ pkg = metadata.packages.find(p => p.name === altName);
389
+ }
390
+ if (!pkg) {
391
+ continue;
392
+ }
393
+ let depPurl = pkg.source == null
394
+ ? toPathDepPurl(pkg.name, pkg.version)
395
+ : toPurl(pkg.name, pkg.version);
396
+ let hashes = hashMap.get(`${pkg.name}@${pkg.version}`);
397
+ sbom.addDependency(rootPurl, depPurl, undefined, hashes);
398
+ }
399
+ }
400
+ return sbom.getAsJsonString(opts);
401
+ }
402
+ /**
403
+ * Recursively adds transitive dependencies to the SBOM.
404
+ * Path dependencies (source == null) are included with a
405
+ * {@code repository_url=local} qualifier so the backend can skip
406
+ * vulnerability checks while still showing them in the dependency tree.
407
+ * @param {Sbom} sbom - the SBOM to add dependencies to
408
+ * @param {object} metadata - parsed cargo metadata
409
+ * @param {string} packageId - the package ID to resolve dependencies for
410
+ * @param {Set<string>} ignoredDeps - set of ignored dependency names
411
+ * @param {Set<string>} visited - set of already-visited package IDs to prevent cycles
412
+ * @param {PackageURL} [startingPurl] - purl to use for the starting package,
413
+ * so callers can ensure it matches the purl already added to the SBOM
414
+ * @private
415
+ */
416
+ function addTransitiveDeps(sbom, metadata, packageId, ignoredDeps, visited, hashMap, startingPurl) {
417
+ if (visited.has(packageId)) {
418
+ return;
419
+ }
420
+ visited.add(packageId);
421
+ let resolveNode = findResolveNode(metadata, packageId);
422
+ if (!resolveNode) {
423
+ return;
424
+ }
425
+ let sourcePackage = findPackageById(metadata, packageId);
426
+ if (!sourcePackage) {
427
+ return;
428
+ }
429
+ let sourcePurl = startingPurl || (sourcePackage.source == null
430
+ ? toPathDepPurl(sourcePackage.name, sourcePackage.version)
431
+ : toPurl(sourcePackage.name, sourcePackage.version));
432
+ let runtimeDeps = filterRuntimeDeps(resolveNode);
433
+ for (let depId of runtimeDeps) {
434
+ let depPackage = findPackageById(metadata, depId);
435
+ if (!depPackage) {
436
+ continue;
437
+ }
438
+ if (isDepIgnored(depPackage.name, ignoredDeps)) {
439
+ continue;
440
+ }
441
+ let depPurl = depPackage.source == null
442
+ ? toPathDepPurl(depPackage.name, depPackage.version)
443
+ : toPurl(depPackage.name, depPackage.version);
444
+ let hashes = hashMap ? hashMap.get(`${depPackage.name}@${depPackage.version}`) : undefined;
445
+ sbom.addDependency(sourcePurl, depPurl, undefined, hashes);
446
+ addTransitiveDeps(sbom, metadata, depId, ignoredDeps, visited, hashMap);
447
+ }
448
+ }
449
+ /**
450
+ * Adds only direct (non-transitive) dependencies to the SBOM.
451
+ * Path dependencies are included with a {@code repository_url=local} qualifier.
452
+ * @param {Sbom} sbom - the SBOM to add dependencies to
453
+ * @param {object} metadata - parsed cargo metadata
454
+ * @param {string} packageId - the package ID to resolve dependencies for
455
+ * @param {PackageURL} parentPurl - the parent purl to attach dependencies to
456
+ * @param {Set<string>} ignoredDeps - set of ignored dependency names
457
+ * @private
458
+ */
459
+ function addDirectDeps(sbom, metadata, packageId, parentPurl, ignoredDeps, hashMap) {
460
+ let resolveNode = findResolveNode(metadata, packageId);
461
+ if (!resolveNode) {
462
+ return;
463
+ }
464
+ let runtimeDeps = filterRuntimeDeps(resolveNode);
465
+ for (let depId of runtimeDeps) {
466
+ let depPackage = findPackageById(metadata, depId);
467
+ if (!depPackage) {
468
+ continue;
469
+ }
470
+ if (isDepIgnored(depPackage.name, ignoredDeps)) {
471
+ continue;
472
+ }
473
+ let depPurl = depPackage.source == null
474
+ ? toPathDepPurl(depPackage.name, depPackage.version)
475
+ : toPurl(depPackage.name, depPackage.version);
476
+ let hashes = hashMap ? hashMap.get(`${depPackage.name}@${depPackage.version}`) : undefined;
477
+ sbom.addDependency(parentPurl, depPurl, undefined, hashes);
478
+ }
479
+ }
480
+ /**
481
+ * Filters the deps of a resolve node to only include runtime (normal) dependencies.
482
+ * @param {object} resolveNode - a node from metadata.resolve.nodes
483
+ * @returns {string[]} array of package IDs for runtime dependencies
484
+ * @private
485
+ */
486
+ function filterRuntimeDeps(resolveNode) {
487
+ if (!resolveNode.deps) {
488
+ return [];
489
+ }
490
+ return resolveNode.deps
491
+ .filter(dep => {
492
+ if (!dep.dep_kinds || dep.dep_kinds.length === 0) {
493
+ return true;
494
+ }
495
+ return dep.dep_kinds.some(dk => dk.kind == null || dk.kind === 'normal');
496
+ })
497
+ .map(dep => dep.pkg);
498
+ }
499
+ /**
500
+ * Finds a package in the metadata by its ID.
501
+ * @param {object} metadata - parsed cargo metadata
502
+ * @param {string} packageId - the package ID to find
503
+ * @returns {object|undefined} the found package or undefined
504
+ * @private
505
+ */
506
+ function findPackageById(metadata, packageId) {
507
+ return metadata.packages.find(pkg => pkg.id === packageId);
508
+ }
509
+ /**
510
+ * Finds a resolve node by package ID.
511
+ * @param {object} metadata - parsed cargo metadata
512
+ * @param {string} packageId - the package ID to find
513
+ * @returns {object|undefined} the found resolve node or undefined
514
+ * @private
515
+ */
516
+ function findResolveNode(metadata, packageId) {
517
+ return metadata.resolve.nodes.find(node => node.id === packageId);
518
+ }
519
+ /**
520
+ * Parses the root Cargo.toml and returns the dependency names listed in the
521
+ * [workspace.dependencies] section. Returns an empty array when the section
522
+ * does not exist.
523
+ * @param {string} manifest - path to the root Cargo.toml
524
+ * @returns {string[]} list of dependency names
525
+ * @private
526
+ */
527
+ function getWorkspaceDepsFromManifest(manifest) {
528
+ let content = fs.readFileSync(manifest, 'utf-8');
529
+ let parsed = parseToml(content);
530
+ return Object.keys(parsed.workspace?.dependencies || {});
531
+ }
532
+ /**
533
+ * Extracts the workspace version from metadata if defined.
534
+ * @param {object} metadata - parsed cargo metadata
535
+ * @returns {string} the workspace version or a placeholder
536
+ * @private
537
+ */
538
+ function getWorkspaceVersion(metadata) {
539
+ let workspaceRoot = metadata.workspace_root;
540
+ let cargoTomlPath = path.join(workspaceRoot, 'Cargo.toml');
541
+ try {
542
+ let content = fs.readFileSync(cargoTomlPath, 'utf-8');
543
+ let parsed = parseToml(content);
544
+ return parsed.workspace?.package?.version || '0.0.0';
545
+ }
546
+ catch (_) {
547
+ return '0.0.0';
548
+ }
549
+ }
550
+ /**
551
+ * Parses Cargo.toml for dependencies annotated with an ignore marker.
552
+ * Supports both `exhortignore` and `trustify-da-ignore` markers.
553
+ * Supports inline deps, table-based deps, and workspace-level dependency sections.
554
+ * Uses cargo metadata to discover workspace members (which already handles glob
555
+ * expansion and exclude filtering) instead of parsing workspace member paths ourselves.
556
+ * @param {string} manifest - path to Cargo.toml
557
+ * @param {object} metadata - parsed cargo metadata
558
+ * @returns {Set<string>} set of dependency names to ignore
559
+ * @private
560
+ */
561
+ function getIgnoredDeps(manifest, metadata) {
562
+ let ignored = new Set();
563
+ scanManifestForIgnored(manifest, ignored);
564
+ // Scan workspace member manifests using metadata
565
+ let manifestDir = path.dirname(manifest);
566
+ let workspaceRoot = metadata.workspace_root;
567
+ let workspaceMembers = metadata.workspace_members || [];
568
+ for (let memberId of workspaceMembers) {
569
+ let memberRelDir = getMemberRelativeDir(memberId, workspaceRoot);
570
+ if (memberRelDir == null) {
571
+ continue;
572
+ }
573
+ let memberManifest = path.join(manifestDir, memberRelDir, 'Cargo.toml');
574
+ if (path.resolve(memberManifest) === path.resolve(manifest)) {
575
+ continue;
576
+ }
577
+ if (fs.existsSync(memberManifest)) {
578
+ scanManifestForIgnored(memberManifest, ignored);
579
+ }
580
+ }
581
+ return ignored;
582
+ }
583
+ /**
584
+ * Extracts a workspace member's directory path relative to the workspace root
585
+ * from its cargo package ID. Workspace member IDs use the `path+file://` scheme,
586
+ * e.g. `path+file:///workspace/root/crates/member#0.1.0`.
587
+ * @param {string} packageId - the cargo package ID
588
+ * @param {string} workspaceRoot - the workspace root path from cargo metadata
589
+ * @returns {string|null} relative directory path, or null if it cannot be determined
590
+ * @private
591
+ */
592
+ function getMemberRelativeDir(packageId, workspaceRoot) {
593
+ let pathMatch = packageId.match(/path\+file:\/\/(.+?)#/);
594
+ if (!pathMatch) {
595
+ return null;
596
+ }
597
+ let pkgPath = pathMatch[1];
598
+ if (!pkgPath.startsWith(workspaceRoot)) {
599
+ return null;
600
+ }
601
+ let relPath = pkgPath.substring(workspaceRoot.length);
602
+ if (relPath.startsWith('/')) {
603
+ relPath = relPath.substring(1);
604
+ }
605
+ return relPath || null;
606
+ }
607
+ /**
608
+ * Scans a single Cargo.toml for ignored dependencies and adds them to the set.
609
+ * @param {string} manifest - path to Cargo.toml
610
+ * @param {Set<string>} ignored - the set to add ignored dependency names to
611
+ * @private
612
+ */
613
+ function scanManifestForIgnored(manifest, ignored) {
614
+ let content = fs.readFileSync(manifest, 'utf-8');
615
+ let lines = content.split(/\r?\n/);
616
+ let currentSection = '';
617
+ let currentDepName = null;
618
+ for (let line of lines) {
619
+ let trimmed = line.trim();
620
+ let sectionMatch = trimmed.match(/^\[([^\]]+)\]\s*(?:#.*)?$/);
621
+ if (sectionMatch) {
622
+ currentSection = sectionMatch[1];
623
+ currentDepName = null;
624
+ let tableDep = currentSection.match(/^(?:dependencies|dev-dependencies|build-dependencies|workspace\.dependencies)\.(.+)$/);
625
+ if (tableDep) {
626
+ currentDepName = tableDep[1];
627
+ // If the section header line itself carries an ignore marker,
628
+ // immediately add the dep
629
+ if (hasIgnoreMarker(line)) {
630
+ ignored.add(currentDepName);
631
+ }
632
+ }
633
+ continue;
634
+ }
635
+ let isIgnored = hasIgnoreMarker(line);
636
+ if (isIgnored && isInDependencySection(currentSection)) {
637
+ let inlineMatch = trimmed.match(/^([a-zA-Z0-9_-]+)\s*=/);
638
+ if (inlineMatch) {
639
+ ignored.add(inlineMatch[1]);
640
+ continue;
641
+ }
642
+ }
643
+ if (isIgnored && currentDepName) {
644
+ ignored.add(currentDepName);
645
+ continue;
646
+ }
647
+ }
648
+ }
649
+ /**
650
+ * Checks if a section name is a dependency section.
651
+ * @param {string} section - the TOML section name
652
+ * @returns {boolean}
653
+ * @private
654
+ */
655
+ function isInDependencySection(section) {
656
+ return section === 'dependencies' ||
657
+ section === 'dev-dependencies' ||
658
+ section === 'build-dependencies' ||
659
+ section === 'workspace.dependencies';
660
+ }
661
+ /**
662
+ * Creates a PackageURL for a Cargo/crates.io package.
663
+ * @param {string} name - the crate name
664
+ * @param {string} version - the crate version
665
+ * @returns {PackageURL} the package URL
666
+ * @private
667
+ */
668
+ function toPurl(name, version) {
669
+ return new PackageURL(ecosystem, undefined, name, version, undefined, undefined);
670
+ }
671
+ /**
672
+ * Creates a PackageURL for a local path dependency, marked with a
673
+ * {@code repository_url=local} qualifier so the backend can distinguish
674
+ * it from registry packages and skip vulnerability checks.
675
+ * @param {string} name - the crate name
676
+ * @param {string} version - the crate version
677
+ * @returns {PackageURL} the package URL with local qualifier
678
+ * @private
679
+ */
680
+ function toPathDepPurl(name, version) {
681
+ return new PackageURL(ecosystem, undefined, name, version, { repository_url: 'local' }, undefined);
682
+ }