diff --git a/src/index.js b/src/index.js index f4ed4946..b3b915c8 100644 --- a/src/index.js +++ b/src/index.js @@ -7,6 +7,7 @@ import analysis from './analysis.js' import fs from 'node:fs' import { getCustom } from "./tools.js"; import { resolveBatchMetadata, resolveContinueOnError } from './batch_opts.js' +import { discoverMavenModules } from './providers/java_maven.js' import { discoverWorkspaceCrates, discoverWorkspacePackages, @@ -23,6 +24,7 @@ export { getProjectLicense, findLicenseFilePath, identifyLicense, getLicenseDeta export default { componentAnalysis, stackAnalysis, stackAnalysisBatch, imageAnalysis, validateToken, generateSbom } export { + discoverMavenModules, discoverWorkspacePackages, discoverWorkspaceCrates, validatePackageJson, @@ -98,7 +100,7 @@ export { * @param {any} valueToBePrinted - The value to log. * @private */ -function logOptionsAndEnvironmentsVariables(alongsideText,valueToBePrinted) { +function logOptionsAndEnvironmentsVariables(alongsideText, valueToBePrinted) { if (process.env["TRUSTIFY_DA_DEBUG"] === "true") { console.log(`${alongsideText}: ${valueToBePrinted} ${EOL}`) } @@ -110,9 +112,9 @@ function logOptionsAndEnvironmentsVariables(alongsideText,valueToBePrinted) { */ function readAndPrintVersionFromPackageJson() { let dirName -// new ESM way in nodeJS ( since node version 22 ) to bring module directory. + // new ESM way in nodeJS ( since node version 22 ) to bring module directory. dirName = import.meta.dirname -// old ESM way in nodeJS ( before node versions 22.00 to bring module directory) + // old ESM way in nodeJS ( before node versions 22.00 to bring module directory) if (!dirName) { dirName = url.fileURLToPath(new URL('.', import.meta.url)); } @@ -319,18 +321,26 @@ async function generateOneSbom(manifestPath, workspaceOpts) { * * @param {string} root - Resolved workspace root * @param {Options} opts - * @returns {Promise<{ ecosystem: 'javascript' | 'cargo' | 'unknown', manifestPaths: string[] }>} + * @returns {Promise<{ ecosystem: 'javascript' | 'cargo' | 'maven' | 'unknown', manifestPaths: string[] }>} * @private */ async function detectWorkspaceManifests(root, opts) { const cargoToml = path.join(root, 'Cargo.toml') const cargoLock = path.join(root, 'Cargo.lock') const packageJson = path.join(root, 'package.json') + const pomXml = path.join(root, 'pom.xml') if (fs.existsSync(cargoToml) && fs.existsSync(cargoLock)) { return { ecosystem: 'cargo', manifestPaths: await discoverWorkspaceCrates(root, opts) } } + if (fs.existsSync(pomXml)) { + const manifestPaths = await discoverMavenModules(root, opts) + if (manifestPaths.length > 0) { + return { ecosystem: 'maven', manifestPaths } + } + } + const hasJsLock = fs.existsSync(path.join(root, 'pnpm-lock.yaml')) || fs.existsSync(path.join(root, 'yarn.lock')) || fs.existsSync(path.join(root, 'package-lock.json')) diff --git a/src/providers/base_java.js b/src/providers/base_java.js index e80a17a7..9d328967 100644 --- a/src/providers/base_java.js +++ b/src/providers/base_java.js @@ -1,9 +1,8 @@ -import fs from 'node:fs' import path from 'node:path' import { PackageURL } from 'packageurl-js' -import { getCustomPath, getGitRootDir, getWrapperPreference, invokeCommand } from "../tools.js" +import { getCustomPath, getWrapperPreference, invokeCommand, traverseForWrapper } from "../tools.js" /** @typedef {import('../provider').Provider} */ @@ -145,7 +144,7 @@ export default class Base_Java { const useWrapper = getWrapperPreference(this.globalBinary, opts) if (useWrapper) { - const wrapper = this.traverseForWrapper(manifestPath) + const wrapper = traverseForWrapper(manifestDir, this.localWrapper) if (wrapper !== undefined) { try { this._invokeCommand(wrapper, ['--version'], {cwd: manifestDir}) @@ -168,41 +167,4 @@ export default class Base_Java { return toolPath } - /** - * - * @param {string} startingManifest - the path of the manifest from which to start searching for the wrapper - * @param {string} repoRoot - the root of the repository at which point to stop searching for mvnw, derived via git if unset and then fallsback - * to the root of the drive the manifest is on (assumes absolute path is given) - * @returns {string|undefined} - */ - traverseForWrapper(startingManifest, repoRoot = undefined) { - const normalizedManifest = this.normalizePath(startingManifest); - const currentDir = this.normalizePath(path.dirname(normalizedManifest)); - repoRoot = repoRoot || getGitRootDir(currentDir) || path.parse(normalizedManifest).root; - const wrapperPath = path.join(currentDir, this.localWrapper); - - try { - fs.accessSync(wrapperPath, fs.constants.X_OK); - return wrapperPath; - } - catch (error) { - if (error.code === 'ENOENT') { - const rootDir = path.parse(currentDir).root; - if (currentDir === repoRoot || currentDir === rootDir) { - return undefined; - } - const parentDir = path.dirname(currentDir); - if (parentDir === currentDir || parentDir === rootDir) { - return undefined; - } - return this.traverseForWrapper(path.join(parentDir, path.basename(normalizedManifest)), repoRoot); - } - throw new Error(`failure searching for ${this.localWrapper}`, { cause: error }); - } - } - - normalizePath(thePath) { - const normalized = path.resolve(thePath).normalize(); - return process.platform === 'win32' ? normalized.toLowerCase() : normalized; - } } diff --git a/src/providers/java_maven.js b/src/providers/java_maven.js index 0e827172..7a03947c 100644 --- a/src/providers/java_maven.js +++ b/src/providers/java_maven.js @@ -7,7 +7,8 @@ import { XMLParser } from 'fast-xml-parser' import { getLicense } from '../license/license_utils.js' import Sbom from '../sbom.js' -import { getCustom } from '../tools.js' +import { getCustom, invokeCommand } from '../tools.js' +import { filterManifestPathsByDiscoveryIgnore, resolveWorkspaceDiscoveryIgnore } from '../workspace.js' import Base_java, { ecosystem_maven } from "./base_java.js"; @@ -309,3 +310,101 @@ export default class Java_maven extends Base_java { return deps.filter(d => dep.artifactId === d.artifactId && dep.groupId === d.groupId && dep.scope === d.scope).length > 0 } } + +const DEFAULT_MAVEN_DISCOVERY_IGNORE = [ + '**/target/**', +] + +/** + * Discover all pom.xml manifest paths in a Maven multi-module project. + * + * @param {string} workspaceRoot - Absolute or relative path to workspace root (must contain pom.xml) + * @param {object} [opts={}] + * @returns {Promise} Paths to pom.xml files (absolute) + */ +export async function discoverMavenModules(workspaceRoot, opts = {}) { + const root = path.resolve(workspaceRoot) + const rootPom = path.join(root, 'pom.xml') + + if (!fs.existsSync(rootPom)) { + return [] + } + + let mvnBin + try { + mvnBin = new Java_maven().selectToolBinary(rootPom, opts) + } catch { + return [rootPom] + } + const visited = new Set() + const manifestPaths = [rootPom] + + collectMavenModules(root, mvnBin, visited, manifestPaths) + + const ignorePatterns = [...resolveWorkspaceDiscoveryIgnore(opts), ...DEFAULT_MAVEN_DISCOVERY_IGNORE] + return filterManifestPathsByDiscoveryIgnore(manifestPaths, root, ignorePatterns) +} + +/** + * @param {string} dir - Absolute path to directory containing pom.xml + * @param {string} mvnBin - Maven binary path + * @param {Set} visited - Already-visited directories (cycle guard) + * @param {string[]} manifestPaths - Accumulator for discovered pom.xml paths + */ +function collectMavenModules(dir, mvnBin, visited, manifestPaths) { + const resolvedDir = path.resolve(dir) + if (visited.has(resolvedDir)) { + return + } + visited.add(resolvedDir) + + const modules = listMavenModules(resolvedDir, mvnBin) + for (const mod of modules) { + const moduleDir = path.resolve(resolvedDir, mod) + const modulePom = path.join(moduleDir, 'pom.xml') + if (fs.existsSync(modulePom)) { + manifestPaths.push(modulePom) + collectMavenModules(moduleDir, mvnBin, visited, manifestPaths) + } + } +} + +/** + * @param {string} dir - Directory containing pom.xml + * @param {string} mvnBin - Maven binary path + * @returns {string[]} Module directory names (relative to `dir`) + */ +function listMavenModules(dir, mvnBin) { + let output + try { + output = invokeCommand(mvnBin, [ + 'help:evaluate', + '-Dexpression=project.modules', + '-q', + '-DforceStdout', + '-f', path.join(dir, 'pom.xml'), + '--batch-mode', + ], { cwd: dir }) + } catch { + return [] + } + + const raw = output.toString().trim() + if (!raw || raw.startsWith(' String(s).trim()).filter(Boolean) +} diff --git a/src/tools.js b/src/tools.js index 756968e5..c4d5801d 100644 --- a/src/tools.js +++ b/src/tools.js @@ -1,4 +1,6 @@ import { execFileSync } from "child_process"; +import fs from 'node:fs' +import path from 'node:path' import { EOL } from "os"; import { HttpsProxyAgent } from "https-proxy-agent"; @@ -137,6 +139,45 @@ export function getGitRootDir(cwd) { } } +/** + * Normalize a filesystem path, lowercasing on Windows for case-insensitive comparison. + * + * @param {string} thePath + * @returns {string} + */ +export function normalizePath(thePath) { + const normalized = path.resolve(thePath).normalize() + return process.platform === 'win32' ? normalized.toLowerCase() : normalized +} + +/** + * Walk up from `startDir` to `repoRoot` looking for an executable wrapper script. + * + * @param {string} startDir - Absolute directory to start from + * @param {string} wrapperName - Wrapper filename (e.g. `mvnw`, `gradlew`) + * @param {string} [repoRoot] - Stop boundary (defaults to git root or filesystem root) + * @returns {string | undefined} + */ +export function traverseForWrapper(startDir, wrapperName, repoRoot = undefined) { + const currentDir = normalizePath(startDir) + repoRoot = repoRoot || getGitRootDir(currentDir) || path.parse(currentDir).root + const wrapperPath = path.join(currentDir, wrapperName) + try { + fs.accessSync(wrapperPath, fs.constants.X_OK) + return wrapperPath + } catch { + const rootDir = path.parse(currentDir).root + if (currentDir === repoRoot || currentDir === rootDir) { + return undefined + } + const parentDir = path.dirname(currentDir) + if (parentDir === currentDir || parentDir === rootDir) { + return undefined + } + return traverseForWrapper(parentDir, wrapperName, repoRoot) + } +} + /** this method invokes command string in a process in a synchronous way. * @param {string} bin - the command to be invoked * @param {Array} args - the args to pass to the binary diff --git a/test/providers/tst_manifests/maven/maven_multi_module/module-a/pom.xml b/test/providers/tst_manifests/maven/maven_multi_module/module-a/pom.xml new file mode 100644 index 00000000..6740fbdd --- /dev/null +++ b/test/providers/tst_manifests/maven/maven_multi_module/module-a/pom.xml @@ -0,0 +1,9 @@ + + 4.0.0 + + com.example + parent + 1.0.0 + + module-a + diff --git a/test/providers/tst_manifests/maven/maven_multi_module/module-b/pom.xml b/test/providers/tst_manifests/maven/maven_multi_module/module-b/pom.xml new file mode 100644 index 00000000..03684ec4 --- /dev/null +++ b/test/providers/tst_manifests/maven/maven_multi_module/module-b/pom.xml @@ -0,0 +1,9 @@ + + 4.0.0 + + com.example + parent + 1.0.0 + + module-b + diff --git a/test/providers/tst_manifests/maven/maven_multi_module/pom.xml b/test/providers/tst_manifests/maven/maven_multi_module/pom.xml new file mode 100644 index 00000000..0566726e --- /dev/null +++ b/test/providers/tst_manifests/maven/maven_multi_module/pom.xml @@ -0,0 +1,11 @@ + + 4.0.0 + com.example + parent + 1.0.0 + pom + + module-a + module-b + + diff --git a/test/providers/tst_manifests/maven/maven_nested_aggregator/parent/child/pom.xml b/test/providers/tst_manifests/maven/maven_nested_aggregator/parent/child/pom.xml new file mode 100644 index 00000000..b29df9e8 --- /dev/null +++ b/test/providers/tst_manifests/maven/maven_nested_aggregator/parent/child/pom.xml @@ -0,0 +1,9 @@ + + 4.0.0 + + com.example + parent + 1.0.0 + + child + diff --git a/test/providers/tst_manifests/maven/maven_nested_aggregator/parent/pom.xml b/test/providers/tst_manifests/maven/maven_nested_aggregator/parent/pom.xml new file mode 100644 index 00000000..b95c8a95 --- /dev/null +++ b/test/providers/tst_manifests/maven/maven_nested_aggregator/parent/pom.xml @@ -0,0 +1,10 @@ + + 4.0.0 + com.example + parent + 1.0.0 + pom + + child + + diff --git a/test/providers/tst_manifests/maven/maven_nested_aggregator/pom.xml b/test/providers/tst_manifests/maven/maven_nested_aggregator/pom.xml new file mode 100644 index 00000000..144babcf --- /dev/null +++ b/test/providers/tst_manifests/maven/maven_nested_aggregator/pom.xml @@ -0,0 +1,10 @@ + + 4.0.0 + com.example + root + 1.0.0 + pom + + parent + + diff --git a/test/providers/tst_manifests/maven/maven_no_modules/pom.xml b/test/providers/tst_manifests/maven/maven_no_modules/pom.xml new file mode 100644 index 00000000..770a783f --- /dev/null +++ b/test/providers/tst_manifests/maven/maven_no_modules/pom.xml @@ -0,0 +1,6 @@ + + 4.0.0 + com.example + single + 1.0.0 + diff --git a/test/providers/workspace.test.js b/test/providers/workspace.test.js index 5c06b68a..b2b7a38c 100644 --- a/test/providers/workspace.test.js +++ b/test/providers/workspace.test.js @@ -4,6 +4,7 @@ import path from 'node:path' import { expect } from 'chai' import esmock from 'esmock' +import { discoverMavenModules } from '../../src/providers/java_maven.js' import { discoverWorkspaceCrates, discoverWorkspacePackages, @@ -187,3 +188,64 @@ suite('discoverWorkspaceCrates', () => { expect(result.some(p => p.includes('crate-b'))).to.be.true }) }) + +suite('discoverMavenModules', () => { + test('returns empty when no pom.xml at root', async () => { + const result = await discoverMavenModules('test/providers/tst_manifests/npm') + expect(result).to.be.an('array') + expect(result).to.have.lengthOf(0) + }) + + test('returns root pom only when mvn reports no modules', async () => { + const root = path.resolve('test/providers/tst_manifests/maven/maven_no_modules') + const result = await discoverMavenModules(root) + expect(result).to.be.an('array') + expect(result).to.have.lengthOf(1) + expect(result[0]).to.equal(path.join(root, 'pom.xml')) + }).timeout(40000) + + test('discovers multi-module project', async () => { + const root = path.resolve('test/providers/tst_manifests/maven/maven_multi_module') + const result = await discoverMavenModules(root) + expect(result).to.be.an('array') + expect(result).to.have.lengthOf(3) + expect(result.every(p => p.endsWith('pom.xml'))).to.be.true + expect(result[0]).to.equal(path.join(root, 'pom.xml')) + expect(result.some(p => p.includes('module-a'))).to.be.true + expect(result.some(p => p.includes('module-b'))).to.be.true + }).timeout(40000) + + test('discovers nested aggregator modules recursively', async () => { + const root = path.resolve('test/providers/tst_manifests/maven/maven_nested_aggregator') + const result = await discoverMavenModules(root) + expect(result).to.be.an('array') + expect(result).to.have.lengthOf(3) + expect(result[0]).to.equal(path.join(root, 'pom.xml')) + expect(result.some(p => p.includes(path.join('parent', 'pom.xml')))).to.be.true + expect(result.some(p => p.includes(path.join('parent', 'child', 'pom.xml')))).to.be.true + }).timeout(40000) + + test('returns root pom when mvn is not available', async () => { + const root = path.resolve('test/providers/tst_manifests/maven/maven_multi_module') + const { discoverMavenModules: discoverMocked } = await esmock('../../src/providers/java_maven.js', { + '../../src/tools.js': { + getCustomPath: () => '/nonexistent/mvn', + getWrapperPreference: () => false, + invokeCommand: () => { throw Object.assign(new Error('mvn not found'), { code: 'ENOENT' }) }, + }, + }) + const result = await discoverMocked(root) + expect(result).to.be.an('array') + expect(result).to.have.lengthOf(1) + expect(result[0]).to.equal(path.join(root, 'pom.xml')) + }) + + test('excludes paths matching workspaceDiscoveryIgnore', async () => { + const root = path.resolve('test/providers/tst_manifests/maven/maven_multi_module') + const result = await discoverMavenModules(root, { + workspaceDiscoveryIgnore: ['**/module-b/**'], + }) + expect(result.some(p => p.includes('module-a'))).to.be.true + expect(result.some(p => p.includes('module-b'))).to.be.false + }).timeout(40000) +})