From 67aba1b7f687db2ca319ce129c2843bf077b1f57 Mon Sep 17 00:00:00 2001 From: Christian Wegener Date: Fri, 27 Mar 2026 13:32:54 +0100 Subject: [PATCH 1/2] Fix: resolve relative classpath elements against the fork's working directory When Surefire uses a manifest-only JAR to pass the classpath (default on Windows and for long classpaths), Class-Path entries in the manifest are relative to the manifest JAR's parent directory - a temp directory, not the fork's working directory. A relative additionalClasspathElement (e.g. ../resources) was written as-is into the manifest, where the JVM resolved it against the temp directory instead of the fork's workingDirectory, silently pointing at the wrong location. Fix: JarManifestForkConfiguration now resolves relative classpath elements against the fork's working directory before computing their manifest-relative representation. All resolveClasspath() implementations receive the resolved working directory as a new parameter. In direct classpath mode (ClasspathForkConfiguration) no change in behavior is needed as the JVM already resolves -cp entries against its own CWD. Adds unit test (ForkConfigurationTest) and integration test (AdditionalClasspathForkIT / additional-classpath-relative-workdir) to guard against regression. --- .../ClasspathForkConfiguration.java | 5 +- .../DefaultForkConfiguration.java | 6 +- .../JarManifestForkConfiguration.java | 23 ++++-- .../ModularClasspathForkConfiguration.java | 1 + .../DefaultForkConfigurationTest.java | 9 ++ .../booterclient/ForkConfigurationTest.java | 82 +++++++++++++++++++ ...ModularClasspathForkConfigurationTest.java | 1 + .../its/AdditionalClasspathForkIT.java | 39 +++++++++ .../cp-extra/relative-cp-marker.txt | 1 + .../pom.xml | 67 +++++++++++++++ .../RelativeClasspathTest.java | 24 ++++++ 11 files changed, 250 insertions(+), 8 deletions(-) create mode 100644 surefire-its/src/test/java/org/apache/maven/surefire/its/AdditionalClasspathForkIT.java create mode 100644 surefire-its/src/test/resources/additional-classpath-relative-workdir/cp-extra/relative-cp-marker.txt create mode 100644 surefire-its/src/test/resources/additional-classpath-relative-workdir/pom.xml create mode 100644 surefire-its/src/test/resources/additional-classpath-relative-workdir/src/test/java/relativeClasspath/RelativeClasspathTest.java diff --git a/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/booterclient/ClasspathForkConfiguration.java b/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/booterclient/ClasspathForkConfiguration.java index 16b4800dae..a9100354b4 100644 --- a/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/booterclient/ClasspathForkConfiguration.java +++ b/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/booterclient/ClasspathForkConfiguration.java @@ -22,6 +22,7 @@ import javax.annotation.Nullable; import java.io.File; +import java.util.List; import java.util.Map; import java.util.Properties; @@ -77,9 +78,11 @@ protected void resolveClasspath( @Nonnull Commandline cli, @Nonnull String booterThatHasMainMethod, @Nonnull StartupConfiguration config, + @Nonnull File workingDirectory, @Nonnull File dumpLogDirectory) throws SurefireBooterForkException { - cli.addEnvironment("CLASSPATH", join(toCompleteClasspath(config).iterator(), File.pathSeparator)); + List classpath = toCompleteClasspath(config); + cli.addEnvironment("CLASSPATH", join(classpath.iterator(), File.pathSeparator)); cli.createArg().setValue(booterThatHasMainMethod); } } diff --git a/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/booterclient/DefaultForkConfiguration.java b/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/booterclient/DefaultForkConfiguration.java index a8517504b0..f0b2132ec4 100644 --- a/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/booterclient/DefaultForkConfiguration.java +++ b/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/booterclient/DefaultForkConfiguration.java @@ -124,6 +124,7 @@ protected abstract void resolveClasspath( @Nonnull Commandline cli, @Nonnull String booterThatHasMainMethod, @Nonnull StartupConfiguration config, + @Nonnull File workingDirectory, @Nonnull File dumpLogDirectory) throws SurefireBooterForkException; @@ -153,7 +154,8 @@ public Commandline createCommandLine( try { Commandline cli = new Commandline(getExcludedEnvironmentVariables()); - cli.setWorkingDirectory(getWorkingDirectory(forkNumber).getAbsolutePath()); + File cwd = getWorkingDirectory(forkNumber); + cli.setWorkingDirectory(cwd.getAbsolutePath()); for (Entry entry : getEnvironmentVariables().entrySet()) { String value = entry.getValue(); @@ -174,7 +176,7 @@ public Commandline createCommandLine( cli.createArg().setLine(getDebugLine()); } - resolveClasspath(cli, findStartClass(config), config, dumpLogDirectory); + resolveClasspath(cli, findStartClass(config), config, cwd, dumpLogDirectory); return cli; } catch (CommandLineException e) { diff --git a/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/booterclient/JarManifestForkConfiguration.java b/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/booterclient/JarManifestForkConfiguration.java index 4bd12a3630..dab81d75c5 100644 --- a/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/booterclient/JarManifestForkConfiguration.java +++ b/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/booterclient/JarManifestForkConfiguration.java @@ -101,10 +101,12 @@ protected void resolveClasspath( @Nonnull Commandline cli, @Nonnull String booterThatHasMainMethod, @Nonnull StartupConfiguration config, + @Nonnull File workingDirectory, @Nonnull File dumpLogDirectory) throws SurefireBooterForkException { try { - File jar = createJar(toCompleteClasspath(config), booterThatHasMainMethod, dumpLogDirectory); + List classpath = toCompleteClasspath(config); + File jar = createJar(classpath, booterThatHasMainMethod, workingDirectory, dumpLogDirectory); cli.createArg().setValue("-jar"); cli.createArg().setValue(escapeToPlatformPath(jar.getAbsolutePath())); } catch (IOException e) { @@ -116,20 +118,25 @@ protected void resolveClasspath( * Create a jar with just a manifest containing a Main-Class entry for BooterConfiguration and a Class-Path entry * for all classpath elements. * - * @param classPath List<String> of all classpath elements - * @param startClassName the class name to start (main-class) + * @param classPath List<String> of all classpath elements + * @param startClassName the class name to start (main-class) + * @param workingDirectory the fork's working directory; relative classpath elements are resolved against it * @return file of the jar * @throws IOException when a file operation fails */ @Nonnull private File createJar( - @Nonnull List classPath, @Nonnull String startClassName, @Nonnull File dumpLogDirectory) + @Nonnull List classPath, + @Nonnull String startClassName, + @Nonnull File workingDirectory, + @Nonnull File dumpLogDirectory) throws IOException { File file = TempFileManager.instance(getTempDirectory()).createTempFile("surefirebooter", ".jar"); if (!isDebug()) { file.deleteOnExit(); } Path parent = file.getParentFile().toPath(); + Path workingDirectoryPath = workingDirectory.toPath().toAbsolutePath().normalize(); OutputStream fos = new BufferedOutputStream(new FileOutputStream(file), 64 * 1024); try (ZipArchiveOutputStream zos = new ZipArchiveOutputStream(fos)) { @@ -147,7 +154,13 @@ private File createJar( // the end of directory entries - otherwise the jvm will ignore them. StringBuilder cp = new StringBuilder(); for (Iterator it = classPath.iterator(); it.hasNext(); ) { - Path classPathElement = Paths.get(it.next()); + Path rawElement = Paths.get(it.next()); + // Relative classpath elements are resolved against the fork's working directory so that + // the resulting manifest-JAR entry resolves to the same location as a direct -cp argument + // would (where the JVM resolves relative entries against its working directory). + Path classPathElement = rawElement.isAbsolute() + ? rawElement + : workingDirectoryPath.resolve(rawElement).normalize(); ClasspathElementUri classpathElementUri = toClasspathElementUri(parent, classPathElement, dumpLogDirectory, dumpError); // too many errors in dump file with the same root cause may slow down the Boot Manifest-JAR startup diff --git a/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/booterclient/ModularClasspathForkConfiguration.java b/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/booterclient/ModularClasspathForkConfiguration.java index 5a25761176..ab85aca420 100644 --- a/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/booterclient/ModularClasspathForkConfiguration.java +++ b/maven-surefire-common/src/main/java/org/apache/maven/plugin/surefire/booterclient/ModularClasspathForkConfiguration.java @@ -91,6 +91,7 @@ protected void resolveClasspath( @Nonnull Commandline cli, @Nonnull String startClass, @Nonnull StartupConfiguration config, + @Nonnull File workingDirectory, @Nonnull File dumpLogDirectory) throws SurefireBooterForkException { try { diff --git a/maven-surefire-common/src/test/java/org/apache/maven/plugin/surefire/booterclient/DefaultForkConfigurationTest.java b/maven-surefire-common/src/test/java/org/apache/maven/plugin/surefire/booterclient/DefaultForkConfigurationTest.java index 8926c36383..f0956ebc5e 100644 --- a/maven-surefire-common/src/test/java/org/apache/maven/plugin/surefire/booterclient/DefaultForkConfigurationTest.java +++ b/maven-surefire-common/src/test/java/org/apache/maven/plugin/surefire/booterclient/DefaultForkConfigurationTest.java @@ -112,6 +112,7 @@ protected void resolveClasspath( @Nonnull Commandline cli, @Nonnull String booterThatHasMainMethod, @Nonnull StartupConfiguration config, + @Nonnull File workingDirectory, @Nonnull File dumpLogDirectory) {} }; @@ -144,6 +145,7 @@ protected void resolveClasspath( @Nonnull Commandline cli, @Nonnull String booterThatHasMainMethod, @Nonnull StartupConfiguration config, + @Nonnull File workingDirectory, @Nonnull File dumpLogDirectory) {} }; @@ -176,6 +178,7 @@ protected void resolveClasspath( @Nonnull Commandline cli, @Nonnull String booterThatHasMainMethod, @Nonnull StartupConfiguration config, + @Nonnull File workingDirectory, @Nonnull File dumpLogDirectory) {} }; @@ -208,6 +211,7 @@ protected void resolveClasspath( @Nonnull Commandline cli, @Nonnull String booterThatHasMainMethod, @Nonnull StartupConfiguration config, + @Nonnull File workingDirectory, @Nonnull File dumpLogDirectory) {} }; @@ -241,6 +245,7 @@ protected void resolveClasspath( @Nonnull Commandline cli, @Nonnull String booterThatHasMainMethod, @Nonnull StartupConfiguration config, + @Nonnull File workingDirectory, @Nonnull File dumpLogDirectory) {} }; @@ -273,6 +278,7 @@ protected void resolveClasspath( @Nonnull Commandline cli, @Nonnull String booterThatHasMainMethod, @Nonnull StartupConfiguration config, + @Nonnull File workingDirectory, @Nonnull File dumpLogDirectory) {} }; @@ -305,6 +311,7 @@ protected void resolveClasspath( @Nonnull Commandline cli, @Nonnull String booterThatHasMainMethod, @Nonnull StartupConfiguration config, + @Nonnull File workingDirectory, @Nonnull File dumpLogDirectory) {} }; @@ -337,6 +344,7 @@ protected void resolveClasspath( @Nonnull Commandline cli, @Nonnull String booterThatHasMainMethod, @Nonnull StartupConfiguration config, + @Nonnull File workingDirectory, @Nonnull File dumpLogDirectory) {} }; @@ -407,4 +415,5 @@ private static T invokeMethod(Object target, String methodName, Class[] p } throw new NoSuchMethodException(methodName); } + } diff --git a/maven-surefire-common/src/test/java/org/apache/maven/plugin/surefire/booterclient/ForkConfigurationTest.java b/maven-surefire-common/src/test/java/org/apache/maven/plugin/surefire/booterclient/ForkConfigurationTest.java index b61cf9c18e..fbd6025195 100644 --- a/maven-surefire-common/src/test/java/org/apache/maven/plugin/surefire/booterclient/ForkConfigurationTest.java +++ b/maven-surefire-common/src/test/java/org/apache/maven/plugin/surefire/booterclient/ForkConfigurationTest.java @@ -30,6 +30,8 @@ import java.util.List; import java.util.Map; import java.util.Properties; +import java.util.jar.JarFile; +import java.util.jar.Manifest; import org.apache.commons.io.FileUtils; import org.apache.maven.plugin.surefire.JdkAttributes; @@ -123,6 +125,7 @@ protected void resolveClasspath( @Nonnull Commandline cli, @Nonnull String booterThatHasMainMethod, @Nonnull StartupConfiguration config, + @Nonnull File workingDirectory, @Nonnull File dumpLogDirectory) {} }; @@ -177,6 +180,7 @@ protected void resolveClasspath( @Nonnull Commandline cli, @Nonnull String booterThatHasMainMethod, @Nonnull StartupConfiguration config, + @Nonnull File workingDirectory, @Nonnull File dumpLogDirectory) {} }; @@ -287,6 +291,7 @@ protected void resolveClasspath( @Nonnull Commandline cli, @Nonnull String booterThatHasMainMethod, @Nonnull StartupConfiguration config, + @Nonnull File workingDirectory, @Nonnull File dumpLogDirectory) {} }; @@ -462,6 +467,83 @@ private static ForkConfiguration getForkConfiguration(File basedir, String argLi mock(ForkNodeFactory.class)); } + /** + * Verifies that a relative {@code additionalClasspathElement} (e.g. {@code ../classes}, which is + * correct relative to the fork's {@code workingDirectory}) ends up at the right absolute location + * inside the manifest JAR's {@code Class-Path}, regardless of where the manifest JAR itself is + * stored. + * + *

Without the fix, Surefire wrote the raw relative token {@code ../classes} into the manifest, + * where the JVM resolved it against the manifest-JAR directory rather than against the fork's + * working directory – silently pointing at the wrong location. + */ + @Test + public void testRelativeClasspathElementResolvedAgainstWorkingDirectory() + throws IOException, SurefireBooterForkException { + // Layout: + // basedir/build-test-dir-1/bin/ <- workingDirectory + // basedir/build-test-dir-1/classes/ <- the directory we want on the classpath + File forkDir = new File(basedir, "build-test-dir-1"); + File workingDir = new File(forkDir, "bin"); + File classesDir = new File(forkDir, "classes"); + assertTrue(workingDir.mkdirs()); + assertTrue(classesDir.mkdirs()); + + // Relative element as a user would write in pom.xml ../classes + // The JVM resolves -cp entries against its working directory, so ../classes from bin/ == classes/. + File cpElement = classesDir; + List cp = singletonList(cpElement.getAbsolutePath()); + ClasspathConfiguration cpConfig = + new ClasspathConfiguration(new Classpath(cp), emptyClasspath(), emptyClasspath(), true, true); + ClassLoaderConfiguration clc = new ClassLoaderConfiguration(true, true); + StartupConfiguration startup = + new StartupConfiguration("", cpConfig, clc, ALL, Collections.emptyList()); + + ForkConfiguration config = getForkConfiguration(workingDir.getCanonicalFile()); + org.apache.maven.surefire.shared.utils.cli.Commandline cli = + config.createCommandLine(startup, 1, getTempDirectory()); + + // The command line must use -jar (manifest-only JAR mode) + String line = join(" ", cli.getCommandline()); + assertThat(line).contains("-jar"); + + // Extract the path of the manifest JAR from the command line + String[] parts = cli.getCommandline(); + String jarPath = null; + for (int i = 0; i < parts.length - 1; i++) { + if ("-jar".equals(parts[i])) { + jarPath = parts[i + 1]; + break; + } + } + assertThat(jarPath).isNotNull(); + + // Read the Class-Path from the manifest + try (JarFile jar = new JarFile(new File(jarPath))) { + Manifest manifest = jar.getManifest(); + String classPath = manifest.getMainAttributes().getValue("Class-Path"); + assertThat(classPath).isNotNull(); + + // The Class-Path entry for classesDir must, when resolved against the manifest JAR's + // parent directory, yield the canonical path of classesDir. + File manifestJar = new File(jarPath); + for (String entry : classPath.split(" ")) { + if (entry.isEmpty()) { + continue; + } + // entries are URI-encoded; decode and resolve against manifest-jar parent + String decoded = java.net.URLDecoder.decode(entry.replace("+", "%2B"), "UTF-8"); + File resolved = new File(manifestJar.getParentFile(), decoded.replace('/', File.separatorChar)); + if (resolved.getCanonicalPath().equals(classesDir.getCanonicalPath()) + || resolved.getCanonicalPath().equals(classesDir.getCanonicalPath() + File.separator)) { + return; // found – test passes + } + } + fail("Class-Path in manifest JAR does not resolve to " + classesDir.getCanonicalPath() + + "; actual Class-Path: " + classPath); + } + } + // based on http://stackoverflow.com/questions/2591083/getting-version-of-java-in-runtime @SuppressWarnings("checkstyle:magicnumber") private static boolean isJavaVersionAtLeast7u60() { diff --git a/maven-surefire-common/src/test/java/org/apache/maven/plugin/surefire/booterclient/ModularClasspathForkConfigurationTest.java b/maven-surefire-common/src/test/java/org/apache/maven/plugin/surefire/booterclient/ModularClasspathForkConfigurationTest.java index 978078eca9..d3fe5d32a6 100644 --- a/maven-surefire-common/src/test/java/org/apache/maven/plugin/surefire/booterclient/ModularClasspathForkConfigurationTest.java +++ b/maven-surefire-common/src/test/java/org/apache/maven/plugin/surefire/booterclient/ModularClasspathForkConfigurationTest.java @@ -150,6 +150,7 @@ public void shouldCreateModularArgsFile() throws Exception { cli, ForkedBooter.class.getName(), startupConfiguration, + pwd, SureFireFileManager.createTempFile("surefire", "surefire-reports")); assertThat(cli.getArguments()).isNotNull(); diff --git a/surefire-its/src/test/java/org/apache/maven/surefire/its/AdditionalClasspathForkIT.java b/surefire-its/src/test/java/org/apache/maven/surefire/its/AdditionalClasspathForkIT.java new file mode 100644 index 0000000000..40859b2e81 --- /dev/null +++ b/surefire-its/src/test/java/org/apache/maven/surefire/its/AdditionalClasspathForkIT.java @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.surefire.its; + +import org.apache.maven.surefire.its.fixture.SurefireJUnit4IntegrationTestCase; +import org.junit.Test; + +/** + * Integration test for relative {@code additionalClasspathElement} entries resolved against + * the fork's working directory (regression guard for the manifest-JAR path-resolution bug). + */ +public class AdditionalClasspathForkIT extends SurefireJUnit4IntegrationTestCase { + + /** + * Verifies that a relative {@code additionalClasspathElement} (e.g. {@code ../cp-extra}) is + * resolved against the fork's {@code workingDirectory}, not against the Maven base directory + * or the location of the manifest-only JAR. + */ + @Test + public void relativeClasspathElementResolvedAgainstWorkingDirectory() { + unpack("/additional-classpath-relative-workdir").executeTest().verifyErrorFree(1); + } +} diff --git a/surefire-its/src/test/resources/additional-classpath-relative-workdir/cp-extra/relative-cp-marker.txt b/surefire-its/src/test/resources/additional-classpath-relative-workdir/cp-extra/relative-cp-marker.txt new file mode 100644 index 0000000000..07c581a66b --- /dev/null +++ b/surefire-its/src/test/resources/additional-classpath-relative-workdir/cp-extra/relative-cp-marker.txt @@ -0,0 +1 @@ +found-via-relative-cp diff --git a/surefire-its/src/test/resources/additional-classpath-relative-workdir/pom.xml b/surefire-its/src/test/resources/additional-classpath-relative-workdir/pom.xml new file mode 100644 index 0000000000..c0dc7614e5 --- /dev/null +++ b/surefire-its/src/test/resources/additional-classpath-relative-workdir/pom.xml @@ -0,0 +1,67 @@ + + + + 4.0.0 + + + org.apache.maven.surefire + it-parent + 1.0 + ../pom.xml + + + org.apache.maven.plugins.surefire + additional-classpath-relative-workdir + 1.0-SNAPSHOT + Test for relative additionalClasspathElement resolved against fork workingDirectory + + + + junit + junit + 4.13.2 + test + + + + + + + maven-surefire-plugin + + + ${project.basedir}/fork-wd + 1 + + ../cp-extra + + + + + + + diff --git a/surefire-its/src/test/resources/additional-classpath-relative-workdir/src/test/java/relativeClasspath/RelativeClasspathTest.java b/surefire-its/src/test/resources/additional-classpath-relative-workdir/src/test/java/relativeClasspath/RelativeClasspathTest.java new file mode 100644 index 0000000000..8e3fe915b0 --- /dev/null +++ b/surefire-its/src/test/resources/additional-classpath-relative-workdir/src/test/java/relativeClasspath/RelativeClasspathTest.java @@ -0,0 +1,24 @@ +package relativeClasspath; + +import org.junit.Test; + +import static org.junit.Assert.assertNotNull; + +/** + * Verifies that a resource placed in a directory referenced via a relative + * {@code additionalClasspathElement} is accessible at test runtime. + * + * The pom.xml sets {@code workingDirectory} to {@code ${project.basedir}/fork-wd} and + * {@code additionalClasspathElement} to {@code ../cp-extra}. The element is relative and + * must be resolved against the fork's working directory, not against the manifest-JAR + * location, so that it ends up pointing at {@code ${project.basedir}/cp-extra}. + */ +public class RelativeClasspathTest { + + @Test + public void relativeClasspathElementMustBeAccessible() { + assertNotNull( + "relative-cp-marker.txt must be loadable from the relative additionalClasspathElement", + getClass().getClassLoader().getResourceAsStream("relative-cp-marker.txt")); + } +} From 7f40092b352a1fae2851a1f2d6a56df1d5125749 Mon Sep 17 00:00:00 2001 From: Christian Wegener Date: Fri, 27 Mar 2026 15:29:24 +0100 Subject: [PATCH 2/2] Fix import (JUnit 5) --- .../apache/maven/surefire/its/AdditionalClasspathForkIT.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/surefire-its/src/test/java/org/apache/maven/surefire/its/AdditionalClasspathForkIT.java b/surefire-its/src/test/java/org/apache/maven/surefire/its/AdditionalClasspathForkIT.java index 40859b2e81..4c56c5f610 100644 --- a/surefire-its/src/test/java/org/apache/maven/surefire/its/AdditionalClasspathForkIT.java +++ b/surefire-its/src/test/java/org/apache/maven/surefire/its/AdditionalClasspathForkIT.java @@ -19,7 +19,7 @@ package org.apache.maven.surefire.its; import org.apache.maven.surefire.its.fixture.SurefireJUnit4IntegrationTestCase; -import org.junit.Test; +import org.junit.jupiter.api.Test; /** * Integration test for relative {@code additionalClasspathElement} entries resolved against