Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 135 additions & 2 deletions src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import io.github.guacsec.trustifyda.image.ImageUtils;
import io.github.guacsec.trustifyda.license.LicenseCheck;
import io.github.guacsec.trustifyda.logging.LoggersFactory;
import io.github.guacsec.trustifyda.providers.JavaMavenProvider;
import io.github.guacsec.trustifyda.providers.golang.model.GoWorkspace;
import io.github.guacsec.trustifyda.providers.javascript.workspace.JsWorkspaceDiscovery;
import io.github.guacsec.trustifyda.providers.rust.model.CargoMetadata;
Expand Down Expand Up @@ -843,7 +844,7 @@ int resolveBatchConcurrency() {
}

private static final Set<String> DEFAULT_WORKSPACE_DISCOVERY_IGNORE =
Set.of("**/node_modules/**", "**/.git/**");
Set.of("**/node_modules/**", "**/.git/**", "**/target/**");

/** Merges default ignore patterns, env var overrides, and caller-provided patterns. */
Set<String> resolveIgnorePatterns(Set<String> callerPatterns) {
Expand All @@ -865,7 +866,8 @@ Set<String> resolveIgnorePatterns(Set<String> callerPatterns) {

/**
* Detects the workspace ecosystem and discovers manifest paths. Checks for Cargo workspace first
* (Cargo.toml + Cargo.lock), then falls back to JS workspace discovery.
* (Cargo.toml + Cargo.lock), then Maven multi-module (pom.xml), then falls back to JS workspace
* discovery.
*/
List<Path> discoverWorkspaceManifests(Path workspaceDir, Set<String> ignorePatterns)
throws IOException {
Expand All @@ -884,6 +886,15 @@ List<Path> discoverWorkspaceManifests(Path workspaceDir, Set<String> ignorePatte
}
}

// Maven multi-module: pom.xml
Path pomXml = workspaceDir.resolve("pom.xml");
if (Files.isRegularFile(pomXml)) {
List<Path> mavenManifests = discoverMavenModules(workspaceDir, ignorePatterns);
if (!mavenManifests.isEmpty()) {
return mavenManifests;
}
}

// JS workspace: require package.json + a lock file
Path packageJson = workspaceDir.resolve("package.json");
boolean hasJsLock =
Expand Down Expand Up @@ -976,6 +987,128 @@ private List<Path> discoverGoWorkspaceModules(Path workspaceDir, Set<String> ign
}
}

/**
* Discovers Maven multi-module workspace manifests by invoking {@code mvn help:evaluate} to list
* declared modules, then recursively checking each module for nested aggregators.
*
* <p>Uses {@link JavaMavenProvider#resolveVerifiedMavenBinary} for Maven binary resolution,
* including wrapper support. Always includes the root {@code pom.xml}. Uses a visited set to
* prevent cycles in the module graph.
*
* @param workspaceDir the root directory containing the aggregator pom.xml
* @param ignorePatterns glob patterns for paths to exclude from results
* @return list of discovered pom.xml paths; always includes the root pom.xml even if Maven is
* unavailable
*/
private List<Path> discoverMavenModules(Path workspaceDir, Set<String> ignorePatterns) {
Path rootPom = workspaceDir.resolve("pom.xml");
String mvnBin = JavaMavenProvider.resolveVerifiedMavenBinary(workspaceDir).orElse(null);
if (mvnBin == null) {
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
LOG.warning("Maven binary not available; returning root pom.xml only");
return List.of(rootPom);
}

var visited = new java.util.HashSet<Path>();
var manifestPaths = new ArrayList<Path>();
manifestPaths.add(rootPom);

collectMavenModules(workspaceDir, mvnBin, visited, manifestPaths);

return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifestPaths, ignorePatterns);
}

/**
* Recursively collects Maven module pom.xml paths by invoking {@code mvn help:evaluate} on each
* directory and descending into sub-modules.
*
* @param dir the directory to evaluate for modules
* @param mvnBin the resolved Maven binary path
* @param visited set of already-visited directories to prevent cycles
* @param manifestPaths accumulator list for discovered pom.xml paths
*/
private void collectMavenModules(
Path dir, String mvnBin, java.util.HashSet<Path> visited, List<Path> manifestPaths) {
Path resolvedDir = dir.toAbsolutePath().normalize();
if (!visited.add(resolvedDir)) {
return;
}

List<String> modules = listMavenModules(resolvedDir, mvnBin);
for (String mod : modules) {
Path moduleDir = resolvedDir.resolve(mod);
Path modulePom = moduleDir.resolve("pom.xml");
if (Files.isRegularFile(modulePom)) {
manifestPaths.add(modulePom);
collectMavenModules(moduleDir, mvnBin, visited, manifestPaths);
}
}
}

