diff --git a/src/main/java/com/redhat/exhort/providers/JavaMavenProvider.java b/src/main/java/com/redhat/exhort/providers/JavaMavenProvider.java index 860dc9ee..88bb90ae 100644 --- a/src/main/java/com/redhat/exhort/providers/JavaMavenProvider.java +++ b/src/main/java/com/redhat/exhort/providers/JavaMavenProvider.java @@ -30,6 +30,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -53,20 +54,21 @@ public final class JavaMavenProvider extends BaseJavaProvider { private static final String PROP_JAVA_HOME = "JAVA_HOME"; private static final Logger log = LoggersFactory.getLogger(JavaMavenProvider.class.getName()); private final String mvnExecutable; + private static final String MVN = "mvn"; + private static final String ARG_VERSION = "-v"; public JavaMavenProvider(Path manifest) { super(Type.MAVEN, manifest); - // check for custom mvn executable - this.mvnExecutable = Operations.getExecutable("mvn", "-v"); + // check for custom mvn executable mvn or mvn wrapper + this.mvnExecutable = selectMvnRuntime(manifest); } @Override public Content provideStack() throws IOException { - // clean command used to clean build target var mvnCleanCmd = new String[] {mvnExecutable, "clean", "-f", manifest.toString()}; var mvnEnvs = getMvnExecEnvs(); // execute the clean command - Operations.runProcess(mvnCleanCmd, mvnEnvs); + Operations.runProcess(manifest.getParent(), mvnCleanCmd, mvnEnvs); // create a temp file for storing the dependency tree in var tmpFile = Files.createTempFile("exhort_dot_graph_", null); // the tree command will build the project and create the dependency tree in the temp file @@ -90,7 +92,7 @@ public Content provideStack() throws IOException { .map(PackageURL::getCoordinates) .collect(Collectors.toList()); // execute the tree command - Operations.runProcess(mvnTreeCmd.toArray(String[]::new), mvnEnvs); + Operations.runProcess(manifest.getParent(), mvnTreeCmd.toArray(String[]::new), mvnEnvs); if (debugLoggingIsNeeded()) { String stackAnalysisDependencyTree = Files.readString(tmpFile); log.info( @@ -137,7 +139,7 @@ private Content generateSbomFromEffectivePom() throws IOException { manifest.toString() }; // execute the effective pom command - Operations.runProcess(mvnEffPomCmd, getMvnExecEnvs()); + Operations.runProcess(manifest.getParent(), mvnEffPomCmd, getMvnExecEnvs()); if (debugLoggingIsNeeded()) { String CaEffectivePoM = Files.readString(tmpEffPom); log.info( @@ -366,4 +368,68 @@ public int hashCode() { return Objects.hash(groupId, artifactId, version); } } + + private String selectMvnRuntime(final Path manifestPath) { + boolean preferWrapper = Operations.getWrapperPreference(MVN); + if (preferWrapper) { + String wrapperName = isWindows() ? "mvnw.cmd" : "mvnw"; + String mvnw = traverseForMvnw(wrapperName, manifestPath.toString()); + if (mvnw != null) { + try { + // verify maven wrapper is accessible + Operations.runProcess(manifest.getParent(), mvnw, ARG_VERSION); + log.fine("using maven wrapper from : " + mvnw); + return mvnw; + } catch (Exception e) { + log.warning( + "Failed to check for mvnw due to: " + e.getMessage() + " Fall back to use mvn"); + } + } + } + // If maven wrapper is not requested or not accessible, fall back to use mvn + String mvn = Operations.getExecutable(MVN, ARG_VERSION); + log.fine("using mvn executable from : " + mvn); + return mvn; + } + + private String traverseForMvnw(String wrapperName, String startingManifest) { + return traverseForMvnw(wrapperName, startingManifest, null); + } + + public static String traverseForMvnw( + String wrapperName, String startingManifest, String repoRoot) { + String normalizedManifest = normalizePath(startingManifest); + + Path path = Paths.get(normalizedManifest); + if (repoRoot == null) { + repoRoot = + Operations.getGitRootDir(path.getParent().toString()) + .orElse(path.toAbsolutePath().getRoot().toString()); + } + + Path wrapperPath = path.getParent().resolve(wrapperName).toAbsolutePath(); + + if (Files.isExecutable(wrapperPath)) { + return wrapperPath.toString(); + } else { + String currentDir = path.getParent().toAbsolutePath().toString(); + if (currentDir.equals(repoRoot)) { + return null; + } + return traverseForMvnw(wrapperName, path.getParent().toString(), repoRoot); + } + } + + public static String normalizePath(String thePath) { + Path normalized = Paths.get(thePath).toAbsolutePath().normalize(); + String result = normalized.toString(); + if (isWindows()) { + result = result.toLowerCase(); + } + return result; + } + + private static boolean isWindows() { + return System.getProperty("os.name").toLowerCase().contains("win"); + } } diff --git a/src/main/java/com/redhat/exhort/tools/Operations.java b/src/main/java/com/redhat/exhort/tools/Operations.java index f92d3735..f8dfeb6c 100644 --- a/src/main/java/com/redhat/exhort/tools/Operations.java +++ b/src/main/java/com/redhat/exhort/tools/Operations.java @@ -17,17 +17,26 @@ import static java.lang.String.join; +import com.redhat.exhort.logging.LoggersFactory; import com.redhat.exhort.utils.Environment; import java.io.BufferedReader; +import java.io.File; import java.io.IOException; import java.io.InputStreamReader; import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.TimeUnit; +import java.util.logging.Logger; import java.util.stream.Collectors; /** Utility class used for executing process on the operating system. * */ public final class Operations { + + private static final Logger log = LoggersFactory.getLogger(Operations.class.getName()); + private Operations() { // constructor not required for a utility class } @@ -56,12 +65,24 @@ public static String getCustomPathOrElse(String defaultExecutable) { * @param cmdList list of command parts */ public static void runProcess(final String... cmdList) { - runProcess(cmdList, null); + runProcess(null, cmdList, null); + } + + public static void runProcess(final Path workingDirectory, final String... cmdList) { + runProcess(workingDirectory, cmdList, null); } public static void runProcess(final String[] cmdList, final Map envMap) { + runProcess(null, cmdList, null); + } + + public static void runProcess( + final Path workingDirectory, final String[] cmdList, final Map envMap) { var processBuilder = new ProcessBuilder(); processBuilder.command(cmdList); + if (workingDirectory != null) { + processBuilder.directory(workingDirectory.toFile()); + } if (envMap != null) { processBuilder.environment().putAll(envMap); } @@ -276,4 +297,56 @@ public static String getExecutable( public static String getExecutable(String command, String args) { return getExecutable(command, args, null); } + + /** + * Checks whether a wrapper preference is set for a given tool name. + * + *

This method looks for an environment variable with the name {@code + * EXHORT_PREFER_W}, where {@code } is the uppercase form of the provided + * {@code name} parameter. If the environment variable is present (i.e., not {@code null}), the + * method returns {@code true}. + * + * @param name the tool name for which to check the wrapper preference (e.g., "maven", "gradle") + * @return {@code true} if the corresponding environment variable is set; {@code false} otherwise + */ + public static boolean getWrapperPreference(String name) { + return Environment.get("EXHORT_PREFER_" + name.toUpperCase() + "W") != null; + } + + /** + * Attempts to retrieve the root directory of a Git repository for the given working directory. + * + *

This method runs the command {@code git rev-parse --show-toplevel} in the specified + * directory, which returns the absolute path to the root of the current Git working tree. If the + * command executes successfully and produces output, the trimmed result is returned as an {@link + * Optional}. If the command fails or the output is empty, an empty {@code Optional} is returned. + * + * @param cwd the working directory in which to execute the Git command + * @return an {@code Optional} containing the Git root directory path if found, otherwise {@code + * Optional.empty()} + */ + public static Optional getGitRootDir(String cwd) { + List command = Arrays.asList("git", "rev-parse", "--show-toplevel"); + ProcessBuilder builder = + new ProcessBuilder(command).directory(new File(cwd)).redirectErrorStream(true); + try { + Process process = builder.start(); + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line = reader.readLine(); + int exitCode = process.waitFor(); + if (exitCode == 0) { + return Optional.ofNullable(line).map(String::trim); + } else { + log.warning("Git command exited with code " + exitCode + " in directory " + cwd); + } + } + } catch (IOException e) { + log.warning("I/O error executing git in " + cwd + ": " + e.getMessage()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warning("Git command interrupted in directory " + cwd + ": " + e.getMessage()); + } + return Optional.empty(); + } } diff --git a/src/test/java/com/redhat/exhort/impl/ExhortApiIT.java b/src/test/java/com/redhat/exhort/impl/ExhortApiIT.java index 20de47d6..a1526b43 100644 --- a/src/test/java/com/redhat/exhort/impl/ExhortApiIT.java +++ b/src/test/java/com/redhat/exhort/impl/ExhortApiIT.java @@ -362,7 +362,7 @@ private void mockMavenDependencyTree(Ecosystem.Type packageManager) throws IOExc depTree = new String(is.readAllBytes()); } mockedOperations - .when(() -> Operations.runProcess(any(), any())) + .when(() -> Operations.runProcess(any(), any(), any())) .thenAnswer( invocationOnMock -> getOutputFileAndOverwriteItWithMock(depTree, invocationOnMock, "-DoutputFile")); @@ -378,7 +378,7 @@ private void mockMavenDependencyTree(Ecosystem.Type packageManager) throws IOExc public static String getOutputFileAndOverwriteItWithMock( String outputFileContent, InvocationOnMock invocationOnMock, String parameterPrefix) throws IOException { - String[] rawArguments = (String[]) invocationOnMock.getRawArguments()[0]; + String[] rawArguments = (String[]) invocationOnMock.getRawArguments()[1]; Optional outputFileArg = Arrays.stream(rawArguments) .filter(arg -> arg != null && arg.startsWith(parameterPrefix)) diff --git a/src/test/java/com/redhat/exhort/providers/Java_Maven_Provider_Test.java b/src/test/java/com/redhat/exhort/providers/Java_Maven_Provider_Test.java index 27e6ce11..16d6207f 100644 --- a/src/test/java/com/redhat/exhort/providers/Java_Maven_Provider_Test.java +++ b/src/test/java/com/redhat/exhort/providers/Java_Maven_Provider_Test.java @@ -84,7 +84,7 @@ void test_the_provideStack(String testFolder) throws IOException { } try (MockedStatic mockedOperations = mockStatic(Operations.class)) { mockedOperations - .when(() -> Operations.runProcess(any(), any())) + .when(() -> Operations.runProcess(any(), any(), any())) .thenAnswer( invocationOnMock -> getOutputFileAndOverwriteItWithMock(depTree, invocationOnMock, "-DoutputFile")); @@ -107,7 +107,7 @@ void test_the_provideStack(String testFolder) throws IOException { public static String getOutputFileAndOverwriteItWithMock( String outputFileContent, InvocationOnMock invocationOnMock, String parameterPrefix) throws IOException { - String[] rawArguments = (String[]) invocationOnMock.getRawArguments()[0]; + String[] rawArguments = (String[]) invocationOnMock.getRawArguments()[1]; Optional outputFileArg = Arrays.stream(rawArguments) .filter(arg -> arg != null && arg.startsWith(parameterPrefix)) @@ -144,7 +144,7 @@ void test_the_provideComponent(String testFolder) throws IOException { } try (MockedStatic mockedOperations = mockStatic(Operations.class)) { mockedOperations - .when(() -> Operations.runProcess(any(), any())) + .when(() -> Operations.runProcess(any(), any(), any())) .thenAnswer( invocationOnMock -> getOutputFileAndOverwriteItWithMock(effectivePom, invocationOnMock, "-Doutput")); @@ -190,7 +190,7 @@ void test_the_provideComponent_With_Path(String testFolder) throws IOException { } try (MockedStatic mockedOperations = mockStatic(Operations.class)) { mockedOperations - .when(() -> Operations.runProcess(any(), any())) + .when(() -> Operations.runProcess(any(), any(), any())) .thenAnswer( invocationOnMock -> getOutputFileAndOverwriteItWithMock(effectivePom, invocationOnMock, "-Doutput"));