@trustify-da/trustify-da-javascript-client 0.3.0-ea.9b6856c → 0.3.0-ea.a3789b1

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