/**
* Invokes {@code mvn help:evaluate -Dexpression=project.modules} on the given directory and
* parses the output to extract module names.
*
* @param dir the directory containing a pom.xml to evaluate
* @param mvnBin the resolved Maven binary path
* @return list of module names, or empty list on failure
*/
private List<String> listMavenModules(Path dir, String mvnBin) {
try {
Path pomFile = dir.resolve("pom.xml");
Operations.ProcessExecOutput output =
Operations.runProcessGetFullOutput(
dir,
new String[] {
mvnBin,
"help:evaluate",
"-Dexpression=project.modules",
"-q",
"-DforceStdout",
"-f",
pomFile.toString(),
"--batch-mode"
},
null);
if (output.getExitCode() != 0) {
LOG.warning(
"mvn help:evaluate failed with exit code " + output.getExitCode() + " in " + dir);
return Collections.emptyList();
}
String raw = output.getOutput().trim();
if (raw.isEmpty() || "null".equals(raw)) {
return Collections.emptyList();
}
return parseMavenModuleList(raw);
} catch (Exception e) {
LOG.warning("Failed to list Maven modules in " + dir + ": " + e.getMessage());
return Collections.emptyList();
}
}

/**
* Parses the output of {@code mvn help:evaluate -Dexpression=project.modules} which returns a
* string like {@code [module-a, module-b]}.
*
* @param raw the raw output string
* @return list of module name strings
*/
static List<String> parseMavenModuleList(String raw) {
if (raw == null || raw.isEmpty()) {
return Collections.emptyList();
}
// Expected format: [module-a, module-b, ...]
java.util.regex.Matcher matcher =
java.util.regex.Pattern.compile("^\\[(.+)]$").matcher(raw.trim());
if (!matcher.matches()) {
return Collections.emptyList();
}
String inner = matcher.group(1);
return java.util.Arrays.stream(inner.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.toList();
}

/**
* Checks whether a package.json has "private": true, meaning it should not be analyzed as a
* publishable package.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.logging.Level;
import java.util.Optional;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import javax.xml.stream.XMLInputFactory;
Expand Down Expand Up @@ -431,35 +431,45 @@ private List<String> buildMvnCommandArgs(String... baseArgs) {
}

private String selectMvnRuntime(final Path manifestPath) {
boolean preferWrapper = Operations.getWrapperPreference(MVN);
if (preferWrapper && manifestPath != null) {
String wrapperName = Operations.isWindows() ? "mvnw.cmd" : "mvnw";
String mvnw = traverseForMvnw(wrapperName, manifestPath.toString());
if (mvnw != null) {
try {
// verify maven wrapper is accessible
Operations.runProcess(manifestPath.getParent(), mvnw, ARG_VERSION);
if (debugLoggingIsNeeded()) {
log.info(String.format("using maven wrapper from : %s", mvnw));
}
return mvnw;
} catch (Exception e) {
log.log(
Level.WARNING,
"Failed to check for mvnw due to: {0} Fall back to use mvn",
e.getMessage());
Path dir = (manifestPath != null) ? manifestPath.getParent() : null;
if (dir != null) {
Optional<String> resolved = resolveVerifiedMavenBinary(dir);
if (resolved.isPresent()) {
if (debugLoggingIsNeeded()) {
log.info(String.format("using maven binary: %s", resolved.get()));
}
return resolved.get();
}
}
// If maven wrapper is not requested or not accessible, fall back to use mvn
String mvn = Operations.getExecutable(MVN, ARG_VERSION);
if (debugLoggingIsNeeded()) {
log.info(String.format("using mvn executable from : %s", mvn));
}
return mvn;
}

private String traverseForMvnw(String wrapperName, String startingManifest) {
public static Optional<String> resolveVerifiedMavenBinary(Path workspaceDir) {
if (Operations.getWrapperPreference(MVN)) {
String wrapperName = Operations.isWindows() ? "mvnw.cmd" : "mvnw";
String mvnw = traverseForMvnw(wrapperName, workspaceDir.resolve("pom.xml").toString());
if (mvnw != null) {
try {
Operations.runProcess(workspaceDir, mvnw, ARG_VERSION);
return Optional.of(mvnw);
} catch (Exception e) {
// wrapper found but not functional — fall through to system mvn
}
}
}
try {
String mvn = Operations.getExecutable(MVN, ARG_VERSION);
return Optional.of(mvn);
} catch (RuntimeException e) {
return Optional.empty();
}
}

private static String traverseForMvnw(String wrapperName, String startingManifest) {
return traverseForMvnw(wrapperName, startingManifest, null);
}

Expand Down
Loading
Loading