diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractAotMojo.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractAotMojo.java index 8c6c9599ae3d..1158a2d74861 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractAotMojo.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractAotMojo.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -46,8 +46,6 @@ import org.apache.maven.shared.artifact.filter.collection.ArtifactsFilter; import org.apache.maven.toolchain.ToolchainManager; -import org.springframework.boot.maven.CommandLineBuilder.ClasspathBuilder; - /** * Abstract base class for AOT processing MOJOs. * @@ -149,7 +147,7 @@ protected final void compileSourceFiles(URL[] classPath, File sourcesDirectory, JavaCompilerPluginConfiguration compilerConfiguration = new JavaCompilerPluginConfiguration(this.project); List options = new ArrayList<>(); options.add("-cp"); - options.add(ClasspathBuilder.build(Arrays.asList(classPath))); + options.add(ClasspathBuilder.build(classPath)); options.add("-d"); options.add(outputDirectory.toPath().toAbsolutePath().toString()); String releaseVersion = compilerConfiguration.getReleaseVersion(); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractRunMojo.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractRunMojo.java index 71eb7569f5c8..c0bdcf7da1cd 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractRunMojo.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractRunMojo.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,15 +20,10 @@ import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; -import java.nio.charset.Charset; -import java.nio.charset.UnsupportedCharsetException; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -46,7 +41,6 @@ import org.springframework.boot.loader.tools.FileUtils; import org.springframework.util.Assert; import org.springframework.util.ObjectUtils; -import org.springframework.util.StringUtils; /** * Base class to run a Spring Boot application. @@ -351,45 +345,18 @@ private void addActiveProfileArgument(RunArguments arguments) { private void addClasspath(List args) throws MojoExecutionException { try { - StringBuilder classpath = new StringBuilder(); - for (URL ele : getClassPathUrls()) { - if (!classpath.isEmpty()) { - classpath.append(File.pathSeparator); - } - classpath.append(new File(ele.toURI())); - } + String classpath = ClasspathBuilder.build(getClassPathUrls()); if (getLog().isDebugEnabled()) { getLog().debug("Classpath for forked process: " + classpath); } args.add("-cp"); - if (needsClasspathArgFile()) { - args.add("@" + ArgFile.create(classpath).path()); - } - else { - args.add(classpath.toString()); - } + args.add(classpath); } catch (Exception ex) { throw new MojoExecutionException("Could not build classpath", ex); } } - private boolean needsClasspathArgFile() { - // Windows limits the maximum command length, so we use an argfile there - return runsOnWindows(); - } - - private boolean runsOnWindows() { - String os = System.getProperty("os.name"); - if (!StringUtils.hasLength(os)) { - if (getLog().isWarnEnabled()) { - getLog().warn("System property os.name is not set"); - } - return false; - } - return os.toLowerCase(Locale.ROOT).contains("win"); - } - protected URL[] getClassPathUrls() throws MojoExecutionException { try { List urls = new ArrayList<>(); @@ -468,37 +435,4 @@ static String format(String key, String value) { } - record ArgFile(Path path) { - - private void write(CharSequence content) throws IOException { - Files.writeString(this.path, "\"" + escape(content) + "\"", getCharset()); - } - - private Charset getCharset() { - String nativeEncoding = System.getProperty("native.encoding"); - if (nativeEncoding == null) { - return Charset.defaultCharset(); - } - try { - return Charset.forName(nativeEncoding); - } - catch (UnsupportedCharsetException ex) { - return Charset.defaultCharset(); - } - } - - private String escape(CharSequence content) { - return content.toString().replace("\\", "\\\\"); - } - - static ArgFile create(CharSequence content) throws IOException { - Path tempFile = Files.createTempFile("spring-boot-", ".argfile"); - tempFile.toFile().deleteOnExit(); - ArgFile argFile = new ArgFile(tempFile); - argFile.write(content); - return argFile; - } - - } - } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/ArgFile.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/ArgFile.java new file mode 100644 index 000000000000..c262fda002b4 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/ArgFile.java @@ -0,0 +1,81 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.maven; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.UnsupportedCharsetException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * + * Utility class that represents `argument` as a file. Mostly used to avoid `Path too long + * on ...` on Windows. + * + * @author Moritz Halbritter + * @author Dmytro Nosan + */ +final class ArgFile { + + private final Path path; + + private ArgFile(Path path) { + this.path = path.toAbsolutePath(); + } + + /** + * Creates a new {@code ArgFile} with the given content. + * @param content the content to write to the argument file + * @return a new {@code ArgFile} + * @throws IOException if an I/O error occurs + */ + static ArgFile create(CharSequence content) throws IOException { + Path tempFile = Files.createTempFile("spring-boot-", ".argfile"); + tempFile.toFile().deleteOnExit(); + ArgFile argFile = new ArgFile(tempFile); + argFile.write(content); + return argFile; + } + + private void write(CharSequence content) throws IOException { + Files.writeString(this.path, "\"" + escape(content) + "\"", getCharset()); + } + + private Charset getCharset() { + String nativeEncoding = System.getProperty("native.encoding"); + if (nativeEncoding == null) { + return Charset.defaultCharset(); + } + try { + return Charset.forName(nativeEncoding); + } + catch (UnsupportedCharsetException ex) { + return Charset.defaultCharset(); + } + } + + private String escape(CharSequence content) { + return content.toString().replace("\\", "\\\\"); + } + + @Override + public String toString() { + return this.path.toString(); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/ClasspathBuilder.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/ClasspathBuilder.java new file mode 100644 index 000000000000..a4ad501998c0 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/ClasspathBuilder.java @@ -0,0 +1,89 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.maven; + +import java.io.File; +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; +import java.util.Locale; + +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; + +/** + * Helper class to build the -cp (classpath) argument of a java process. + * + * @author Stephane Nicoll + * @author Dmytro Nosan + */ +final class ClasspathBuilder { + + private ClasspathBuilder() { + } + + /** + * Builds a classpath string or an argument file representing the classpath, depending + * on the operating system. + * @param urls an array of {@link URL} representing the elements of the classpath + * @return the classpath; on Windows, the path to an argument file is returned, + * prefixed with '@' + */ + static String build(URL... urls) { + if (ObjectUtils.isEmpty(urls)) { + return ""; + } + if (urls.length == 1) { + return toFile(urls[0]).toString(); + } + StringBuilder builder = new StringBuilder(); + for (URL url : urls) { + if (!builder.isEmpty()) { + builder.append(File.pathSeparator); + } + builder.append(toFile(url)); + } + String classpath = builder.toString(); + if (runsOnWindows()) { + try { + return "@" + ArgFile.create(classpath); + } + catch (IOException ex) { + return classpath; + } + } + return classpath; + } + + private static File toFile(URL url) { + try { + return new File(url.toURI()); + } + catch (URISyntaxException ex) { + throw new IllegalArgumentException(ex); + } + } + + private static boolean runsOnWindows() { + String os = System.getProperty("os.name"); + if (!StringUtils.hasText(os)) { + return false; + } + return os.toLowerCase(Locale.ROOT).contains("win"); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/CommandLineBuilder.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/CommandLineBuilder.java index ba5a45ce8667..00b023fb252a 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/CommandLineBuilder.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/CommandLineBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,8 +16,6 @@ package org.springframework.boot.maven; -import java.io.File; -import java.net.URISyntaxException; import java.net.URL; import java.util.ArrayList; import java.util.Arrays; @@ -84,7 +82,7 @@ List build() { } if (!this.classpathElements.isEmpty()) { commandLine.add("-cp"); - commandLine.add(ClasspathBuilder.build(this.classpathElements)); + commandLine.add(ClasspathBuilder.build(this.classpathElements.toArray(URL[]::new))); } commandLine.add(this.mainClass); if (!this.arguments.isEmpty()) { @@ -93,30 +91,6 @@ List build() { return commandLine; } - static class ClasspathBuilder { - - static String build(List classpathElements) { - StringBuilder classpath = new StringBuilder(); - for (URL element : classpathElements) { - if (!classpath.isEmpty()) { - classpath.append(File.pathSeparator); - } - classpath.append(toFile(element)); - } - return classpath.toString(); - } - - private static File toFile(URL element) { - try { - return new File(element.toURI()); - } - catch (URISyntaxException ex) { - throw new IllegalArgumentException(ex); - } - } - - } - /** * Format System properties. */ diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/AbstractRunMojoTests.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/ArgFileTests.java similarity index 72% rename from spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/AbstractRunMojoTests.java rename to spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/ArgFileTests.java index ba5669ee356c..2c83f86187c2 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/AbstractRunMojoTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/ArgFileTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,24 +18,24 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.nio.file.Paths; import org.junit.jupiter.api.Test; -import org.springframework.boot.maven.AbstractRunMojo.ArgFile; - import static org.assertj.core.api.Assertions.assertThat; /** - * Tests for {@link AbstractRunMojo}. + * Tests for {@link ArgFile}. * * @author Moritz Halbritter + * @author Dmytro Nosan */ -class AbstractRunMojoTests { +class ArgFileTests { @Test - void argfileEscapesContent() throws IOException { + void argFileEscapesContent() throws IOException { ArgFile file = ArgFile.create("some \\ content"); - assertThat(file.path()).content(StandardCharsets.UTF_8).isEqualTo("\"some \\\\ content\""); + assertThat(Paths.get(file.toString())).content(StandardCharsets.UTF_8).isEqualTo("\"some \\\\ content\""); } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/ClasspathBuilderTests.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/ClasspathBuilderTests.java new file mode 100644 index 000000000000..8216c2188117 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/ClasspathBuilderTests.java @@ -0,0 +1,69 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.maven; + +import java.io.File; +import java.nio.file.Path; +import java.nio.file.Paths; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ClasspathBuilder}. + * + * @author Dmytro Nosan + */ +class ClasspathBuilderTests { + + @Test + void buildWithEmptyClassPath() { + assertThat(ClasspathBuilder.build()).isEmpty(); + } + + @Test + void buildWithSingleClassPathURL(@TempDir Path tempDir) throws Exception { + Path file = tempDir.resolve("test.jar"); + assertThat(ClasspathBuilder.build(file.toUri().toURL())).isEqualTo(file.toString()); + } + + @Test + @DisabledOnOs(OS.WINDOWS) + void buildWithMultipleClassPathURLs(@TempDir Path tempDir) throws Exception { + Path file = tempDir.resolve("test.jar"); + Path file1 = tempDir.resolve("test1.jar"); + assertThat(ClasspathBuilder.build(file.toUri().toURL(), file1.toUri().toURL())) + .isEqualTo(file + File.pathSeparator + file1); + } + + @Test + @EnabledOnOs(OS.WINDOWS) + void buildWithMultipleClassPathURLsOnWindows(@TempDir Path tempDir) throws Exception { + Path file = tempDir.resolve("test.jar"); + Path file1 = tempDir.resolve("test1.jar"); + String classpath = ClasspathBuilder.build(file.toUri().toURL(), file1.toUri().toURL()); + assertThat(classpath).startsWith("@"); + assertThat(Paths.get(classpath.substring(1))) + .hasContent("\"" + (file + File.pathSeparator + file1).replace("\\", "\\\\") + "\""); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/CommandLineBuilderTests.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/CommandLineBuilderTests.java index aedd17bd48b3..4d5e245be44d 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/CommandLineBuilderTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/CommandLineBuilderTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,10 +16,25 @@ package org.springframework.boot.maven; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.lang.management.ManagementFactory; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.boot.loader.tools.JavaExecutable; import org.springframework.boot.maven.sample.ClassWithMainMethod; import static org.assertj.core.api.Assertions.assertThat; @@ -76,4 +91,57 @@ void buildWithNullIntermediateArgumentIsIgnored() { .containsExactly(CLASS_NAME, "--test", "--another"); } + @Test + @DisabledOnOs(OS.WINDOWS) + void buildWithClassPath(@TempDir Path tempDir) throws Exception { + Path file = tempDir.resolve("test.jar"); + Path file1 = tempDir.resolve("test1.jar"); + assertThat(CommandLineBuilder.forMainClass(CLASS_NAME) + .withClasspath(file.toUri().toURL(), file1.toUri().toURL()) + .build()).containsExactly("-cp", file + File.pathSeparator + file1, CLASS_NAME); + } + + @Test + @EnabledOnOs(OS.WINDOWS) + void buildWithClassPathOnWindows(@TempDir Path tempDir) throws Exception { + Path file = tempDir.resolve("test.jar"); + Path file1 = tempDir.resolve("test1.jar"); + List args = CommandLineBuilder.forMainClass(CLASS_NAME) + .withClasspath(file.toUri().toURL(), file1.toUri().toURL()) + .build(); + assertThat(args).hasSize(3); + assertThat(args.get(0)).isEqualTo("-cp"); + assertThat(args.get(1)).startsWith("@"); + assertThat(args.get(2)).isEqualTo(CLASS_NAME); + assertThat(Paths.get(args.get(1).substring(1))) + .hasContent("\"" + (file + File.pathSeparator + file1).replace("\\", "\\\\") + "\""); + } + + @Test + void buildAndRunWithLongClassPath() throws IOException, InterruptedException { + StringBuilder classPath = new StringBuilder(ManagementFactory.getRuntimeMXBean().getClassPath()); + while (classPath.length() < 35000) { + classPath.append(File.pathSeparator).append(classPath); + } + URL[] urls = Arrays.stream(classPath.toString().split(File.pathSeparator)).map(this::toURL).toArray(URL[]::new); + List command = CommandLineBuilder.forMainClass(ClassWithMainMethod.class.getName()) + .withClasspath(urls) + .build(); + ProcessBuilder pb = new JavaExecutable().processBuilder(command.toArray(new String[0])); + Process process = pb.start(); + assertThat(process.waitFor()).isEqualTo(0); + try (InputStream inputStream = process.getInputStream()) { + assertThat(inputStream).hasContent("Hello World"); + } + } + + private URL toURL(String path) { + try { + return Paths.get(path).toUri().toURL(); + } + catch (MalformedURLException ex) { + throw new RuntimeException(ex); + } + } + }