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 8c6c9599ae3..c89fb6764f4 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.plugins.annotations.Parameter; 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 @@ public abstract class AbstractAotMojo extends AbstractDependencyFilterMojo { JavaCompilerPluginConfiguration compilerConfiguration = new JavaCompilerPluginConfiguration(this.project); List options = new ArrayList<>(); options.add("-cp"); - options.add(ClasspathBuilder.build(Arrays.asList(classPath))); + options.add(ClasspathBuilder.forURLs(classPath).build().argument()); 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 3bd36f28289..d2a2accac00 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.File; 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; @@ -44,7 +39,7 @@ import org.apache.maven.project.MavenProject; import org.apache.maven.toolchain.ToolchainManager; import org.springframework.boot.loader.tools.FileUtils; -import org.springframework.util.StringUtils; +import org.springframework.boot.maven.ClasspathBuilder.Classpath; /** * Base class to run a Spring Boot application. @@ -338,45 +333,19 @@ public abstract class AbstractRunMojo extends AbstractDependencyFilterMojo { 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())); - } + Classpath classpath = ClasspathBuilder.forURLs(getClassPathUrls()).build(); if (getLog().isDebugEnabled()) { - getLog().debug("Classpath for forked process: " + classpath); + getLog().debug("Classpath for forked process: " + + classpath.elements().map(Object::toString).collect(Collectors.joining(File.separator))); } args.add("-cp"); - if (needsClasspathArgFile()) { - args.add("@" + ArgFile.create(classpath).path()); - } - else { - args.add(classpath.toString()); - } + args.add(classpath.argument()); } 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<>(); @@ -451,37 +420,4 @@ public abstract class AbstractRunMojo extends AbstractDependencyFilterMojo { } - 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/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 00000000000..7505a4db6b9 --- /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,177 @@ +/* + * 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.nio.charset.Charset; +import java.nio.charset.UnsupportedCharsetException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +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 + */ +class ClasspathBuilder { + + private final List urls; + + protected ClasspathBuilder(List urls) { + this.urls = urls; + } + + /** + * 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 ClasspathBuilder forURLs(List urls) { + return new ClasspathBuilder(new ArrayList<>(urls)); + } + + /** + * 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 ClasspathBuilder forURLs(URL... urls) { + return new ClasspathBuilder(Arrays.asList(urls)); + } + + Classpath build() { + if (ObjectUtils.isEmpty(this.urls)) { + return new Classpath("", Collections.emptyList()); + } + if (this.urls.size() == 1) { + Path file = toFile(this.urls.get(0)); + return new Classpath(file.toString(), List.of(file)); + } + List files = this.urls.stream().map(ClasspathBuilder::toFile).toList(); + String argument = files.stream().map(Object::toString).collect(Collectors.joining(File.pathSeparator)); + if (needsClasspathArgFile()) { + argument = createArgFile(argument); + } + return new Classpath(argument, files); + } + + protected boolean needsClasspathArgFile() { + String os = System.getProperty("os.name"); + if (!StringUtils.hasText(os)) { + return false; + } + // Windows limits the maximum command length, so we use an argfile + return os.toLowerCase(Locale.ROOT).contains("win"); + } + + /** + * Create a temporary file with the given {@code} classpath. Return a suitable + * argument to load the file, that is the full path prefixed by {@code @}. + * @param classpath the classpath to use + * @return a suitable argument for the classpath using a file + */ + private String createArgFile(String classpath) { + try { + return "@" + writeClasspathToFile(classpath); + } + catch (IOException ex) { + return classpath; + } + } + + private Path writeClasspathToFile(CharSequence classpath) throws IOException { + Path tempFile = Files.createTempFile("spring-boot-", ".argfile"); + tempFile.toFile().deleteOnExit(); + Files.writeString(tempFile, "\"" + escape(classpath) + "\"", getCharset()); + return tempFile; + } + + private static 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 static String escape(CharSequence content) { + return content.toString().replace("\\", "\\\\"); + } + + private static Path toFile(URL url) { + try { + return Paths.get(url.toURI()); + } + catch (URISyntaxException ex) { + throw new IllegalArgumentException(ex); + } + } + + static final class Classpath { + + private final String argument; + + private final List elements; + + private Classpath(String argument, List elements) { + this.argument = argument; + this.elements = elements; + } + + /** + * Return the {@code -cp} argument value. + * @return the argument to use + */ + String argument() { + return this.argument; + } + + /** + * Return the classpath elements. + * @return the JAR files to use + */ + Stream elements() { + return this.elements.stream(); + } + + } + +} 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 ba5a45ce866..1e09773bd45 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 @@ final class CommandLineBuilder { } if (!this.classpathElements.isEmpty()) { commandLine.add("-cp"); - commandLine.add(ClasspathBuilder.build(this.classpathElements)); + commandLine.add(ClasspathBuilder.forURLs(this.classpathElements).build().argument()); } commandLine.add(this.mainClass); if (!this.arguments.isEmpty()) { @@ -93,30 +91,6 @@ final class CommandLineBuilder { 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/AbstractRunMojoTests.java deleted file mode 100644 index ba5669ee356..00000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/test/java/org/springframework/boot/maven/AbstractRunMojoTests.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2012-2024 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.StandardCharsets; - -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}. - * - * @author Moritz Halbritter - */ -class AbstractRunMojoTests { - - @Test - void argfileEscapesContent() throws IOException { - ArgFile file = ArgFile.create("some \\ content"); - assertThat(file.path()).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 00000000000..18927066a02 --- /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,157 @@ +/* + * 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.net.MalformedURLException; +import java.net.URL; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Nested; +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.maven.ClasspathBuilder.Classpath; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ClasspathBuilder}. + * + * @author Dmytro Nosan + * @author Stephane Nicoll + */ +class ClasspathBuilderTests { + + @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.forURLs(file.toUri().toURL(), file1.toUri().toURL()).build().argument()) + .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.forURLs(file.toUri().toURL(), file1.toUri().toURL()).build().argument(); + assertThat(classpath).startsWith("@"); + assertThat(Paths.get(classpath.substring(1))) + .hasContent("\"" + (file + File.pathSeparator + file1).replace("\\", "\\\\") + "\""); + } + + @Nested + class WindowsTests { + + @Test + void buildWithEmptyClassPath() throws MalformedURLException { + Classpath classpath = classPathBuilder().build(); + assertThat(classpath.argument()).isEmpty(); + assertThat(classpath.elements()).isEmpty(); + } + + @Test + void buildWithSingleClassPathURL(@TempDir Path tempDir) throws Exception { + Path file = tempDir.resolve("test.jar"); + Classpath classpath = classPathBuilder(file).build(); + assertThat(classpath.argument()).isEqualTo(file.toString()); + assertThat(classpath.elements()).singleElement().isEqualTo(file); + } + + @Test + void buildWithMultipleClassPathURLs(@TempDir Path tempDir) throws Exception { + Path file = tempDir.resolve("test.jar"); + Path file2 = tempDir.resolve("test2.jar"); + Classpath classpath = classPathBuilder(file, file2).build(); + assertThat(classpath.argument()).startsWith("@"); + assertThat(Paths.get(classpath.argument().substring(1))) + .hasContent("\"" + (file + File.pathSeparator + file2).replace("\\", "\\\\") + "\""); + } + + private ClasspathBuilder classPathBuilder(Path... files) throws MalformedURLException { + return new TestClasspathBuilder(true, files); + } + + } + + @Nested + class UnixTests { + + @Test + void buildWithEmptyClassPath() throws MalformedURLException { + Classpath classpath = classPathBuilder().build(); + assertThat(classpath.argument()).isEmpty(); + assertThat(classpath.elements()).isEmpty(); + } + + @Test + void buildWithSingleClassPathURL(@TempDir Path tempDir) throws Exception { + Path file = tempDir.resolve("test.jar"); + Classpath classpath = classPathBuilder(file).build(); + assertThat(classpath.argument()).isEqualTo(file.toString()); + assertThat(classpath.elements()).singleElement().isEqualTo(file); + } + + @Test + void buildWithMultipleClassPathURLs(@TempDir Path tempDir) throws Exception { + Path file = tempDir.resolve("test.jar"); + Path file2 = tempDir.resolve("test2.jar"); + Classpath classpath = classPathBuilder(file, file2).build(); + assertThat(classpath.argument()).doesNotStartWith("@") + .isEqualTo((file + File.pathSeparator + file2).replace("\\", "\\\\")); + } + + private ClasspathBuilder classPathBuilder(Path... files) throws MalformedURLException { + return new TestClasspathBuilder(false, files); + } + + } + + private static class TestClasspathBuilder extends ClasspathBuilder { + + private final boolean needsClasspathArgFile; + + protected TestClasspathBuilder(boolean needsClasspathArgFile, Path... files) throws MalformedURLException { + super(toURLs(files)); + this.needsClasspathArgFile = needsClasspathArgFile; + } + + private static List toURLs(Path... files) throws MalformedURLException { + List urls = new ArrayList<>(); + for (Path file : files) { + urls.add(file.toUri().toURL()); + } + return urls; + } + + @Override + protected boolean needsClasspathArgFile() { + return this.needsClasspathArgFile; + } + + } + +} 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 aedd17bd48b..3c142189375 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,55 @@ class CommandLineBuilderTests { .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 { + URL[] urls = Arrays.stream(ManagementFactory.getRuntimeMXBean().getClassPath().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); + } + } + }