diff --git a/src/cyclone_dx_sbom.js b/src/cyclone_dx_sbom.js index 2b290dae..dee19538 100644 --- a/src/cyclone_dx_sbom.js +++ b/src/cyclone_dx_sbom.js @@ -164,6 +164,31 @@ export default class CycloneDxSbom { return this; } + /** + * Attach hashes to already-added components by matching their PURL. This is a + * post-processing step so ecosystem-specific hash sources (e.g. Maven reading + * the local .m2 cache) can enrich the SBOM without threading their concern + * through the shared dependency-tree parser. Components without a matching + * entry, or whose hashes are already set, are left untouched. + * @param {Map>} hashMap - PURL→hashes map + * @return {CycloneDxSbom} the updated SBOM + */ + attachHashes(hashMap) { + if (!hashMap || hashMap.size === 0) { + return this + } + for (const component of this.components) { + if (component.hashes) { + continue + } + const hashes = hashMap.get(component.purl) + if (hashes && hashes.length > 0) { + component.hashes = hashes + } + } + return this + } + /** @param {{}} opts - various options, settings and configuration of application. * @return String CycloneDx Sbom json object in a string format */ diff --git a/src/providers/base_java.js b/src/providers/base_java.js index ced65ce5..88711fa5 100644 --- a/src/providers/base_java.js +++ b/src/providers/base_java.js @@ -22,6 +22,14 @@ export default class Base_Java { DEP_REGEX = /(([-a-zA-Z0-9._]{2,})|[0-9])/g CONFLICT_REGEX = /.*omitted for conflict with (\S+)\)/ + /** + * Maven dependency scopes. Used to detect whether the trailing column of a + * dependency-tree line is a scope keyword — which in turn signals that an + * optional classifier column is present between the packaging and version. + * @type {string[]} + */ + static MAVEN_SCOPES = ['compile', 'provided', 'runtime', 'test', 'system', 'import'] + globalBinary localWrapper @@ -90,30 +98,73 @@ export default class Base_Java { return ((line.indexOf('-') - 1) / 3) + 1; } + /** + * Parse a single dependency-tree line into its Maven coordinate parts. + * + * This is the single source of truth for interpreting a dependency-tree + * line. Both {@link parseDep} (used to build SBOM component PURLs) and the + * Maven hash-map builder rely on it, so the PURL key and the artifact file + * path can never drift from one another. + * + * A line has the shape `groupId:artifactId:packaging[:classifier]:version[:scope]`. + * The classifier column is only present when a sixth column holds a known + * Maven scope keyword; otherwise the fourth column is the version. + * + * @param {string} line - line to parse from a dependency tree + * @returns {{groupId: string, artifactId: string, packaging: string, classifier: (string|null), version: string, scope: (string|null), overridden: boolean}} + * Parsed coordinate. `version` is the resolved concrete version — when the + * line carries a conflict override the override version replaces it and + * `overridden` is set, mirroring how Maven records the winning version. + */ + parseCoordinate(line) { + const parts = line.split(':').map(part => part ? part.match(this.DEP_REGEX)?.[0] ?? '' : '') + const groupId = parts[0] ?? '' + const artifactId = parts[1] ?? '' + const packaging = parts[2] ?? '' + // A classifier column exists only when a sixth column holds a scope keyword. + const hasClassifier = parts.length >= 6 && Base_Java.MAVEN_SCOPES.includes(parts[5]) + const classifier = hasClassifier ? parts[3] : null + let version = (hasClassifier ? parts[4] : parts[3]) ?? '' + const scope = hasClassifier ? parts[5] : (parts.length >= 5 ? parts[4] : null) + // A conflict override replaces the resolved version entirely. + const override = line.match(this.CONFLICT_REGEX) + const overridden = Boolean(override) + if (overridden) { + version = override[1] + } + return { groupId, artifactId, packaging, classifier, version, scope, overridden } + } + + /** + * Build the canonical PackageURL for a parsed coordinate. + * + * The classifier is folded into the version component (e.g. + * `4.1.0-linux-x86_64`) except when the version came from a conflict + * override, in which case the override version stands alone — matching the + * historical behavior of {@link parseDep}. + * + * @param {{groupId: string, artifactId: string, classifier: (string|null), version: string, overridden: boolean}} coord + * @returns {PackageURL} The canonical packageURL for the coordinate + * @protected + */ + _coordinateToPurl(coord) { + const purlVersion = (coord.classifier && !coord.overridden) + ? `${coord.version}-${coord.classifier}` + : coord.version + return this.toPurl(coord.groupId, coord.artifactId, purlVersion) + } + /** * Create a PackageURL from any line in a Text Graph dependency tree for a manifest path. * @param {string} line - line to parse from a dependencies.txt file * @returns {PackageURL} The parsed packageURL */ parseDep(line) { - let match = line.split(':').map(part => part ? part.match(this.DEP_REGEX)[0] : ''); - if (!match) { - throw new Error(`Unable generate SBOM from dependency tree. Line: ${line} cannot be parsed into a PackageURL`); - } - let version - if (match.length >= 5 && ['compile', 'provided', 'runtime'].includes(match[5])) { - version = `${match[4]}-${match[3]}` - } else { - version = match[3] - } - let override = line.match(this.CONFLICT_REGEX); - if (override) { - version = override[1]; - } - if (match[0].trim() === '') { + const coord = this.parseCoordinate(line) + if (coord.groupId.trim() === '') { throw new Error(`Artifact coordinates should have a non-empty group ID: ${line}`); } - return this.toPurl(match[0], match[1], version); + return this._coordinateToPurl(coord); } /** diff --git a/src/providers/java_maven.js b/src/providers/java_maven.js index 1f0d6ccf..4f881cde 100644 --- a/src/providers/java_maven.js +++ b/src/providers/java_maven.js @@ -1,3 +1,4 @@ +import crypto from 'node:crypto' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -145,21 +146,100 @@ export default class Java_maven extends Base_java { if (process.env["TRUSTIFY_DA_DEBUG"] === "true") { console.error("Dependency tree that will be used as input for creating the BOM =>" + EOL + EOL + content.toString()) } - let sbom = this.createSbomFileFromTextFormat(content.toString(), ignoredDeps, opts, manifest); + const depTreeContent = content.toString() + const hashMap = this._buildMavenHashMap(depTreeContent, opts) + let sbom = this.createSbomFileFromTextFormat(depTreeContent, ignoredDeps, opts, manifest, hashMap); // delete temp file and directory fs.rmSync(tmpDir, { recursive: true, force: true }) // return dependency graph as string return sbom } + /** @type {Object} Packaging types that produce .jar files despite non-jar packaging names. */ + static PACKAGING_TO_JAR = { 'bundle': 'jar', 'eclipse-plugin': 'jar' } + /** + * Build a Map of PURL string → CycloneDX hash entries by reading artifact files from the local Maven repository. + * + * Both the artifact file path and the hash-map key are derived from the + * shared {@link Base_java#parseCoordinate} parser: the file path uses the + * resolved version and raw classifier, while the key uses the same canonical + * PURL builder as {@link Base_java#parseDep}. This guarantees the key always + * matches the PURL that {@link Base_java#parseDependencyTree} looks up. * + * @param {string} depTreeText Raw dependency tree text from mvn dependency:tree + * @param {{}} [opts={}] Options bag (may contain TRUSTIFY_DA_MVN_REPO) + * @returns {Map>} + */ + _buildMavenHashMap(depTreeText, opts = {}) { + const m2Repo = getCustom('TRUSTIFY_DA_MVN_REPO', path.join(os.homedir(), '.m2', 'repository'), opts) + const hashMap = new Map() + const lines = depTreeText.split(EOL) + // Track hash coverage so an incomplete .m2 cache (e.g. ephemeral CI + // containers or resolve-only phases) surfaces a summary warning instead + // of silently producing an SBOM with missing hashes. + let attempted = 0 + let missed = 0 + + for (const rawLine of lines) { + const trimmed = rawLine.trim() + // Strip leading tree-drawing characters (e.g. "\-", "+-", "|") so the + // parenthesized-line check sees the coordinate itself. Omitted + // duplicate/conflict lines start with these characters after trim(), + // then an opening "(" — without stripping, the "(" check never fires. + const cleaned = trimmed.replace(/^[|+\\\- ]+/, '') + if (!cleaned || cleaned.startsWith('(')) { continue } + + const coord = this.parseCoordinate(rawLine) + if (!coord.groupId || !coord.artifactId || !coord.packaging) { continue } + if (coord.packaging === 'pom') { continue } + + // Key by the exact PURL parseDep() produces, so the lookup in + // parseDependencyTree (hashMap.get(to.toString())) always hits. + const purl = this._coordinateToPurl(coord).toString() + // The same artifact recurs across dependency-tree branches — notably in + // multi-module reactor builds, where every module re-lists shared deps. + // The digest is deterministic per PURL, so skip the redundant file read + // and SHA-256 computation once this PURL is already hashed. + if (hashMap.has(purl)) { continue } + + const ext = Java_maven.PACKAGING_TO_JAR[coord.packaging] || coord.packaging + const groupPath = coord.groupId.replaceAll('.', path.sep) + const fileName = coord.classifier + ? `${coord.artifactId}-${coord.version}-${coord.classifier}.${ext}` + : `${coord.artifactId}-${coord.version}.${ext}` + const artifactPath = path.join(m2Repo, groupPath, coord.artifactId, coord.version, fileName) + + attempted++ + try { + const fileContent = fs.readFileSync(artifactPath) + const digest = crypto.createHash('sha256').update(fileContent).digest('hex') + hashMap.set(purl, [{ alg: 'SHA-256', content: digest }]) + } catch { + missed++ + if (process.env['TRUSTIFY_DA_DEBUG'] === 'true') { + console.error(`Maven hash: artifact not found at ${artifactPath}, omitting hash`) + } + } + } + // Mirror the pip provider's convention (python_controller.js): surface an + // unconditional warning when hashes could not be computed, so incomplete + // hash coverage is visible even without TRUSTIFY_DA_DEBUG. + if (missed > 0) { + console.warn(`Maven hash: ${missed} of ${attempted} artifacts could not be read from the local .m2 cache; SBOM will be generated without hashes for those components.`) + } + return hashMap + } + + /** * @param {String} textGraphList Text graph String of the manifest * @param {[String]} ignoredDeps List of ignored dependencies to be omitted from sbom + * @param {{}} opts Options * @param {String} manifestPath Path to the pom.xml manifest + * @param {Map>} [hashMap] PURL→hashes map * @return {String} formatted sbom Json String with all dependencies */ - createSbomFileFromTextFormat(textGraphList, ignoredDeps, opts, manifestPath) { + createSbomFileFromTextFormat(textGraphList, ignoredDeps, opts, manifestPath, hashMap) { let lines = textGraphList.split(EOL); // get root component let root = lines[0]; @@ -168,6 +248,9 @@ export default class Java_maven extends Base_java { let sbom = new Sbom(); sbom.addRoot(rootPurl, license); this.parseDependencyTree(root, 0, lines.slice(1), sbom); + // Attach Maven artifact hashes as a post-processing step, keeping this + // Maven-specific concern out of the shared dependency-tree parser. + sbom.attachHashes(hashMap); return sbom.filterIgnoredDeps(ignoredDeps).getAsJsonString(opts); } diff --git a/src/sbom.js b/src/sbom.js index 1c423f14..042a45c6 100644 --- a/src/sbom.js +++ b/src/sbom.js @@ -56,6 +56,17 @@ export default class Sbom { return this.sbomModel.addDependency(sourceRef, targetRef, scope, targetHashes) } + /** + * Attach hashes to existing components by matching their PURL. Post-processing + * step used by ecosystem-specific providers (e.g. Maven) to enrich the SBOM + * with artifact hashes without leaking their concern into the shared parser. + * @param {Map>} hashMap - PURL→hashes map + * @return {Sbom} + */ + attachHashes(hashMap){ + return this.sbomModel.attachHashes(hashMap) + } + /** * @return String sbom json in a string format */ diff --git a/test/cyclone_dx_sbom_hashes.test.js b/test/cyclone_dx_sbom_hashes.test.js index 286b5e9a..a2b01dd1 100644 --- a/test/cyclone_dx_sbom_hashes.test.js +++ b/test/cyclone_dx_sbom_hashes.test.js @@ -135,6 +135,77 @@ suite('CycloneDX SBOM hash support', () => { expect(depComponents[0].hashes).to.deep.equal(depHashes) }) + /** Verifies that attachHashes enriches an existing component matched by PURL. */ + test('attachHashes attaches hashes to a matching component by PURL', () => { + // Given an SBOM with a dependency added without hashes + const sbom = new CycloneDxSbom() + const root = new PackageURL('maven', 'com.example', 'root', '1.0.0', undefined, undefined) + const dep = new PackageURL('maven', 'log4j', 'log4j', '1.2.17', undefined, undefined) + sbom.addRoot(root) + sbom.addDependency(root, dep) + + // When attaching hashes keyed by the dependency's PURL + sbom.attachHashes(new Map([[dep.toString(), sampleHashes]])) + + // Then the matching component carries the hashes + const depComponent = sbom.components.find(c => c.name === 'log4j') + expect(depComponent.hashes).to.deep.equal(sampleHashes) + }) + + /** Verifies that attachHashes leaves components without a map entry untouched. */ + test('attachHashes leaves unmatched components without a hashes field', () => { + // Given an SBOM whose dependency has no entry in the hash map + const sbom = new CycloneDxSbom() + const root = new PackageURL('maven', 'com.example', 'root', '1.0.0', undefined, undefined) + const dep = new PackageURL('maven', 'log4j', 'log4j', '1.2.17', undefined, undefined) + sbom.addRoot(root) + sbom.addDependency(root, dep) + + // When attaching a map that references a different PURL + const other = new PackageURL('maven', 'com.other', 'lib', '9.9.9', undefined, undefined) + sbom.attachHashes(new Map([[other.toString(), sampleHashes]])) + + // Then the unmatched component has no hashes property + const depComponent = sbom.components.find(c => c.name === 'log4j') + expect(depComponent).to.not.have.property('hashes') + }) + + /** Verifies that attachHashes does not overwrite hashes already present on a component. */ + test('attachHashes does not overwrite existing hashes', () => { + // Given a component that already has hashes + const sbom = new CycloneDxSbom() + const root = new PackageURL('maven', 'com.example', 'root', '1.0.0', undefined, undefined) + const dep = new PackageURL('maven', 'log4j', 'log4j', '1.2.17', undefined, undefined) + const originalHashes = [{ alg: 'SHA-256', content: 'original' }] + sbom.addRoot(root) + sbom.addDependency(root, dep, undefined, originalHashes) + + // When attaching a different hash for the same PURL + sbom.attachHashes(new Map([[dep.toString(), sampleHashes]])) + + // Then the original hashes are preserved + const depComponent = sbom.components.find(c => c.name === 'log4j') + expect(depComponent.hashes).to.deep.equal(originalHashes) + }) + + /** Verifies that attachHashes tolerates an undefined or empty map without error. */ + test('attachHashes is a no-op for an empty or undefined map', () => { + // Given an SBOM with a dependency + const sbom = new CycloneDxSbom() + const root = new PackageURL('maven', 'com.example', 'root', '1.0.0', undefined, undefined) + const dep = new PackageURL('maven', 'log4j', 'log4j', '1.2.17', undefined, undefined) + sbom.addRoot(root) + sbom.addDependency(root, dep) + + // When attaching an empty map and an undefined map + sbom.attachHashes(new Map()) + sbom.attachHashes(undefined) + + // Then no hashes are added and no error is thrown + const depComponent = sbom.components.find(c => c.name === 'log4j') + expect(depComponent).to.not.have.property('hashes') + }) + /** Verifies that passing an empty hashes array is treated the same as no hashes. */ test('empty hashes array does not add hashes field', () => { // Given an SBOM with a dependency with an empty hashes array diff --git a/test/providers/java_maven.test.js b/test/providers/java_maven.test.js index 94b7b109..680e22e2 100644 --- a/test/providers/java_maven.test.js +++ b/test/providers/java_maven.test.js @@ -1,10 +1,11 @@ -import fs from 'fs' -import { platform } from 'os'; -import path from 'path'; +import crypto from 'node:crypto' +import fs from 'node:fs' +import os, { platform } from 'node:os' +import path from 'node:path' import { expect } from 'chai' import esmock from 'esmock'; -import { useFakeTimers } from "sinon"; +import { spy, useFakeTimers } from "sinon"; import which from 'which'; import Java_maven from '../../src/providers/java_maven.js' @@ -181,3 +182,347 @@ suite('testing the java-maven version parsing in getDependencies', () => { expect(bouncyCastleDependency.ref).to.equal('pkg:maven/org.bouncycastle/bcprov-jdk18on@1.80'); }); }); + +suite('testing the java-maven SHA-256 hash computation', () => { + let tmpM2Repo + const jarContent = Buffer.from('mock-jar-content-for-testing') + const expectedDigest = crypto.createHash('sha256').update(jarContent).digest('hex') + + suiteSetup(() => { + tmpM2Repo = fs.mkdtempSync(path.join(os.tmpdir(), 'trustify_da_m2_test_')) + // Create a mock .m2 directory structure with a jar file + const jarDir = path.join(tmpM2Repo, 'log4j', 'log4j', '1.2.17') + fs.mkdirSync(jarDir, { recursive: true }) + fs.writeFileSync(path.join(jarDir, 'log4j-1.2.17.jar'), jarContent) + }) + + suiteTeardown(() => { + fs.rmSync(tmpM2Repo, { recursive: true, force: true }) + }) + + /** Verifies that SHA-256 hashes are computed from jar files in the local .m2 repository. */ + test('verify _buildMavenHashMap computes SHA-256 from jar files', () => { + // Given a dependency tree with a dependency whose jar exists in the mock .m2 repo + const provider = new Java_maven() + const depTree = 'com.example:root:jar:1.0.0\n\\- log4j:log4j:jar:1.2.17:compile\n' + + // When building the hash map using the mock .m2 repo + const hashMap = provider._buildMavenHashMap(depTree, { 'TRUSTIFY_DA_MVN_REPO': tmpM2Repo }) + + // Then the hash map contains the correct SHA-256 digest for log4j + const purl = 'pkg:maven/log4j/log4j@1.2.17' + expect(hashMap.has(purl)).to.equal(true) + expect(hashMap.get(purl)).to.deep.equal([{ alg: 'SHA-256', content: expectedDigest }]) + }) + + /** Verifies that missing jar files result in omitted hashes rather than errors. */ + test('verify _buildMavenHashMap omits hash when jar file is not in cache', () => { + // Given a dependency tree referencing an artifact not in the mock repo + const provider = new Java_maven() + const depTree = 'com.example:root:jar:1.0.0\n\\- org.missing:artifact:jar:1.0.0:compile\n' + + // When building the hash map + const hashMap = provider._buildMavenHashMap(depTree, { 'TRUSTIFY_DA_MVN_REPO': tmpM2Repo }) + + // Then the hash map is empty — no error thrown + expect(hashMap.size).to.equal(0) + }) + + /** Verifies that custom Maven repository path via TRUSTIFY_DA_MVN_REPO is respected. */ + test('verify _buildMavenHashMap uses custom TRUSTIFY_DA_MVN_REPO path', () => { + // Given the env var points to our mock .m2 repo + const provider = new Java_maven() + const depTree = 'com.example:root:jar:1.0.0\n\\- log4j:log4j:jar:1.2.17:compile\n' + + // When building hash map with TRUSTIFY_DA_MVN_REPO set via opts + const hashMap = provider._buildMavenHashMap(depTree, { 'TRUSTIFY_DA_MVN_REPO': tmpM2Repo }) + + // Then hash is found (proving the custom path was used) + expect(hashMap.has('pkg:maven/log4j/log4j@1.2.17')).to.equal(true) + }) + + /** Verifies that packaging types like 'bundle' are mapped to .jar file extension. */ + test('verify _buildMavenHashMap maps bundle packaging to .jar extension', () => { + // Given a mock jar under a groupId that uses 'bundle' packaging in the tree + const bundleDir = path.join(tmpM2Repo, 'org', 'osgi', 'core', '6.0.0') + fs.mkdirSync(bundleDir, { recursive: true }) + fs.writeFileSync(path.join(bundleDir, 'core-6.0.0.jar'), jarContent) + + const provider = new Java_maven() + const depTree = 'com.example:root:jar:1.0.0\n\\- org.osgi:core:bundle:6.0.0:compile\n' + + // When building the hash map + const hashMap = provider._buildMavenHashMap(depTree, { 'TRUSTIFY_DA_MVN_REPO': tmpM2Repo }) + + // Then the bundle dependency gets a hash (mapped to .jar) + expect(hashMap.has('pkg:maven/org.osgi/core@6.0.0')).to.equal(true) + expect(hashMap.get('pkg:maven/org.osgi/core@6.0.0')[0].content).to.equal(expectedDigest) + }) + + /** Verifies that POM-only artifacts are skipped and no hash is computed. */ + test('verify _buildMavenHashMap skips pom packaging type', () => { + const provider = new Java_maven() + const depTree = 'com.example:root:jar:1.0.0\n\\- org.example:bom:pom:1.0.0:compile\n' + + // When building the hash map + const hashMap = provider._buildMavenHashMap(depTree, { 'TRUSTIFY_DA_MVN_REPO': tmpM2Repo }) + + // Then no hash entry for the pom-only artifact + expect(hashMap.has('pkg:maven/org.example/bom@1.0.0')).to.equal(false) + }) + + /** Verifies that classified dependencies produce the correct file path with classifier in the filename. */ + test('verify _buildMavenHashMap handles classified dependencies', () => { + // Given a jar with classifier in the expected path + const classifiedDir = path.join(tmpM2Repo, 'io', 'netty', 'netty-transport', '4.1.0') + fs.mkdirSync(classifiedDir, { recursive: true }) + fs.writeFileSync(path.join(classifiedDir, 'netty-transport-4.1.0-linux-x86_64.jar'), jarContent) + + const provider = new Java_maven() + const depTree = 'com.example:root:jar:1.0.0\n\\- io.netty:netty-transport:jar:linux-x86_64:4.1.0:compile\n' + + // When building the hash map + const hashMap = provider._buildMavenHashMap(depTree, { 'TRUSTIFY_DA_MVN_REPO': tmpM2Repo }) + + // Then the classified dependency gets a hash keyed by the mangled PURL + const purl = 'pkg:maven/io.netty/netty-transport@4.1.0-linux-x86_64' + expect(hashMap.has(purl)).to.equal(true) + expect(hashMap.get(purl)[0].content).to.equal(expectedDigest) + }) + + /** Verifies that hashes flow through createSbomFileFromTextFormat into SBOM components. */ + test('verify hashes appear in SBOM components via createSbomFileFromTextFormat', () => { + // Given a dependency tree and a hash map with an entry + const clock = useFakeTimers(new Date('2023-08-07T00:00:00.000Z')) + try { + const provider = new Java_maven() + const depTree = 'com.example:root:jar:1.0.0\n\\- log4j:log4j:jar:1.2.17:compile' + const hashMap = new Map() + hashMap.set('pkg:maven/log4j/log4j@1.2.17', [{ alg: 'SHA-256', content: 'abcdef1234567890' }]) + + // When creating the SBOM with the hash map + const sbomJson = provider.createSbomFileFromTextFormat( + depTree, [], {}, + 'test/providers/tst_manifests/maven/pom_deps_with_no_ignore/pom.xml', + hashMap + ) + const sbom = JSON.parse(sbomJson) + + // Then the log4j component includes the hash + const log4jComponent = sbom.components.find(c => c.name === 'log4j') + expect(log4jComponent).to.exist + expect(log4jComponent.hashes).to.deep.equal([{ alg: 'SHA-256', content: 'abcdef1234567890' }]) + } finally { + clock.restore() + } + }) + + /** Verifies that lines with empty parts from DEP_REGEX mismatch are skipped without error. */ + test('verify _buildMavenHashMap skips lines with empty parsed fields', () => { + const provider = new Java_maven() + const depTree = 'com.example:root:jar:1.0.0\n\\- :log4j:jar:1.2.17:compile\n\\- log4j::jar:1.2.17:compile\n' + + // When building the hash map with lines that have empty groupId or artifactId + const hashMap = provider._buildMavenHashMap(depTree, { 'TRUSTIFY_DA_MVN_REPO': tmpM2Repo }) + + // Then the hash map is empty — malformed lines are skipped + expect(hashMap.size).to.equal(0) + }) + + /** + * Verifies that an artifact recurring across dependency-tree branches (as in a + * multi-module reactor build) is read and hashed only once — the dedup guard + * skips the redundant file read + SHA-256 computation for the already-hashed PURL. + */ + test('verify _buildMavenHashMap reads each artifact once despite repeated tree lines', () => { + // Given the same resolved artifact listed on three separate tree branches + const readSpy = spy(fs, 'readFileSync') + try { + const provider = new Java_maven() + const depTree = [ + 'com.example:root:jar:1.0.0', + '+- com.example:module-a:jar:1.0.0:compile', + '| \\- log4j:log4j:jar:1.2.17:compile', + '+- com.example:module-b:jar:1.0.0:compile', + '| \\- log4j:log4j:jar:1.2.17:compile', + '\\- com.example:module-c:jar:1.0.0:compile', + ' \\- log4j:log4j:jar:1.2.17:compile' + ].join('\n') + const log4jJar = path.join(tmpM2Repo, 'log4j', 'log4j', '1.2.17', 'log4j-1.2.17.jar') + + // When building the hash map + const hashMap = provider._buildMavenHashMap(depTree, { 'TRUSTIFY_DA_MVN_REPO': tmpM2Repo }) + + // Then the log4j jar is read exactly once even though it appears three times, + // and its hash is still present with the correct digest + const log4jReads = readSpy.getCalls().filter(c => c.args[0] === log4jJar) + expect(log4jReads.length).to.equal(1) + expect(hashMap.get('pkg:maven/log4j/log4j@1.2.17')).to.deep.equal([{ alg: 'SHA-256', content: expectedDigest }]) + } finally { + readSpy.restore() + } + }) + + /** + * Verifies that an incomplete .m2 cache surfaces a summary warning (even + * without TRUSTIFY_DA_DEBUG) reporting how many of the attempted artifacts + * could not be read, so degraded hash coverage is visible rather than silent. + */ + test('verify _buildMavenHashMap warns with a coverage summary when artifacts are missing', () => { + // Given a tree with one cached artifact (log4j) and one absent from the mock repo + const warnSpy = spy(console, 'warn') + try { + const provider = new Java_maven() + const depTree = [ + 'com.example:root:pom:1.0.0', + '+- log4j:log4j:jar:1.2.17:compile', + '\\- com.example:missing-lib:jar:9.9.9:compile' + ].join('\n') + + // When building the hash map + const hashMap = provider._buildMavenHashMap(depTree, { 'TRUSTIFY_DA_MVN_REPO': tmpM2Repo }) + + // Then the cached artifact is still hashed, and exactly one summary + // warning reports the single miss out of the two attempted reads + expect(hashMap.has('pkg:maven/log4j/log4j@1.2.17')).to.equal(true) + expect(hashMap.has('pkg:maven/com.example/missing-lib@9.9.9')).to.equal(false) + expect(warnSpy.callCount).to.equal(1) + expect(warnSpy.firstCall.args[0]).to.equal( + 'Maven hash: 1 of 2 artifacts could not be read from the local .m2 cache; SBOM will be generated without hashes for those components.' + ) + } finally { + warnSpy.restore() + } + }) + + /** Verifies that no coverage warning is emitted when every attempted artifact is hashed. */ + test('verify _buildMavenHashMap stays silent when all artifacts are hashed', () => { + // Given a tree whose only artifact (log4j) exists in the mock repo + const warnSpy = spy(console, 'warn') + try { + const provider = new Java_maven() + const depTree = 'com.example:root:pom:1.0.0\n\\- log4j:log4j:jar:1.2.17:compile' + + // When building the hash map + const hashMap = provider._buildMavenHashMap(depTree, { 'TRUSTIFY_DA_MVN_REPO': tmpM2Repo }) + + // Then the artifact is hashed and no coverage warning is emitted + expect(hashMap.has('pkg:maven/log4j/log4j@1.2.17')).to.equal(true) + expect(warnSpy.called).to.equal(false) + } finally { + warnSpy.restore() + } + }) + + /** + * Verifies that parenthesized (omitted/duplicate) lines are skipped by the + * guard itself — before any file I/O — even when the referenced artifact IS + * present in the mock .m2 repository. This proves the tree-character-stripping + * guard is what filters the line, not an incidentally missing fixture jar. + */ + test('verify _buildMavenHashMap skips parenthesized duplicate entries via the guard', () => { + // Given the "omitted for duplicate" artifact's jar exists in the mock repo, + // so a missing file cannot be the reason the entry is skipped + const slf4jDir = path.join(tmpM2Repo, 'org', 'slf4j', 'slf4j-api', '1.7.36') + fs.mkdirSync(slf4jDir, { recursive: true }) + fs.writeFileSync(path.join(slf4jDir, 'slf4j-api-1.7.36.jar'), jarContent) + expect(fs.existsSync(path.join(slf4jDir, 'slf4j-api-1.7.36.jar'))).to.equal(true) + + const provider = new Java_maven() + const depTree = [ + 'com.example:root:jar:1.0.0', + '\\- log4j:log4j:jar:1.2.17:compile', + ' \\- (org.slf4j:slf4j-api:jar:1.7.36:compile - omitted for duplicate)' + ].join('\n') + + // When building the hash map + const hashMap = provider._buildMavenHashMap(depTree, { 'TRUSTIFY_DA_MVN_REPO': tmpM2Repo }) + + // Then the parenthesized slf4j entry is absent (skipped by the guard) even + // though its jar exists, while the non-parenthesized log4j entry is hashed + expect(hashMap.has('pkg:maven/org.slf4j/slf4j-api@1.7.36')).to.equal(false) + expect(hashMap.has('pkg:maven/log4j/log4j@1.2.17')).to.equal(true) + }) + + /** + * Verifies that an "omitted for conflict" line — which carries a classifier and + * a conflict override — is skipped by the guard before any file I/O, even when + * the referenced jar is present in the mock .m2 repository. The resolved + * version's hash comes from the real (non-parenthesized) winner node elsewhere + * in the tree, never from the omitted loser line itself. + */ + test('verify _buildMavenHashMap skips omitted-for-conflict lines via the guard', () => { + // Given the omitted classified artifact's jar exists in the mock repo, + // so a missing file cannot be the reason the entry is skipped + const overrideDir = path.join(tmpM2Repo, 'io', 'netty', 'netty-transport', '4.2.0') + fs.mkdirSync(overrideDir, { recursive: true }) + fs.writeFileSync(path.join(overrideDir, 'netty-transport-4.2.0-linux-x86_64.jar'), jarContent) + + const provider = new Java_maven() + // A verbose-tree line where the classified dep loses a conflict to 4.2.0 + const depTree = [ + 'com.example:root:jar:1.0.0', + '\\- log4j:log4j:jar:1.2.17:compile', + ' \\- (io.netty:netty-transport:jar:linux-x86_64:4.1.0:compile - omitted for conflict with 4.2.0)' + ].join('\n') + + // When building the hash map + const hashMap = provider._buildMavenHashMap(depTree, { 'TRUSTIFY_DA_MVN_REPO': tmpM2Repo }) + + // Then the parenthesized conflict line yields no entry (guard-skipped before + // I/O) under either the override PURL or the classified PURL, while the + // non-parenthesized log4j entry is still hashed + expect(hashMap.has('pkg:maven/io.netty/netty-transport@4.2.0')).to.equal(false) + expect(hashMap.has('pkg:maven/io.netty/netty-transport@4.2.0-linux-x86_64')).to.equal(false) + expect(hashMap.has('pkg:maven/log4j/log4j@1.2.17')).to.equal(true) + }) + + /** + * Verifies that a classified dependency declared with a non-compile scope + * (system) is detected as classified — the hash-map key and parseDep PURL + * agree and the hash attaches. Guards against scope-list drift between the + * two code paths. + */ + test('verify classified dependency with system scope produces matching keys and attaches hash', () => { + // Given a classified jar for a system-scoped dependency + const systemDir = path.join(tmpM2Repo, 'com', 'sun', 'tools', '1.8.0') + fs.mkdirSync(systemDir, { recursive: true }) + fs.writeFileSync(path.join(systemDir, 'tools-1.8.0-jdk8.jar'), jarContent) + + const provider = new Java_maven() + const depTree = 'com.example:root:jar:1.0.0\n\\- com.sun:tools:jar:jdk8:1.8.0:system' + + // When building the hash map + const hashMap = provider._buildMavenHashMap(depTree, { 'TRUSTIFY_DA_MVN_REPO': tmpM2Repo }) + + // Then the classifier is folded into the version and both paths agree + const purl = 'pkg:maven/com.sun/tools@1.8.0-jdk8' + expect(provider.parseDep(depTree.split('\n')[1]).toString()).to.equal(purl) + expect(hashMap.has(purl)).to.equal(true) + expect(hashMap.get(purl)[0].content).to.equal(expectedDigest) + }) + + /** + * Drift guard: the hash-map key derivation must stay identical to the PURL + * parseDep() emits for the same line, across plain, classified, override, and + * scoped coordinate shapes. Both must route through the shared parseCoordinate + * / _coordinateToPurl helpers, so this equality holds by construction. + */ + test('verify parseDep and hash-map key derivation agree for all coordinate shapes', () => { + const provider = new Java_maven() + const lines = [ + '\\- log4j:log4j:jar:1.2.17:compile', + '\\- io.netty:netty-transport:jar:linux-x86_64:4.1.0:compile', + '\\- (io.netty:netty-transport:jar:linux-x86_64:4.1.0:compile - omitted for conflict with 4.2.0)', + '\\- com.sun:tools:jar:jdk8:1.8.0:system', + '\\- (org.foo:bar:jar:1.0.0:compile - omitted for conflict with 2.0.0)' + ] + + // For each shape, the key the hash map would store equals parseDep's PURL + for (const line of lines) { + const viaHashMap = provider._coordinateToPurl(provider.parseCoordinate(line)).toString() + const viaParseDep = provider.parseDep(line).toString() + expect(viaHashMap).to.equal(viaParseDep) + } + }) +});