diff --git a/settings.gradle b/settings.gradle index d5d771e915..9dc4ab6ee4 100644 --- a/settings.gradle +++ b/settings.gradle @@ -58,6 +58,7 @@ include "spring-boot-project:spring-boot-tools:spring-boot-configuration-process include "spring-boot-project:spring-boot-tools:spring-boot-gradle-plugin" include "spring-boot-project:spring-boot-tools:spring-boot-gradle-test-support" include "spring-boot-project:spring-boot-tools:spring-boot-jarmode-layertools" +include "spring-boot-project:spring-boot-tools:spring-boot-loader" include "spring-boot-project:spring-boot-tools:spring-boot-loader-classic" include "spring-boot-project:spring-boot-tools:spring-boot-loader-tools" include "spring-boot-project:spring-boot-tools:spring-boot-maven-plugin" @@ -75,6 +76,7 @@ include "spring-boot-project:spring-boot-testcontainers" include "spring-boot-project:spring-boot-test-autoconfigure" include "spring-boot-tests:spring-boot-integration-tests:spring-boot-configuration-processor-tests" include "spring-boot-tests:spring-boot-integration-tests:spring-boot-launch-script-tests" +include "spring-boot-tests:spring-boot-integration-tests:spring-boot-loader-tests" include "spring-boot-tests:spring-boot-integration-tests:spring-boot-loader-classic-tests" include "spring-boot-tests:spring-boot-integration-tests:spring-boot-server-tests" include "spring-boot-system-tests:spring-boot-deployment-tests" diff --git a/spring-boot-project/spring-boot-dependencies/build.gradle b/spring-boot-project/spring-boot-dependencies/build.gradle index c1ffd7a661..3f7bac1a79 100644 --- a/spring-boot-project/spring-boot-dependencies/build.gradle +++ b/spring-boot-project/spring-boot-dependencies/build.gradle @@ -1380,6 +1380,7 @@ bom { "spring-boot-devtools", "spring-boot-docker-compose", "spring-boot-jarmode-layertools", + "spring-boot-loader", "spring-boot-loader-classic", "spring-boot-loader-tools", "spring-boot-properties-migrator", diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/build.gradle b/spring-boot-project/spring-boot-tools/spring-boot-loader/build.gradle new file mode 100644 index 0000000000..845fde0b61 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/build.gradle @@ -0,0 +1,23 @@ +plugins { + id "java-library" + id "org.springframework.boot.conventions" + id "org.springframework.boot.deployed" +} + +description = "Spring Boot Loader" + +dependencies { + compileOnly("org.springframework:spring-core") + + testImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support")) + testImplementation("org.assertj:assertj-core") + testImplementation("org.awaitility:awaitility") + testImplementation("org.junit.jupiter:junit-jupiter") + testImplementation("org.mockito:mockito-core") + testImplementation("org.springframework:spring-test") + testImplementation("org.springframework:spring-core-test") + + testRuntimeOnly("ch.qos.logback:logback-classic") + testRuntimeOnly("org.bouncycastle:bcprov-jdk18on:1.71") + testRuntimeOnly("org.springframework:spring-webmvc") +} \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ClassPathIndexFile.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ClassPathIndexFile.java new file mode 100644 index 0000000000..5ad01e5071 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ClassPathIndexFile.java @@ -0,0 +1,123 @@ +/* + * Copyright 2012-2023 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.loader; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.MalformedURLException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * A class path index file that provides ordering information for JARs. + * + * @author Madhura Bhave + * @author Phillip Webb + */ +final class ClassPathIndexFile { + + private final File root; + + private final List lines; + + private ClassPathIndexFile(File root, List lines) { + this.root = root; + this.lines = lines.stream().map(this::extractName).toList(); + } + + private String extractName(String line) { + if (line.startsWith("- \"") && line.endsWith("\"")) { + return line.substring(3, line.length() - 1); + } + throw new IllegalStateException("Malformed classpath index line [" + line + "]"); + } + + int size() { + return this.lines.size(); + } + + boolean containsEntry(String name) { + if (name == null || name.isEmpty()) { + return false; + } + return this.lines.contains(name); + } + + List getUrls() { + return this.lines.stream().map(this::asUrl).toList(); + } + + private URL asUrl(String line) { + try { + return new File(this.root, line).toURI().toURL(); + } + catch (MalformedURLException ex) { + throw new IllegalStateException(ex); + } + } + + static ClassPathIndexFile loadIfPossible(URL root, String location) throws IOException { + return loadIfPossible(asFile(root), location); + } + + private static ClassPathIndexFile loadIfPossible(File root, String location) throws IOException { + return loadIfPossible(root, new File(root, location)); + } + + private static ClassPathIndexFile loadIfPossible(File root, File indexFile) throws IOException { + if (indexFile.exists() && indexFile.isFile()) { + try (InputStream inputStream = new FileInputStream(indexFile)) { + return new ClassPathIndexFile(root, loadLines(inputStream)); + } + } + return null; + } + + private static List loadLines(InputStream inputStream) throws IOException { + List lines = new ArrayList<>(); + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8)); + String line = reader.readLine(); + while (line != null) { + if (!line.trim().isEmpty()) { + lines.add(line); + } + line = reader.readLine(); + } + return Collections.unmodifiableList(lines); + } + + private static File asFile(URL url) { + if (!"file".equals(url.getProtocol())) { + throw new IllegalArgumentException("URL does not reference a file"); + } + try { + return new File(url.toURI()); + } + catch (URISyntaxException ex) { + return new File(url.getPath()); + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ExecutableArchiveLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ExecutableArchiveLauncher.java new file mode 100644 index 0000000000..d2ceaf61c5 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ExecutableArchiveLauncher.java @@ -0,0 +1,207 @@ +/* + * Copyright 2012-2023 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.loader; + +import java.io.IOException; +import java.net.URL; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.jar.Attributes; +import java.util.jar.Manifest; + +import org.springframework.boot.loader.archive.Archive; +import org.springframework.boot.loader.archive.ExplodedArchive; + +/** + * Base class for executable archive {@link Launcher}s. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Madhura Bhave + * @author Scott Frederick + * @since 1.0.0 + */ +public abstract class ExecutableArchiveLauncher extends Launcher { + + private static final String START_CLASS_ATTRIBUTE = "Start-Class"; + + protected static final String BOOT_CLASSPATH_INDEX_ATTRIBUTE = "Spring-Boot-Classpath-Index"; + + protected static final String DEFAULT_CLASSPATH_INDEX_FILE_NAME = "classpath.idx"; + + private final Archive archive; + + private final ClassPathIndexFile classPathIndex; + + public ExecutableArchiveLauncher() { + try { + this.archive = createArchive(); + this.classPathIndex = getClassPathIndex(this.archive); + } + catch (Exception ex) { + throw new IllegalStateException(ex); + } + } + + protected ExecutableArchiveLauncher(Archive archive) { + try { + this.archive = archive; + this.classPathIndex = getClassPathIndex(this.archive); + } + catch (Exception ex) { + throw new IllegalStateException(ex); + } + } + + protected ClassPathIndexFile getClassPathIndex(Archive archive) throws IOException { + // Only needed for exploded archives, regular ones already have a defined order + if (archive instanceof ExplodedArchive) { + String location = getClassPathIndexFileLocation(archive); + return ClassPathIndexFile.loadIfPossible(archive.getUrl(), location); + } + return null; + } + + private String getClassPathIndexFileLocation(Archive archive) throws IOException { + Manifest manifest = archive.getManifest(); + Attributes attributes = (manifest != null) ? manifest.getMainAttributes() : null; + String location = (attributes != null) ? attributes.getValue(BOOT_CLASSPATH_INDEX_ATTRIBUTE) : null; + return (location != null) ? location : getArchiveEntryPathPrefix() + DEFAULT_CLASSPATH_INDEX_FILE_NAME; + } + + @Override + protected String getMainClass() throws Exception { + Manifest manifest = this.archive.getManifest(); + String mainClass = null; + if (manifest != null) { + mainClass = manifest.getMainAttributes().getValue(START_CLASS_ATTRIBUTE); + } + if (mainClass == null) { + throw new IllegalStateException("No 'Start-Class' manifest entry specified in " + this); + } + return mainClass; + } + + @Override + protected ClassLoader createClassLoader(Iterator archives) throws Exception { + List urls = new ArrayList<>(guessClassPathSize()); + while (archives.hasNext()) { + urls.add(archives.next().getUrl()); + } + if (this.classPathIndex != null) { + urls.addAll(this.classPathIndex.getUrls()); + } + return createClassLoader(urls.toArray(new URL[0])); + } + + private int guessClassPathSize() { + if (this.classPathIndex != null) { + return this.classPathIndex.size() + 10; + } + return 50; + } + + @Override + protected Iterator getClassPathArchivesIterator() throws Exception { + Archive.EntryFilter searchFilter = this::isSearchCandidate; + Iterator archives = this.archive.getNestedArchives(searchFilter, + (entry) -> isNestedArchive(entry) && !isEntryIndexed(entry)); + if (isPostProcessingClassPathArchives()) { + archives = applyClassPathArchivePostProcessing(archives); + } + return archives; + } + + private boolean isEntryIndexed(Archive.Entry entry) { + if (this.classPathIndex != null) { + return this.classPathIndex.containsEntry(entry.getName()); + } + return false; + } + + private Iterator applyClassPathArchivePostProcessing(Iterator archives) throws Exception { + List list = new ArrayList<>(); + while (archives.hasNext()) { + list.add(archives.next()); + } + postProcessClassPathArchives(list); + return list.iterator(); + } + + /** + * Determine if the specified entry is a candidate for further searching. + * @param entry the entry to check + * @return {@code true} if the entry is a candidate for further searching + * @since 2.3.0 + */ + protected boolean isSearchCandidate(Archive.Entry entry) { + if (getArchiveEntryPathPrefix() == null) { + return true; + } + return entry.getName().startsWith(getArchiveEntryPathPrefix()); + } + + /** + * Determine if the specified entry is a nested item that should be added to the + * classpath. + * @param entry the entry to check + * @return {@code true} if the entry is a nested item (jar or directory) + */ + protected abstract boolean isNestedArchive(Archive.Entry entry); + + /** + * Return if post-processing needs to be applied to the archives. For back + * compatibility this method returns {@code true}, but subclasses that don't override + * {@link #postProcessClassPathArchives(List)} should provide an implementation that + * returns {@code false}. + * @return if the {@link #postProcessClassPathArchives(List)} method is implemented + * @since 2.3.0 + */ + protected boolean isPostProcessingClassPathArchives() { + return true; + } + + /** + * Called to post-process archive entries before they are used. Implementations can + * add and remove entries. + * @param archives the archives + * @throws Exception if the post-processing fails + * @see #isPostProcessingClassPathArchives() + */ + protected void postProcessClassPathArchives(List archives) throws Exception { + } + + /** + * Return the path prefix for entries in the archive. + * @return the path prefix + */ + protected String getArchiveEntryPathPrefix() { + return null; + } + + @Override + protected boolean isExploded() { + return this.archive.isExploded(); + } + + @Override + protected final Archive getArchive() { + return this.archive; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/JarLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/JarLauncher.java new file mode 100644 index 0000000000..5061573e24 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/JarLauncher.java @@ -0,0 +1,68 @@ +/* + * Copyright 2012-2023 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.loader; + +import org.springframework.boot.loader.archive.Archive; +import org.springframework.boot.loader.archive.Archive.EntryFilter; + +/** + * {@link Launcher} for JAR based archives. This launcher assumes that dependency jars are + * included inside a {@code /BOOT-INF/lib} directory and that application classes are + * included inside a {@code /BOOT-INF/classes} directory. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Madhura Bhave + * @author Scott Frederick + * @since 1.0.0 + */ +public class JarLauncher extends ExecutableArchiveLauncher { + + static final EntryFilter NESTED_ARCHIVE_ENTRY_FILTER = (entry) -> { + if (entry.isDirectory()) { + return entry.getName().equals("BOOT-INF/classes/"); + } + return entry.getName().startsWith("BOOT-INF/lib/"); + }; + + public JarLauncher() { + } + + protected JarLauncher(Archive archive) { + super(archive); + } + + @Override + protected boolean isPostProcessingClassPathArchives() { + return false; + } + + @Override + protected boolean isNestedArchive(Archive.Entry entry) { + return NESTED_ARCHIVE_ENTRY_FILTER.matches(entry); + } + + @Override + protected String getArchiveEntryPathPrefix() { + return "BOOT-INF/"; + } + + public static void main(String[] args) throws Exception { + new JarLauncher().launch(args); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/LaunchedURLClassLoader.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/LaunchedURLClassLoader.java new file mode 100644 index 0000000000..7e3e2fa223 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/LaunchedURLClassLoader.java @@ -0,0 +1,366 @@ +/* + * Copyright 2012-2023 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.loader; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.JarURLConnection; +import java.net.URL; +import java.net.URLClassLoader; +import java.net.URLConnection; +import java.util.Enumeration; +import java.util.function.Supplier; +import java.util.jar.JarFile; +import java.util.jar.Manifest; + +import org.springframework.boot.loader.archive.Archive; +import org.springframework.boot.loader.jar.Handler; + +/** + * {@link ClassLoader} used by the {@link Launcher}. + * + * @author Phillip Webb + * @author Dave Syer + * @author Andy Wilkinson + * @since 1.0.0 + */ +public class LaunchedURLClassLoader extends URLClassLoader { + + private static final int BUFFER_SIZE = 4096; + + static { + ClassLoader.registerAsParallelCapable(); + } + + private final boolean exploded; + + private final Archive rootArchive; + + private final Object packageLock = new Object(); + + private volatile DefinePackageCallType definePackageCallType; + + /** + * Create a new {@link LaunchedURLClassLoader} instance. + * @param urls the URLs from which to load classes and resources + * @param parent the parent class loader for delegation + */ + public LaunchedURLClassLoader(URL[] urls, ClassLoader parent) { + this(false, urls, parent); + } + + /** + * Create a new {@link LaunchedURLClassLoader} instance. + * @param exploded if the underlying archive is exploded + * @param urls the URLs from which to load classes and resources + * @param parent the parent class loader for delegation + */ + public LaunchedURLClassLoader(boolean exploded, URL[] urls, ClassLoader parent) { + this(exploded, null, urls, parent); + } + + /** + * Create a new {@link LaunchedURLClassLoader} instance. + * @param exploded if the underlying archive is exploded + * @param rootArchive the root archive or {@code null} + * @param urls the URLs from which to load classes and resources + * @param parent the parent class loader for delegation + * @since 2.3.1 + */ + public LaunchedURLClassLoader(boolean exploded, Archive rootArchive, URL[] urls, ClassLoader parent) { + super(urls, parent); + this.exploded = exploded; + this.rootArchive = rootArchive; + } + + @Override + public URL findResource(String name) { + if (this.exploded) { + return super.findResource(name); + } + Handler.setUseFastConnectionExceptions(true); + try { + return super.findResource(name); + } + finally { + Handler.setUseFastConnectionExceptions(false); + } + } + + @Override + public Enumeration findResources(String name) throws IOException { + if (this.exploded) { + return super.findResources(name); + } + Handler.setUseFastConnectionExceptions(true); + try { + return new UseFastConnectionExceptionsEnumeration(super.findResources(name)); + } + finally { + Handler.setUseFastConnectionExceptions(false); + } + } + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (name.startsWith("org.springframework.boot.loader.jarmode.")) { + try { + Class result = loadClassInLaunchedClassLoader(name); + if (resolve) { + resolveClass(result); + } + return result; + } + catch (ClassNotFoundException ex) { + } + } + if (this.exploded) { + return super.loadClass(name, resolve); + } + Handler.setUseFastConnectionExceptions(true); + try { + try { + definePackageIfNecessary(name); + } + catch (IllegalArgumentException ex) { + // Tolerate race condition due to being parallel capable + if (getDefinedPackage(name) == null) { + // This should never happen as the IllegalArgumentException indicates + // that the package has already been defined and, therefore, + // getDefinedPackage(name) should not return null. + throw new AssertionError("Package " + name + " has already been defined but it could not be found"); + } + } + return super.loadClass(name, resolve); + } + finally { + Handler.setUseFastConnectionExceptions(false); + } + } + + private Class loadClassInLaunchedClassLoader(String name) throws ClassNotFoundException { + String internalName = name.replace('.', '/') + ".class"; + InputStream inputStream = getParent().getResourceAsStream(internalName); + if (inputStream == null) { + throw new ClassNotFoundException(name); + } + try { + try { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + byte[] buffer = new byte[BUFFER_SIZE]; + int bytesRead = -1; + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + inputStream.close(); + byte[] bytes = outputStream.toByteArray(); + Class definedClass = defineClass(name, bytes, 0, bytes.length); + definePackageIfNecessary(name); + return definedClass; + } + finally { + inputStream.close(); + } + } + catch (IOException ex) { + throw new ClassNotFoundException("Cannot load resource for class [" + name + "]", ex); + } + } + + /** + * Define a package before a {@code findClass} call is made. This is necessary to + * ensure that the appropriate manifest for nested JARs is associated with the + * package. + * @param className the class name being found + */ + private void definePackageIfNecessary(String className) { + int lastDot = className.lastIndexOf('.'); + if (lastDot >= 0) { + String packageName = className.substring(0, lastDot); + if (getDefinedPackage(packageName) == null) { + try { + definePackage(className, packageName); + } + catch (IllegalArgumentException ex) { + // Tolerate race condition due to being parallel capable + if (getDefinedPackage(packageName) == null) { + // This should never happen as the IllegalArgumentException + // indicates that the package has already been defined and, + // therefore, getDefinedPackage(name) should not have returned + // null. + throw new AssertionError( + "Package " + packageName + " has already been defined but it could not be found"); + } + } + } + } + } + + private void definePackage(String className, String packageName) { + String packageEntryName = packageName.replace('.', '/') + "/"; + String classEntryName = className.replace('.', '/') + ".class"; + for (URL url : getURLs()) { + try { + URLConnection connection = url.openConnection(); + if (connection instanceof JarURLConnection jarURLConnection) { + JarFile jarFile = jarURLConnection.getJarFile(); + if (jarFile.getEntry(classEntryName) != null && jarFile.getEntry(packageEntryName) != null + && jarFile.getManifest() != null) { + definePackage(packageName, jarFile.getManifest(), url); + return; + } + } + } + catch (IOException ex) { + // Ignore + } + } + } + + @Override + protected Package definePackage(String name, Manifest man, URL url) throws IllegalArgumentException { + if (!this.exploded) { + return super.definePackage(name, man, url); + } + synchronized (this.packageLock) { + return doDefinePackage(DefinePackageCallType.MANIFEST, () -> super.definePackage(name, man, url)); + } + } + + @Override + protected Package definePackage(String name, String specTitle, String specVersion, String specVendor, + String implTitle, String implVersion, String implVendor, URL sealBase) throws IllegalArgumentException { + if (!this.exploded) { + return super.definePackage(name, specTitle, specVersion, specVendor, implTitle, implVersion, implVendor, + sealBase); + } + synchronized (this.packageLock) { + if (this.definePackageCallType == null) { + // We're not part of a call chain which means that the URLClassLoader + // is trying to define a package for our exploded JAR. We use the + // manifest version to ensure package attributes are set + Manifest manifest = getManifest(this.rootArchive); + if (manifest != null) { + return definePackage(name, manifest, sealBase); + } + } + return doDefinePackage(DefinePackageCallType.ATTRIBUTES, () -> super.definePackage(name, specTitle, + specVersion, specVendor, implTitle, implVersion, implVendor, sealBase)); + } + } + + private Manifest getManifest(Archive archive) { + try { + return (archive != null) ? archive.getManifest() : null; + } + catch (IOException ex) { + return null; + } + } + + private T doDefinePackage(DefinePackageCallType type, Supplier call) { + DefinePackageCallType existingType = this.definePackageCallType; + try { + this.definePackageCallType = type; + return call.get(); + } + finally { + this.definePackageCallType = existingType; + } + } + + /** + * Clear URL caches. + */ + public void clearCache() { + if (this.exploded) { + return; + } + for (URL url : getURLs()) { + try { + URLConnection connection = url.openConnection(); + if (connection instanceof JarURLConnection) { + clearCache(connection); + } + } + catch (IOException ex) { + // Ignore + } + } + + } + + private void clearCache(URLConnection connection) throws IOException { + Object jarFile = ((JarURLConnection) connection).getJarFile(); + if (jarFile instanceof org.springframework.boot.loader.jar.JarFile) { + ((org.springframework.boot.loader.jar.JarFile) jarFile).clearCache(); + } + } + + private static class UseFastConnectionExceptionsEnumeration implements Enumeration { + + private final Enumeration delegate; + + UseFastConnectionExceptionsEnumeration(Enumeration delegate) { + this.delegate = delegate; + } + + @Override + public boolean hasMoreElements() { + Handler.setUseFastConnectionExceptions(true); + try { + return this.delegate.hasMoreElements(); + } + finally { + Handler.setUseFastConnectionExceptions(false); + } + + } + + @Override + public URL nextElement() { + Handler.setUseFastConnectionExceptions(true); + try { + return this.delegate.nextElement(); + } + finally { + Handler.setUseFastConnectionExceptions(false); + } + } + + } + + /** + * The different types of call made to define a package. We track these for exploded + * jars so that we can detect packages that should have manifest attributes applied. + */ + private enum DefinePackageCallType { + + /** + * A define package call from a resource that has a manifest. + */ + MANIFEST, + + /** + * A define package call with a direct set of attributes. + */ + ATTRIBUTES + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/Launcher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/Launcher.java new file mode 100644 index 0000000000..2f4cac9444 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/Launcher.java @@ -0,0 +1,159 @@ +/* + * Copyright 2012-2023 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.loader; + +import java.io.File; +import java.net.URI; +import java.net.URL; +import java.security.CodeSource; +import java.security.ProtectionDomain; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +import org.springframework.boot.loader.archive.Archive; +import org.springframework.boot.loader.archive.ExplodedArchive; +import org.springframework.boot.loader.archive.JarFileArchive; +import org.springframework.boot.loader.jar.JarFile; + +/** + * Base class for launchers that can start an application with a fully configured + * classpath backed by one or more {@link Archive}s. + * + * @author Phillip Webb + * @author Dave Syer + * @since 1.0.0 + */ +public abstract class Launcher { + + private static final String JAR_MODE_LAUNCHER = "org.springframework.boot.loader.jarmode.JarModeLauncher"; + + /** + * Launch the application. This method is the initial entry point that should be + * called by a subclass {@code public static void main(String[] args)} method. + * @param args the incoming arguments + * @throws Exception if the application fails to launch + */ + protected void launch(String[] args) throws Exception { + if (!isExploded()) { + JarFile.registerUrlProtocolHandler(); + } + ClassLoader classLoader = createClassLoader(getClassPathArchivesIterator()); + String jarMode = System.getProperty("jarmode"); + String launchClass = (jarMode != null && !jarMode.isEmpty()) ? JAR_MODE_LAUNCHER : getMainClass(); + launch(args, launchClass, classLoader); + } + + /** + * Create a classloader for the specified archives. + * @param archives the archives + * @return the classloader + * @throws Exception if the classloader cannot be created + * @since 2.3.0 + */ + protected ClassLoader createClassLoader(Iterator archives) throws Exception { + List urls = new ArrayList<>(50); + while (archives.hasNext()) { + urls.add(archives.next().getUrl()); + } + return createClassLoader(urls.toArray(new URL[0])); + } + + /** + * Create a classloader for the specified URLs. + * @param urls the URLs + * @return the classloader + * @throws Exception if the classloader cannot be created + */ + protected ClassLoader createClassLoader(URL[] urls) throws Exception { + return new LaunchedURLClassLoader(isExploded(), getArchive(), urls, getClass().getClassLoader()); + } + + /** + * Launch the application given the archive file and a fully configured classloader. + * @param args the incoming arguments + * @param launchClass the launch class to run + * @param classLoader the classloader + * @throws Exception if the launch fails + */ + protected void launch(String[] args, String launchClass, ClassLoader classLoader) throws Exception { + Thread.currentThread().setContextClassLoader(classLoader); + createMainMethodRunner(launchClass, args, classLoader).run(); + } + + /** + * Create the {@code MainMethodRunner} used to launch the application. + * @param mainClass the main class + * @param args the incoming arguments + * @param classLoader the classloader + * @return the main method runner + */ + protected MainMethodRunner createMainMethodRunner(String mainClass, String[] args, ClassLoader classLoader) { + return new MainMethodRunner(mainClass, args); + } + + /** + * Returns the main class that should be launched. + * @return the name of the main class + * @throws Exception if the main class cannot be obtained + */ + protected abstract String getMainClass() throws Exception; + + /** + * Returns the archives that will be used to construct the class path. + * @return the class path archives + * @throws Exception if the class path archives cannot be obtained + * @since 2.3.0 + */ + protected abstract Iterator getClassPathArchivesIterator() throws Exception; + + protected final Archive createArchive() throws Exception { + ProtectionDomain protectionDomain = getClass().getProtectionDomain(); + CodeSource codeSource = protectionDomain.getCodeSource(); + URI location = (codeSource != null) ? codeSource.getLocation().toURI() : null; + String path = (location != null) ? location.getSchemeSpecificPart() : null; + if (path == null) { + throw new IllegalStateException("Unable to determine code source archive"); + } + File root = new File(path); + if (!root.exists()) { + throw new IllegalStateException("Unable to determine code source archive from " + root); + } + return (root.isDirectory() ? new ExplodedArchive(root) : new JarFileArchive(root)); + } + + /** + * Returns if the launcher is running in an exploded mode. If this method returns + * {@code true} then only regular JARs are supported and the additional URL and + * ClassLoader support infrastructure can be optimized. + * @return if the jar is exploded. + * @since 2.3.0 + */ + protected boolean isExploded() { + return false; + } + + /** + * Return the root archive. + * @return the root archive + * @since 2.3.1 + */ + protected Archive getArchive() { + return null; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/MainMethodRunner.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/MainMethodRunner.java new file mode 100644 index 0000000000..12355a2bef --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/MainMethodRunner.java @@ -0,0 +1,52 @@ +/* + * Copyright 2012-2023 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.loader; + +import java.lang.reflect.Method; + +/** + * Utility class that is used by {@link Launcher}s to call a main method. The class + * containing the main method is loaded using the thread context class loader. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @since 1.0.0 + */ +public class MainMethodRunner { + + private final String mainClassName; + + private final String[] args; + + /** + * Create a new {@link MainMethodRunner} instance. + * @param mainClass the main class + * @param args incoming arguments + */ + public MainMethodRunner(String mainClass, String[] args) { + this.mainClassName = mainClass; + this.args = (args != null) ? args.clone() : null; + } + + public void run() throws Exception { + Class mainClass = Class.forName(this.mainClassName, false, Thread.currentThread().getContextClassLoader()); + Method mainMethod = mainClass.getDeclaredMethod("main", String[].class); + mainMethod.setAccessible(true); + mainMethod.invoke(null, new Object[] { this.args }); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/PropertiesLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/PropertiesLauncher.java new file mode 100755 index 0000000000..3703ac1367 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/PropertiesLauncher.java @@ -0,0 +1,726 @@ +/* + * Copyright 2012-2023 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.loader; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.lang.reflect.Constructor; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLDecoder; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Properties; +import java.util.Set; +import java.util.jar.Manifest; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.boot.loader.archive.Archive; +import org.springframework.boot.loader.archive.Archive.Entry; +import org.springframework.boot.loader.archive.Archive.EntryFilter; +import org.springframework.boot.loader.archive.ExplodedArchive; +import org.springframework.boot.loader.archive.JarFileArchive; +import org.springframework.boot.loader.util.SystemPropertyUtils; + +/** + * {@link Launcher} for archives with user-configured classpath and main class through a + * properties file. This model is often more flexible and more amenable to creating + * well-behaved OS-level services than a model based on executable jars. + *

+ * Looks in various places for a properties file to extract loader settings, defaulting to + * {@code loader.properties} either on the current classpath or in the current working + * directory. The name of the properties file can be changed by setting a System property + * {@code loader.config.name} (e.g. {@code -Dloader.config.name=foo} will look for + * {@code foo.properties}. If that file doesn't exist then tries + * {@code loader.config.location} (with allowed prefixes {@code classpath:} and + * {@code file:} or any valid URL). Once that file is located turns it into Properties and + * extracts optional values (which can also be provided overridden as System properties in + * case the file doesn't exist): + *

    + *
  • {@code loader.path}: a comma-separated list of directories (containing file + * resources and/or nested archives in *.jar or *.zip or archives) or archives to append + * to the classpath. {@code BOOT-INF/classes,BOOT-INF/lib} in the application archive are + * always used
  • + *
  • {@code loader.main}: the main method to delegate execution to once the class loader + * is set up. No default, but will fall back to looking for a {@code Start-Class} in a + * {@code MANIFEST.MF}, if there is one in ${loader.home}/META-INF.
  • + *
+ * + * @author Dave Syer + * @author Janne Valkealahti + * @author Andy Wilkinson + * @since 1.0.0 + */ +public class PropertiesLauncher extends Launcher { + + private static final Class[] PARENT_ONLY_PARAMS = new Class[] { ClassLoader.class }; + + private static final Class[] URLS_AND_PARENT_PARAMS = new Class[] { URL[].class, ClassLoader.class }; + + private static final Class[] NO_PARAMS = new Class[] {}; + + private static final URL[] NO_URLS = new URL[0]; + + private static final String DEBUG = "loader.debug"; + + /** + * Properties key for main class. As a manifest entry can also be specified as + * {@code Start-Class}. + */ + public static final String MAIN = "loader.main"; + + /** + * Properties key for classpath entries (directories possibly containing jars or + * jars). Multiple entries can be specified using a comma-separated list. {@code + * BOOT-INF/classes,BOOT-INF/lib} in the application archive are always used. + */ + public static final String PATH = "loader.path"; + + /** + * Properties key for home directory. This is the location of external configuration + * if not on classpath, and also the base path for any relative paths in the + * {@link #PATH loader path}. Defaults to current working directory ( + * ${user.dir}). + */ + public static final String HOME = "loader.home"; + + /** + * Properties key for default command line arguments. These arguments (if present) are + * prepended to the main method arguments before launching. + */ + public static final String ARGS = "loader.args"; + + /** + * Properties key for name of external configuration file (excluding suffix). Defaults + * to "application". Ignored if {@link #CONFIG_LOCATION loader config location} is + * provided instead. + */ + public static final String CONFIG_NAME = "loader.config.name"; + + /** + * Properties key for config file location (including optional classpath:, file: or + * URL prefix). + */ + public static final String CONFIG_LOCATION = "loader.config.location"; + + /** + * Properties key for boolean flag (default false) which, if set, will cause the + * external configuration properties to be copied to System properties (assuming that + * is allowed by Java security). + */ + public static final String SET_SYSTEM_PROPERTIES = "loader.system"; + + private static final Pattern WORD_SEPARATOR = Pattern.compile("\\W+"); + + private static final String NESTED_ARCHIVE_SEPARATOR = "!" + File.separator; + + private final File home; + + private List paths = new ArrayList<>(); + + private final Properties properties = new Properties(); + + private final Archive parent; + + private volatile ClassPathArchives classPathArchives; + + public PropertiesLauncher() { + try { + this.home = getHomeDirectory(); + initializeProperties(); + initializePaths(); + this.parent = createArchive(); + } + catch (Exception ex) { + throw new IllegalStateException(ex); + } + } + + protected File getHomeDirectory() { + try { + return new File(getPropertyWithDefault(HOME, "${user.dir}")); + } + catch (Exception ex) { + throw new IllegalStateException(ex); + } + } + + private void initializeProperties() throws Exception { + List configs = new ArrayList<>(); + if (getProperty(CONFIG_LOCATION) != null) { + configs.add(getProperty(CONFIG_LOCATION)); + } + else { + String[] names = getPropertyWithDefault(CONFIG_NAME, "loader").split(","); + for (String name : names) { + configs.add("file:" + getHomeDirectory() + "/" + name + ".properties"); + configs.add("classpath:" + name + ".properties"); + configs.add("classpath:BOOT-INF/classes/" + name + ".properties"); + } + } + for (String config : configs) { + try (InputStream resource = getResource(config)) { + if (resource != null) { + debug("Found: " + config); + loadResource(resource); + // Load the first one we find + return; + } + else { + debug("Not found: " + config); + } + } + } + } + + private void loadResource(InputStream resource) throws Exception { + this.properties.load(resource); + for (Object key : Collections.list(this.properties.propertyNames())) { + String text = this.properties.getProperty((String) key); + String value = SystemPropertyUtils.resolvePlaceholders(this.properties, text); + if (value != null) { + this.properties.put(key, value); + } + } + if ("true".equals(getProperty(SET_SYSTEM_PROPERTIES))) { + debug("Adding resolved properties to System properties"); + for (Object key : Collections.list(this.properties.propertyNames())) { + String value = this.properties.getProperty((String) key); + System.setProperty((String) key, value); + } + } + } + + private InputStream getResource(String config) throws Exception { + if (config.startsWith("classpath:")) { + return getClasspathResource(config.substring("classpath:".length())); + } + config = handleUrl(config); + if (isUrl(config)) { + return getURLResource(config); + } + return getFileResource(config); + } + + private String handleUrl(String path) throws UnsupportedEncodingException { + if (path.startsWith("jar:file:") || path.startsWith("file:")) { + path = URLDecoder.decode(path, "UTF-8"); + if (path.startsWith("file:")) { + path = path.substring("file:".length()); + if (path.startsWith("//")) { + path = path.substring(2); + } + } + } + return path; + } + + private boolean isUrl(String config) { + return config.contains("://"); + } + + private InputStream getClasspathResource(String config) { + while (config.startsWith("/")) { + config = config.substring(1); + } + config = "/" + config; + debug("Trying classpath: " + config); + return getClass().getResourceAsStream(config); + } + + private InputStream getFileResource(String config) throws Exception { + File file = new File(config); + debug("Trying file: " + config); + if (file.canRead()) { + return new FileInputStream(file); + } + return null; + } + + private InputStream getURLResource(String config) throws Exception { + URL url = new URL(config); + if (exists(url)) { + URLConnection con = url.openConnection(); + try { + return con.getInputStream(); + } + catch (IOException ex) { + // Close the HTTP connection (if applicable). + if (con instanceof HttpURLConnection httpURLConnection) { + httpURLConnection.disconnect(); + } + throw ex; + } + } + return null; + } + + private boolean exists(URL url) throws IOException { + // Try a URL connection content-length header... + URLConnection connection = url.openConnection(); + try { + connection.setUseCaches(connection.getClass().getSimpleName().startsWith("JNLP")); + if (connection instanceof HttpURLConnection httpConnection) { + httpConnection.setRequestMethod("HEAD"); + int responseCode = httpConnection.getResponseCode(); + if (responseCode == HttpURLConnection.HTTP_OK) { + return true; + } + else if (responseCode == HttpURLConnection.HTTP_NOT_FOUND) { + return false; + } + } + return (connection.getContentLength() >= 0); + } + finally { + if (connection instanceof HttpURLConnection httpURLConnection) { + httpURLConnection.disconnect(); + } + } + } + + private void initializePaths() throws Exception { + String path = getProperty(PATH); + if (path != null) { + this.paths = parsePathsProperty(path); + } + debug("Nested archive paths: " + this.paths); + } + + private List parsePathsProperty(String commaSeparatedPaths) { + List paths = new ArrayList<>(); + for (String path : commaSeparatedPaths.split(",")) { + path = cleanupPath(path); + // "" means the user wants root of archive but not current directory + path = (path == null || path.isEmpty()) ? "/" : path; + paths.add(path); + } + if (paths.isEmpty()) { + paths.add("lib"); + } + return paths; + } + + protected String[] getArgs(String... args) throws Exception { + String loaderArgs = getProperty(ARGS); + if (loaderArgs != null) { + String[] defaultArgs = loaderArgs.split("\\s+"); + String[] additionalArgs = args; + args = new String[defaultArgs.length + additionalArgs.length]; + System.arraycopy(defaultArgs, 0, args, 0, defaultArgs.length); + System.arraycopy(additionalArgs, 0, args, defaultArgs.length, additionalArgs.length); + } + return args; + } + + @Override + protected String getMainClass() throws Exception { + String mainClass = getProperty(MAIN, "Start-Class"); + if (mainClass == null) { + throw new IllegalStateException("No '" + MAIN + "' or 'Start-Class' specified"); + } + return mainClass; + } + + @Override + protected ClassLoader createClassLoader(Iterator archives) throws Exception { + String customLoaderClassName = getProperty("loader.classLoader"); + if (customLoaderClassName == null) { + return super.createClassLoader(archives); + } + Set urls = new LinkedHashSet<>(); + while (archives.hasNext()) { + urls.add(archives.next().getUrl()); + } + ClassLoader loader = new LaunchedURLClassLoader(urls.toArray(NO_URLS), getClass().getClassLoader()); + debug("Classpath for custom loader: " + urls); + loader = wrapWithCustomClassLoader(loader, customLoaderClassName); + debug("Using custom class loader: " + customLoaderClassName); + return loader; + } + + @SuppressWarnings("unchecked") + private ClassLoader wrapWithCustomClassLoader(ClassLoader parent, String className) throws Exception { + Class type = (Class) Class.forName(className, true, parent); + ClassLoader classLoader = newClassLoader(type, PARENT_ONLY_PARAMS, parent); + if (classLoader == null) { + classLoader = newClassLoader(type, URLS_AND_PARENT_PARAMS, NO_URLS, parent); + } + if (classLoader == null) { + classLoader = newClassLoader(type, NO_PARAMS); + } + if (classLoader == null) { + throw new IllegalArgumentException("Unable to create class loader for " + className); + } + return classLoader; + } + + private ClassLoader newClassLoader(Class loaderClass, Class[] parameterTypes, Object... initargs) + throws Exception { + try { + Constructor constructor = loaderClass.getDeclaredConstructor(parameterTypes); + constructor.setAccessible(true); + return constructor.newInstance(initargs); + } + catch (NoSuchMethodException ex) { + return null; + } + } + + private String getProperty(String propertyKey) throws Exception { + return getProperty(propertyKey, null, null); + } + + private String getProperty(String propertyKey, String manifestKey) throws Exception { + return getProperty(propertyKey, manifestKey, null); + } + + private String getPropertyWithDefault(String propertyKey, String defaultValue) throws Exception { + return getProperty(propertyKey, null, defaultValue); + } + + private String getProperty(String propertyKey, String manifestKey, String defaultValue) throws Exception { + if (manifestKey == null) { + manifestKey = propertyKey.replace('.', '-'); + manifestKey = toCamelCase(manifestKey); + } + String property = SystemPropertyUtils.getProperty(propertyKey); + if (property != null) { + String value = SystemPropertyUtils.resolvePlaceholders(this.properties, property); + debug("Property '" + propertyKey + "' from environment: " + value); + return value; + } + if (this.properties.containsKey(propertyKey)) { + String value = SystemPropertyUtils.resolvePlaceholders(this.properties, + this.properties.getProperty(propertyKey)); + debug("Property '" + propertyKey + "' from properties: " + value); + return value; + } + try { + if (this.home != null) { + // Prefer home dir for MANIFEST if there is one + try (ExplodedArchive archive = new ExplodedArchive(this.home, false)) { + Manifest manifest = archive.getManifest(); + if (manifest != null) { + String value = manifest.getMainAttributes().getValue(manifestKey); + if (value != null) { + debug("Property '" + manifestKey + "' from home directory manifest: " + value); + return SystemPropertyUtils.resolvePlaceholders(this.properties, value); + } + } + } + } + } + catch (IllegalStateException ex) { + // Ignore + } + // Otherwise try the parent archive + Manifest manifest = createArchive().getManifest(); + if (manifest != null) { + String value = manifest.getMainAttributes().getValue(manifestKey); + if (value != null) { + debug("Property '" + manifestKey + "' from archive manifest: " + value); + return SystemPropertyUtils.resolvePlaceholders(this.properties, value); + } + } + return (defaultValue != null) ? SystemPropertyUtils.resolvePlaceholders(this.properties, defaultValue) + : defaultValue; + } + + @Override + protected Iterator getClassPathArchivesIterator() throws Exception { + ClassPathArchives classPathArchives = this.classPathArchives; + if (classPathArchives == null) { + classPathArchives = new ClassPathArchives(); + this.classPathArchives = classPathArchives; + } + return classPathArchives.iterator(); + } + + public static void main(String[] args) throws Exception { + PropertiesLauncher launcher = new PropertiesLauncher(); + args = launcher.getArgs(args); + launcher.launch(args); + } + + public static String toCamelCase(CharSequence string) { + if (string == null) { + return null; + } + StringBuilder builder = new StringBuilder(); + Matcher matcher = WORD_SEPARATOR.matcher(string); + int pos = 0; + while (matcher.find()) { + builder.append(capitalize(string.subSequence(pos, matcher.end()).toString())); + pos = matcher.end(); + } + builder.append(capitalize(string.subSequence(pos, string.length()).toString())); + return builder.toString(); + } + + private static String capitalize(String str) { + return Character.toUpperCase(str.charAt(0)) + str.substring(1); + } + + private void debug(String message) { + if (Boolean.getBoolean(DEBUG)) { + System.out.println(message); + } + } + + private String cleanupPath(String path) { + path = path.trim(); + // No need for current dir path + if (path.startsWith("./")) { + path = path.substring(2); + } + String lowerCasePath = path.toLowerCase(Locale.ENGLISH); + if (lowerCasePath.endsWith(".jar") || lowerCasePath.endsWith(".zip")) { + return path; + } + if (path.endsWith("/*")) { + path = path.substring(0, path.length() - 1); + } + else { + // It's a directory + if (!path.endsWith("/") && !path.equals(".")) { + path = path + "/"; + } + } + return path; + } + + void close() throws Exception { + if (this.classPathArchives != null) { + this.classPathArchives.close(); + } + if (this.parent != null) { + this.parent.close(); + } + } + + /** + * An iterable collection of the classpath archives. + */ + private class ClassPathArchives implements Iterable { + + private final List classPathArchives; + + private final List jarFileArchives = new ArrayList<>(); + + ClassPathArchives() throws Exception { + this.classPathArchives = new ArrayList<>(); + for (String path : PropertiesLauncher.this.paths) { + for (Archive archive : getClassPathArchives(path)) { + addClassPathArchive(archive); + } + } + addNestedEntries(); + } + + private void addClassPathArchive(Archive archive) throws IOException { + if (!(archive instanceof ExplodedArchive)) { + this.classPathArchives.add(archive); + return; + } + this.classPathArchives.add(archive); + this.classPathArchives.addAll(asList(archive.getNestedArchives(null, new ArchiveEntryFilter()))); + } + + private List getClassPathArchives(String path) throws Exception { + String root = cleanupPath(handleUrl(path)); + List lib = new ArrayList<>(); + File file = new File(root); + if (!"/".equals(root)) { + if (!isAbsolutePath(root)) { + file = new File(PropertiesLauncher.this.home, root); + } + if (file.isDirectory()) { + debug("Adding classpath entries from " + file); + Archive archive = new ExplodedArchive(file, false); + lib.add(archive); + } + } + Archive archive = getArchive(file); + if (archive != null) { + debug("Adding classpath entries from archive " + archive.getUrl() + root); + lib.add(archive); + } + List nestedArchives = getNestedArchives(root); + if (nestedArchives != null) { + debug("Adding classpath entries from nested " + root); + lib.addAll(nestedArchives); + } + return lib; + } + + private boolean isAbsolutePath(String root) { + // Windows contains ":" others start with "/" + return root.contains(":") || root.startsWith("/"); + } + + private Archive getArchive(File file) throws IOException { + if (isNestedArchivePath(file)) { + return null; + } + String name = file.getName().toLowerCase(Locale.ENGLISH); + if (name.endsWith(".jar") || name.endsWith(".zip")) { + return getJarFileArchive(file); + } + return null; + } + + private boolean isNestedArchivePath(File file) { + return file.getPath().contains(NESTED_ARCHIVE_SEPARATOR); + } + + private List getNestedArchives(String path) throws Exception { + Archive parent = PropertiesLauncher.this.parent; + String root = path; + if (!root.equals("/") && root.startsWith("/") + || parent.getUrl().toURI().equals(PropertiesLauncher.this.home.toURI())) { + // If home dir is same as parent archive, no need to add it twice. + return null; + } + int index = root.indexOf('!'); + if (index != -1) { + File file = new File(PropertiesLauncher.this.home, root.substring(0, index)); + if (root.startsWith("jar:file:")) { + file = new File(root.substring("jar:file:".length(), index)); + } + parent = getJarFileArchive(file); + root = root.substring(index + 1); + while (root.startsWith("/")) { + root = root.substring(1); + } + } + if (root.endsWith(".jar")) { + File file = new File(PropertiesLauncher.this.home, root); + if (file.exists()) { + parent = getJarFileArchive(file); + root = ""; + } + } + if (root.equals("/") || root.equals("./") || root.equals(".")) { + // The prefix for nested jars is actually empty if it's at the root + root = ""; + } + EntryFilter filter = new PrefixMatchingArchiveFilter(root); + List archives = asList(parent.getNestedArchives(null, filter)); + if ((root == null || root.isEmpty() || ".".equals(root)) && !path.endsWith(".jar") + && parent != PropertiesLauncher.this.parent) { + // You can't find the root with an entry filter so it has to be added + // explicitly. But don't add the root of the parent archive. + archives.add(parent); + } + return archives; + } + + private void addNestedEntries() { + // The parent archive might have "BOOT-INF/lib/" and "BOOT-INF/classes/" + // directories, meaning we are running from an executable JAR. We add nested + // entries from there with low priority (i.e. at end). + try { + Iterator archives = PropertiesLauncher.this.parent.getNestedArchives(null, + JarLauncher.NESTED_ARCHIVE_ENTRY_FILTER); + while (archives.hasNext()) { + this.classPathArchives.add(archives.next()); + } + } + catch (IOException ex) { + // Ignore + } + } + + private List asList(Iterator iterator) { + List list = new ArrayList<>(); + while (iterator.hasNext()) { + list.add(iterator.next()); + } + return list; + } + + private JarFileArchive getJarFileArchive(File file) throws IOException { + JarFileArchive archive = new JarFileArchive(file); + this.jarFileArchives.add(archive); + return archive; + } + + @Override + public Iterator iterator() { + return this.classPathArchives.iterator(); + } + + void close() throws IOException { + for (JarFileArchive archive : this.jarFileArchives) { + archive.close(); + } + } + + } + + /** + * Convenience class for finding nested archives that have a prefix in their file path + * (e.g. "lib/"). + */ + private static final class PrefixMatchingArchiveFilter implements EntryFilter { + + private final String prefix; + + private final ArchiveEntryFilter filter = new ArchiveEntryFilter(); + + private PrefixMatchingArchiveFilter(String prefix) { + this.prefix = prefix; + } + + @Override + public boolean matches(Entry entry) { + if (entry.isDirectory()) { + return entry.getName().equals(this.prefix); + } + return entry.getName().startsWith(this.prefix) && this.filter.matches(entry); + } + + } + + /** + * Convenience class for finding nested archives (archive entries that can be + * classpath entries). + */ + private static final class ArchiveEntryFilter implements EntryFilter { + + private static final String DOT_JAR = ".jar"; + + private static final String DOT_ZIP = ".zip"; + + @Override + public boolean matches(Entry entry) { + return entry.getName().endsWith(DOT_JAR) || entry.getName().endsWith(DOT_ZIP); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/WarLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/WarLauncher.java new file mode 100644 index 0000000000..482832c1f7 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/WarLauncher.java @@ -0,0 +1,62 @@ +/* + * Copyright 2012-2023 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.loader; + +import org.springframework.boot.loader.archive.Archive; + +/** + * {@link Launcher} for WAR based archives. This launcher for standard WAR archives. + * Supports dependencies in {@code WEB-INF/lib} as well as {@code WEB-INF/lib-provided}, + * classes are loaded from {@code WEB-INF/classes}. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Scott Frederick + * @since 1.0.0 + */ +public class WarLauncher extends ExecutableArchiveLauncher { + + public WarLauncher() { + } + + protected WarLauncher(Archive archive) { + super(archive); + } + + @Override + protected boolean isPostProcessingClassPathArchives() { + return false; + } + + @Override + public boolean isNestedArchive(Archive.Entry entry) { + if (entry.isDirectory()) { + return entry.getName().equals("WEB-INF/classes/"); + } + return entry.getName().startsWith("WEB-INF/lib/") || entry.getName().startsWith("WEB-INF/lib-provided/"); + } + + @Override + protected String getArchiveEntryPathPrefix() { + return "WEB-INF/"; + } + + public static void main(String[] args) throws Exception { + new WarLauncher().launch(args); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/Archive.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/Archive.java new file mode 100644 index 0000000000..c1f2bbb2f7 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/Archive.java @@ -0,0 +1,115 @@ +/* + * Copyright 2012-2023 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.loader.archive; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Iterator; +import java.util.jar.Manifest; + +import org.springframework.boot.loader.Launcher; + +/** + * An archive that can be launched by the {@link Launcher}. + * + * @author Phillip Webb + * @since 1.0.0 + * @see JarFileArchive + */ +public interface Archive extends Iterable, AutoCloseable { + + /** + * Returns a URL that can be used to load the archive. + * @return the archive URL + * @throws MalformedURLException if the URL is malformed + */ + URL getUrl() throws MalformedURLException; + + /** + * Returns the manifest of the archive. + * @return the manifest + * @throws IOException if the manifest cannot be read + */ + Manifest getManifest() throws IOException; + + /** + * Returns nested {@link Archive}s for entries that match the specified filters. + * @param searchFilter filter used to limit when additional sub-entry searching is + * required or {@code null} if all entries should be considered. + * @param includeFilter filter used to determine which entries should be included in + * the result or {@code null} if all entries should be included + * @return the nested archives + * @throws IOException on IO error + * @since 2.3.0 + */ + Iterator getNestedArchives(EntryFilter searchFilter, EntryFilter includeFilter) throws IOException; + + /** + * Return if the archive is exploded (already unpacked). + * @return if the archive is exploded + * @since 2.3.0 + */ + default boolean isExploded() { + return false; + } + + /** + * Closes the {@code Archive}, releasing any open resources. + * @throws Exception if an error occurs during close processing + * @since 2.2.0 + */ + @Override + default void close() throws Exception { + + } + + /** + * Represents a single entry in the archive. + */ + interface Entry { + + /** + * Returns {@code true} if the entry represents a directory. + * @return if the entry is a directory + */ + boolean isDirectory(); + + /** + * Returns the name of the entry. + * @return the name of the entry + */ + String getName(); + + } + + /** + * Strategy interface to filter {@link Entry Entries}. + */ + @FunctionalInterface + interface EntryFilter { + + /** + * Apply the jar entry filter. + * @param entry the entry to filter + * @return {@code true} if the filter matches + */ + boolean matches(Entry entry); + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/ExplodedArchive.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/ExplodedArchive.java new file mode 100644 index 0000000000..f8cd52dc16 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/ExplodedArchive.java @@ -0,0 +1,342 @@ +/* + * Copyright 2012-2023 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.loader.archive; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.Deque; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.NoSuchElementException; +import java.util.Set; +import java.util.jar.Manifest; + +/** + * {@link Archive} implementation backed by an exploded archive directory. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Madhura Bhave + * @since 1.0.0 + */ +public class ExplodedArchive implements Archive { + + private static final Set SKIPPED_NAMES = new HashSet<>(Arrays.asList(".", "..")); + + private final File root; + + private final boolean recursive; + + private final File manifestFile; + + private Manifest manifest; + + /** + * Create a new {@link ExplodedArchive} instance. + * @param root the root directory + */ + public ExplodedArchive(File root) { + this(root, true); + } + + /** + * Create a new {@link ExplodedArchive} instance. + * @param root the root directory + * @param recursive if recursive searching should be used to locate the manifest. + * Defaults to {@code true}, directories with a large tree might want to set this to + * {@code false}. + */ + public ExplodedArchive(File root, boolean recursive) { + if (!root.exists() || !root.isDirectory()) { + throw new IllegalArgumentException("Invalid source directory " + root); + } + this.root = root; + this.recursive = recursive; + this.manifestFile = getManifestFile(root); + } + + private File getManifestFile(File root) { + File metaInf = new File(root, "META-INF"); + return new File(metaInf, "MANIFEST.MF"); + } + + @Override + public URL getUrl() throws MalformedURLException { + return this.root.toURI().toURL(); + } + + @Override + public Manifest getManifest() throws IOException { + if (this.manifest == null && this.manifestFile.exists()) { + try (FileInputStream inputStream = new FileInputStream(this.manifestFile)) { + this.manifest = new Manifest(inputStream); + } + } + return this.manifest; + } + + @Override + public Iterator getNestedArchives(EntryFilter searchFilter, EntryFilter includeFilter) throws IOException { + return new ArchiveIterator(this.root, this.recursive, searchFilter, includeFilter); + } + + @Override + @Deprecated(since = "2.3.10", forRemoval = false) + public Iterator iterator() { + return new EntryIterator(this.root, this.recursive, null, null); + } + + protected Archive getNestedArchive(Entry entry) { + File file = ((FileEntry) entry).getFile(); + return (file.isDirectory() ? new ExplodedArchive(file) : new SimpleJarFileArchive((FileEntry) entry)); + } + + @Override + public boolean isExploded() { + return true; + } + + @Override + public String toString() { + try { + return getUrl().toString(); + } + catch (Exception ex) { + return "exploded archive"; + } + } + + /** + * File based {@link Entry} {@link Iterator}. + */ + private abstract static class AbstractIterator implements Iterator { + + private static final Comparator entryComparator = Comparator.comparing(File::getAbsolutePath); + + private final File root; + + private final boolean recursive; + + private final EntryFilter searchFilter; + + private final EntryFilter includeFilter; + + private final Deque> stack = new LinkedList<>(); + + private FileEntry current; + + private final String rootUrl; + + AbstractIterator(File root, boolean recursive, EntryFilter searchFilter, EntryFilter includeFilter) { + this.root = root; + this.rootUrl = this.root.toURI().getPath(); + this.recursive = recursive; + this.searchFilter = searchFilter; + this.includeFilter = includeFilter; + this.stack.add(listFiles(root)); + this.current = poll(); + } + + @Override + public boolean hasNext() { + return this.current != null; + } + + @Override + public T next() { + FileEntry entry = this.current; + if (entry == null) { + throw new NoSuchElementException(); + } + this.current = poll(); + return adapt(entry); + } + + private FileEntry poll() { + while (!this.stack.isEmpty()) { + while (this.stack.peek().hasNext()) { + File file = this.stack.peek().next(); + if (SKIPPED_NAMES.contains(file.getName())) { + continue; + } + FileEntry entry = getFileEntry(file); + if (isListable(entry)) { + this.stack.addFirst(listFiles(file)); + } + if (this.includeFilter == null || this.includeFilter.matches(entry)) { + return entry; + } + } + this.stack.poll(); + } + return null; + } + + private FileEntry getFileEntry(File file) { + URI uri = file.toURI(); + String name = uri.getPath().substring(this.rootUrl.length()); + try { + return new FileEntry(name, file, uri.toURL()); + } + catch (MalformedURLException ex) { + throw new IllegalStateException(ex); + } + } + + private boolean isListable(FileEntry entry) { + return entry.isDirectory() && (this.recursive || entry.getFile().getParentFile().equals(this.root)) + && (this.searchFilter == null || this.searchFilter.matches(entry)) + && (this.includeFilter == null || !this.includeFilter.matches(entry)); + } + + private Iterator listFiles(File file) { + File[] files = file.listFiles(); + if (files == null) { + return Collections.emptyIterator(); + } + Arrays.sort(files, entryComparator); + return Arrays.asList(files).iterator(); + } + + @Override + public void remove() { + throw new UnsupportedOperationException("remove"); + } + + protected abstract T adapt(FileEntry entry); + + } + + private static class EntryIterator extends AbstractIterator { + + EntryIterator(File root, boolean recursive, EntryFilter searchFilter, EntryFilter includeFilter) { + super(root, recursive, searchFilter, includeFilter); + } + + @Override + protected Entry adapt(FileEntry entry) { + return entry; + } + + } + + private static class ArchiveIterator extends AbstractIterator { + + ArchiveIterator(File root, boolean recursive, EntryFilter searchFilter, EntryFilter includeFilter) { + super(root, recursive, searchFilter, includeFilter); + } + + @Override + protected Archive adapt(FileEntry entry) { + File file = entry.getFile(); + return (file.isDirectory() ? new ExplodedArchive(file) : new SimpleJarFileArchive(entry)); + } + + } + + /** + * {@link Entry} backed by a File. + */ + private static class FileEntry implements Entry { + + private final String name; + + private final File file; + + private final URL url; + + FileEntry(String name, File file, URL url) { + this.name = name; + this.file = file; + this.url = url; + } + + File getFile() { + return this.file; + } + + @Override + public boolean isDirectory() { + return this.file.isDirectory(); + } + + @Override + public String getName() { + return this.name; + } + + URL getUrl() { + return this.url; + } + + } + + /** + * {@link Archive} implementation backed by a simple JAR file that doesn't itself + * contain nested archives. + */ + private static class SimpleJarFileArchive implements Archive { + + private final URL url; + + SimpleJarFileArchive(FileEntry file) { + this.url = file.getUrl(); + } + + @Override + public URL getUrl() throws MalformedURLException { + return this.url; + } + + @Override + public Manifest getManifest() throws IOException { + return null; + } + + @Override + public Iterator getNestedArchives(EntryFilter searchFilter, EntryFilter includeFilter) + throws IOException { + return Collections.emptyIterator(); + } + + @Override + @Deprecated(since = "2.3.10", forRemoval = false) + public Iterator iterator() { + return Collections.emptyIterator(); + } + + @Override + public String toString() { + try { + return getUrl().toString(); + } + catch (Exception ex) { + return "jar archive"; + } + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/JarFileArchive.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/JarFileArchive.java new file mode 100755 index 0000000000..91e7bc53a4 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/JarFileArchive.java @@ -0,0 +1,310 @@ +/* + * Copyright 2012-2023 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.loader.archive; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.FileAttribute; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.EnumSet; +import java.util.Iterator; +import java.util.UUID; +import java.util.jar.JarEntry; +import java.util.jar.Manifest; + +import org.springframework.boot.loader.jar.JarFile; + +/** + * {@link Archive} implementation backed by a {@link JarFile}. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @since 1.0.0 + */ +public class JarFileArchive implements Archive { + + private static final String UNPACK_MARKER = "UNPACK:"; + + private static final int BUFFER_SIZE = 32 * 1024; + + private static final FileAttribute[] NO_FILE_ATTRIBUTES = {}; + + private static final EnumSet DIRECTORY_PERMISSIONS = EnumSet.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE); + + private static final EnumSet FILE_PERMISSIONS = EnumSet.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE); + + private final JarFile jarFile; + + private URL url; + + private Path tempUnpackDirectory; + + public JarFileArchive(File file) throws IOException { + this(file, file.toURI().toURL()); + } + + public JarFileArchive(File file, URL url) throws IOException { + this(new JarFile(file)); + this.url = url; + } + + public JarFileArchive(JarFile jarFile) { + this.jarFile = jarFile; + } + + @Override + public URL getUrl() throws MalformedURLException { + if (this.url != null) { + return this.url; + } + return this.jarFile.getUrl(); + } + + @Override + public Manifest getManifest() throws IOException { + return this.jarFile.getManifest(); + } + + @Override + public Iterator getNestedArchives(EntryFilter searchFilter, EntryFilter includeFilter) throws IOException { + return new NestedArchiveIterator(this.jarFile.iterator(), searchFilter, includeFilter); + } + + @Override + @Deprecated(since = "2.3.10", forRemoval = false) + public Iterator iterator() { + return new EntryIterator(this.jarFile.iterator(), null, null); + } + + @Override + public void close() throws IOException { + this.jarFile.close(); + } + + protected Archive getNestedArchive(Entry entry) throws IOException { + JarEntry jarEntry = ((JarFileEntry) entry).getJarEntry(); + if (jarEntry.getComment().startsWith(UNPACK_MARKER)) { + return getUnpackedNestedArchive(jarEntry); + } + try { + JarFile jarFile = this.jarFile.getNestedJarFile(jarEntry); + return new JarFileArchive(jarFile); + } + catch (Exception ex) { + throw new IllegalStateException("Failed to get nested archive for entry " + entry.getName(), ex); + } + } + + private Archive getUnpackedNestedArchive(JarEntry jarEntry) throws IOException { + String name = jarEntry.getName(); + if (name.lastIndexOf('/') != -1) { + name = name.substring(name.lastIndexOf('/') + 1); + } + Path path = getTempUnpackDirectory().resolve(name); + if (!Files.exists(path) || Files.size(path) != jarEntry.getSize()) { + unpack(jarEntry, path); + } + return new JarFileArchive(path.toFile(), path.toUri().toURL()); + } + + private Path getTempUnpackDirectory() { + if (this.tempUnpackDirectory == null) { + Path tempDirectory = Paths.get(System.getProperty("java.io.tmpdir")); + this.tempUnpackDirectory = createUnpackDirectory(tempDirectory); + } + return this.tempUnpackDirectory; + } + + private Path createUnpackDirectory(Path parent) { + int attempts = 0; + while (attempts++ < 1000) { + String fileName = Paths.get(this.jarFile.getName()).getFileName().toString(); + Path unpackDirectory = parent.resolve(fileName + "-spring-boot-libs-" + UUID.randomUUID()); + try { + createDirectory(unpackDirectory); + return unpackDirectory; + } + catch (IOException ex) { + } + } + throw new IllegalStateException("Failed to create unpack directory in directory '" + parent + "'"); + } + + private void unpack(JarEntry entry, Path path) throws IOException { + createFile(path); + path.toFile().deleteOnExit(); + try (InputStream inputStream = this.jarFile.getInputStream(entry); + OutputStream outputStream = Files.newOutputStream(path, StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING)) { + byte[] buffer = new byte[BUFFER_SIZE]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + outputStream.flush(); + } + } + + private void createDirectory(Path path) throws IOException { + Files.createDirectory(path, getFileAttributes(path.getFileSystem(), DIRECTORY_PERMISSIONS)); + } + + private void createFile(Path path) throws IOException { + Files.createFile(path, getFileAttributes(path.getFileSystem(), FILE_PERMISSIONS)); + } + + private FileAttribute[] getFileAttributes(FileSystem fileSystem, EnumSet ownerReadWrite) { + if (!fileSystem.supportedFileAttributeViews().contains("posix")) { + return NO_FILE_ATTRIBUTES; + } + return new FileAttribute[] { PosixFilePermissions.asFileAttribute(ownerReadWrite) }; + } + + @Override + public String toString() { + try { + return getUrl().toString(); + } + catch (Exception ex) { + return "jar archive"; + } + } + + /** + * Abstract base class for iterator implementations. + */ + private abstract static class AbstractIterator implements Iterator { + + private final Iterator iterator; + + private final EntryFilter searchFilter; + + private final EntryFilter includeFilter; + + private Entry current; + + AbstractIterator(Iterator iterator, EntryFilter searchFilter, EntryFilter includeFilter) { + this.iterator = iterator; + this.searchFilter = searchFilter; + this.includeFilter = includeFilter; + this.current = poll(); + } + + @Override + public boolean hasNext() { + return this.current != null; + } + + @Override + public T next() { + T result = adapt(this.current); + this.current = poll(); + return result; + } + + private Entry poll() { + while (this.iterator.hasNext()) { + JarFileEntry candidate = new JarFileEntry(this.iterator.next()); + if ((this.searchFilter == null || this.searchFilter.matches(candidate)) + && (this.includeFilter == null || this.includeFilter.matches(candidate))) { + return candidate; + } + } + return null; + } + + protected abstract T adapt(Entry entry); + + } + + /** + * {@link Archive.Entry} iterator implementation backed by {@link JarEntry}. + */ + private static class EntryIterator extends AbstractIterator { + + EntryIterator(Iterator iterator, EntryFilter searchFilter, EntryFilter includeFilter) { + super(iterator, searchFilter, includeFilter); + } + + @Override + protected Entry adapt(Entry entry) { + return entry; + } + + } + + /** + * Nested {@link Archive} iterator implementation backed by {@link JarEntry}. + */ + private class NestedArchiveIterator extends AbstractIterator { + + NestedArchiveIterator(Iterator iterator, EntryFilter searchFilter, EntryFilter includeFilter) { + super(iterator, searchFilter, includeFilter); + } + + @Override + protected Archive adapt(Entry entry) { + try { + return getNestedArchive(entry); + } + catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + + } + + /** + * {@link Archive.Entry} implementation backed by a {@link JarEntry}. + */ + private static class JarFileEntry implements Entry { + + private final JarEntry jarEntry; + + JarFileEntry(JarEntry jarEntry) { + this.jarEntry = jarEntry; + } + + JarEntry getJarEntry() { + return this.jarEntry; + } + + @Override + public boolean isDirectory() { + return this.jarEntry.isDirectory(); + } + + @Override + public String getName() { + return this.jarEntry.getName(); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/package-info.java new file mode 100644 index 0000000000..27ce99b006 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright 2012-2023 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. + */ + +/** + * Abstraction over logical Archives be they backed by a JAR file or unpacked into a + * directory. + * + * @see org.springframework.boot.loader.archive.Archive + */ +package org.springframework.boot.loader.archive; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/data/RandomAccessData.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/data/RandomAccessData.java new file mode 100644 index 0000000000..e96d5ea81a --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/data/RandomAccessData.java @@ -0,0 +1,74 @@ +/* + * Copyright 2012-2023 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.loader.data; + +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; + +/** + * Interface that provides read-only random access to some underlying data. + * Implementations must allow concurrent reads in a thread-safe manner. + * + * @author Phillip Webb + * @since 1.0.0 + */ +public interface RandomAccessData { + + /** + * Returns an {@link InputStream} that can be used to read the underlying data. The + * caller is responsible close the underlying stream. + * @return a new input stream that can be used to read the underlying data. + * @throws IOException if the stream cannot be opened + */ + InputStream getInputStream() throws IOException; + + /** + * Returns a new {@link RandomAccessData} for a specific subsection of this data. + * @param offset the offset of the subsection + * @param length the length of the subsection + * @return the subsection data + */ + RandomAccessData getSubsection(long offset, long length); + + /** + * Reads all the data and returns it as a byte array. + * @return the data + * @throws IOException if the data cannot be read + */ + byte[] read() throws IOException; + + /** + * Reads the {@code length} bytes of data starting at the given {@code offset}. + * @param offset the offset from which data should be read + * @param length the number of bytes to be read + * @return the data + * @throws IOException if the data cannot be read + * @throws IndexOutOfBoundsException if offset is beyond the end of the file or + * subsection + * @throws EOFException if offset plus length is greater than the length of the file + * or subsection + */ + byte[] read(long offset, long length) throws IOException; + + /** + * Returns the size of the data. + * @return the size + */ + long getSize(); + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/data/RandomAccessDataFile.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/data/RandomAccessDataFile.java new file mode 100644 index 0000000000..4bd5d20541 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/data/RandomAccessDataFile.java @@ -0,0 +1,262 @@ +/* + * Copyright 2012-2023 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.loader.data; + +import java.io.EOFException; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.RandomAccessFile; + +/** + * {@link RandomAccessData} implementation backed by a {@link RandomAccessFile}. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @since 1.0.0 + */ +public class RandomAccessDataFile implements RandomAccessData { + + private final FileAccess fileAccess; + + private final long offset; + + private final long length; + + /** + * Create a new {@link RandomAccessDataFile} backed by the specified file. + * @param file the underlying file + * @throws IllegalArgumentException if the file is null or does not exist + */ + public RandomAccessDataFile(File file) { + if (file == null) { + throw new IllegalArgumentException("File must not be null"); + } + this.fileAccess = new FileAccess(file); + this.offset = 0L; + this.length = file.length(); + } + + /** + * Private constructor used to create a {@link #getSubsection(long, long) subsection}. + * @param fileAccess provides access to the underlying file + * @param offset the offset of the section + * @param length the length of the section + */ + private RandomAccessDataFile(FileAccess fileAccess, long offset, long length) { + this.fileAccess = fileAccess; + this.offset = offset; + this.length = length; + } + + /** + * Returns the underlying File. + * @return the underlying file + */ + public File getFile() { + return this.fileAccess.file; + } + + @Override + public InputStream getInputStream() throws IOException { + return new DataInputStream(); + } + + @Override + public RandomAccessData getSubsection(long offset, long length) { + if (offset < 0 || length < 0 || offset + length > this.length) { + throw new IndexOutOfBoundsException(); + } + return new RandomAccessDataFile(this.fileAccess, this.offset + offset, length); + } + + @Override + public byte[] read() throws IOException { + return read(0, this.length); + } + + @Override + public byte[] read(long offset, long length) throws IOException { + if (offset > this.length) { + throw new IndexOutOfBoundsException(); + } + if (offset + length > this.length) { + throw new EOFException(); + } + byte[] bytes = new byte[(int) length]; + read(bytes, offset, 0, bytes.length); + return bytes; + } + + private int readByte(long position) throws IOException { + if (position >= this.length) { + return -1; + } + return this.fileAccess.readByte(this.offset + position); + } + + private int read(byte[] bytes, long position, int offset, int length) throws IOException { + if (position > this.length) { + return -1; + } + return this.fileAccess.read(bytes, this.offset + position, offset, length); + } + + @Override + public long getSize() { + return this.length; + } + + public void close() throws IOException { + this.fileAccess.close(); + } + + /** + * {@link InputStream} implementation for the {@link RandomAccessDataFile}. + */ + private class DataInputStream extends InputStream { + + private int position; + + @Override + public int read() throws IOException { + int read = RandomAccessDataFile.this.readByte(this.position); + if (read > -1) { + moveOn(1); + } + return read; + } + + @Override + public int read(byte[] b) throws IOException { + return read(b, 0, (b != null) ? b.length : 0); + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + if (b == null) { + throw new NullPointerException("Bytes must not be null"); + } + return doRead(b, off, len); + } + + /** + * Perform the actual read. + * @param b the bytes to read or {@code null} when reading a single byte + * @param off the offset of the byte array + * @param len the length of data to read + * @return the number of bytes read into {@code b} or the actual read byte if + * {@code b} is {@code null}. Returns -1 when the end of the stream is reached + * @throws IOException in case of I/O errors + */ + int doRead(byte[] b, int off, int len) throws IOException { + if (len == 0) { + return 0; + } + int cappedLen = cap(len); + if (cappedLen <= 0) { + return -1; + } + return (int) moveOn(RandomAccessDataFile.this.read(b, this.position, off, cappedLen)); + } + + @Override + public long skip(long n) throws IOException { + return (n <= 0) ? 0 : moveOn(cap(n)); + } + + @Override + public int available() throws IOException { + return (int) RandomAccessDataFile.this.length - this.position; + } + + /** + * Cap the specified value such that it cannot exceed the number of bytes + * remaining. + * @param n the value to cap + * @return the capped value + */ + private int cap(long n) { + return (int) Math.min(RandomAccessDataFile.this.length - this.position, n); + } + + /** + * Move the stream position forwards the specified amount. + * @param amount the amount to move + * @return the amount moved + */ + private long moveOn(int amount) { + this.position += amount; + return amount; + } + + } + + private static final class FileAccess { + + private final Object monitor = new Object(); + + private final File file; + + private RandomAccessFile randomAccessFile; + + private FileAccess(File file) { + this.file = file; + openIfNecessary(); + } + + private int read(byte[] bytes, long position, int offset, int length) throws IOException { + synchronized (this.monitor) { + openIfNecessary(); + this.randomAccessFile.seek(position); + return this.randomAccessFile.read(bytes, offset, length); + } + } + + private void openIfNecessary() { + if (this.randomAccessFile == null) { + try { + this.randomAccessFile = new RandomAccessFile(this.file, "r"); + } + catch (FileNotFoundException ex) { + throw new IllegalArgumentException( + String.format("File %s must exist", this.file.getAbsolutePath())); + } + } + } + + private void close() throws IOException { + synchronized (this.monitor) { + if (this.randomAccessFile != null) { + this.randomAccessFile.close(); + this.randomAccessFile = null; + } + } + } + + private int readByte(long position) throws IOException { + synchronized (this.monitor) { + openIfNecessary(); + this.randomAccessFile.seek(position); + return this.randomAccessFile.read(); + } + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/data/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/data/package-info.java new file mode 100644 index 0000000000..34bf2ead43 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/data/package-info.java @@ -0,0 +1,22 @@ +/* + * Copyright 2012-2023 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. + */ + +/** + * Classes and interfaces to allow random access to a block of data. + * + * @see org.springframework.boot.loader.data.RandomAccessData + */ +package org.springframework.boot.loader.data; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/AbstractJarFile.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/AbstractJarFile.java new file mode 100644 index 0000000000..6a98ef6821 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/AbstractJarFile.java @@ -0,0 +1,78 @@ +/* + * Copyright 2012-2023 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.loader.jar; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URL; +import java.security.Permission; + +/** + * Base class for extended variants of {@link java.util.jar.JarFile}. + * + * @author Phillip Webb + */ +abstract class AbstractJarFile extends java.util.jar.JarFile { + + /** + * Create a new {@link AbstractJarFile}. + * @param file the root jar file. + * @throws IOException on IO error + */ + AbstractJarFile(File file) throws IOException { + super(file); + } + + /** + * Return a URL that can be used to access this JAR file. NOTE: the specified URL + * cannot be serialized and or cloned. + * @return the URL + * @throws MalformedURLException if the URL is malformed + */ + abstract URL getUrl() throws MalformedURLException; + + /** + * Return the {@link JarFileType} of this instance. + * @return the jar file type + */ + abstract JarFileType getType(); + + /** + * Return the security permission for this JAR. + * @return the security permission. + */ + abstract Permission getPermission(); + + /** + * Return an {@link InputStream} for the entire jar contents. + * @return the contents input stream + * @throws IOException on IO error + */ + abstract InputStream getInputStream() throws IOException; + + /** + * The type of a {@link JarFile}. + */ + enum JarFileType { + + DIRECT, NESTED_DIRECTORY, NESTED_JAR + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/AsciiBytes.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/AsciiBytes.java new file mode 100644 index 0000000000..cfe121b689 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/AsciiBytes.java @@ -0,0 +1,255 @@ +/* + * Copyright 2012-2023 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.loader.jar; + +import java.nio.charset.StandardCharsets; + +/** + * Simple wrapper around a byte array that represents an ASCII. Used for performance + * reasons to save constructing Strings for ZIP data. + * + * @author Phillip Webb + * @author Andy Wilkinson + */ +final class AsciiBytes { + + private static final String EMPTY_STRING = ""; + + private static final int[] INITIAL_BYTE_BITMASK = { 0x7F, 0x1F, 0x0F, 0x07 }; + + private static final int SUBSEQUENT_BYTE_BITMASK = 0x3F; + + private final byte[] bytes; + + private final int offset; + + private final int length; + + private String string; + + private int hash; + + /** + * Create a new {@link AsciiBytes} from the specified String. + * @param string the source string + */ + AsciiBytes(String string) { + this(string.getBytes(StandardCharsets.UTF_8)); + this.string = string; + } + + /** + * Create a new {@link AsciiBytes} from the specified bytes. NOTE: underlying bytes + * are not expected to change. + * @param bytes the source bytes + */ + AsciiBytes(byte[] bytes) { + this(bytes, 0, bytes.length); + } + + /** + * Create a new {@link AsciiBytes} from the specified bytes. NOTE: underlying bytes + * are not expected to change. + * @param bytes the source bytes + * @param offset the offset + * @param length the length + */ + AsciiBytes(byte[] bytes, int offset, int length) { + if (offset < 0 || length < 0 || (offset + length) > bytes.length) { + throw new IndexOutOfBoundsException(); + } + this.bytes = bytes; + this.offset = offset; + this.length = length; + } + + int length() { + return this.length; + } + + boolean startsWith(AsciiBytes prefix) { + if (this == prefix) { + return true; + } + if (prefix.length > this.length) { + return false; + } + for (int i = 0; i < prefix.length; i++) { + if (this.bytes[i + this.offset] != prefix.bytes[i + prefix.offset]) { + return false; + } + } + return true; + } + + boolean endsWith(AsciiBytes postfix) { + if (this == postfix) { + return true; + } + if (postfix.length > this.length) { + return false; + } + for (int i = 0; i < postfix.length; i++) { + if (this.bytes[this.offset + (this.length - 1) - i] != postfix.bytes[postfix.offset + (postfix.length - 1) + - i]) { + return false; + } + } + return true; + } + + AsciiBytes substring(int beginIndex) { + return substring(beginIndex, this.length); + } + + AsciiBytes substring(int beginIndex, int endIndex) { + int length = endIndex - beginIndex; + if (this.offset + length > this.bytes.length) { + throw new IndexOutOfBoundsException(); + } + return new AsciiBytes(this.bytes, this.offset + beginIndex, length); + } + + boolean matches(CharSequence name, char suffix) { + int charIndex = 0; + int nameLen = name.length(); + int totalLen = nameLen + ((suffix != 0) ? 1 : 0); + for (int i = this.offset; i < this.offset + this.length; i++) { + int b = this.bytes[i]; + int remainingUtfBytes = getNumberOfUtfBytes(b) - 1; + b &= INITIAL_BYTE_BITMASK[remainingUtfBytes]; + for (int j = 0; j < remainingUtfBytes; j++) { + b = (b << 6) + (this.bytes[++i] & SUBSEQUENT_BYTE_BITMASK); + } + char c = getChar(name, suffix, charIndex++); + if (b <= 0xFFFF) { + if (c != b) { + return false; + } + } + else { + if (c != ((b >> 0xA) + 0xD7C0)) { + return false; + } + c = getChar(name, suffix, charIndex++); + if (c != ((b & 0x3FF) + 0xDC00)) { + return false; + } + } + } + return charIndex == totalLen; + } + + private char getChar(CharSequence name, char suffix, int index) { + if (index < name.length()) { + return name.charAt(index); + } + if (index == name.length()) { + return suffix; + } + return 0; + } + + private int getNumberOfUtfBytes(int b) { + if ((b & 0x80) == 0) { + return 1; + } + int numberOfUtfBytes = 0; + while ((b & 0x80) != 0) { + b <<= 1; + numberOfUtfBytes++; + } + return numberOfUtfBytes; + } + + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } + if (this == obj) { + return true; + } + if (obj.getClass() == AsciiBytes.class) { + AsciiBytes other = (AsciiBytes) obj; + if (this.length == other.length) { + for (int i = 0; i < this.length; i++) { + if (this.bytes[this.offset + i] != other.bytes[other.offset + i]) { + return false; + } + } + return true; + } + } + return false; + } + + @Override + public int hashCode() { + int hash = this.hash; + if (hash == 0 && this.bytes.length > 0) { + for (int i = this.offset; i < this.offset + this.length; i++) { + int b = this.bytes[i]; + int remainingUtfBytes = getNumberOfUtfBytes(b) - 1; + b &= INITIAL_BYTE_BITMASK[remainingUtfBytes]; + for (int j = 0; j < remainingUtfBytes; j++) { + b = (b << 6) + (this.bytes[++i] & SUBSEQUENT_BYTE_BITMASK); + } + if (b <= 0xFFFF) { + hash = 31 * hash + b; + } + else { + hash = 31 * hash + ((b >> 0xA) + 0xD7C0); + hash = 31 * hash + ((b & 0x3FF) + 0xDC00); + } + } + this.hash = hash; + } + return hash; + } + + @Override + public String toString() { + if (this.string == null) { + if (this.length == 0) { + this.string = EMPTY_STRING; + } + else { + this.string = new String(this.bytes, this.offset, this.length, StandardCharsets.UTF_8); + } + } + return this.string; + } + + static String toString(byte[] bytes) { + return new String(bytes, StandardCharsets.UTF_8); + } + + static int hashCode(CharSequence charSequence) { + // We're compatible with String's hashCode() + if (charSequence instanceof StringSequence) { + // ... but save making an unnecessary String for StringSequence + return charSequence.hashCode(); + } + return charSequence.toString().hashCode(); + } + + static int hashCode(int hash, char suffix) { + return (suffix != 0) ? (31 * hash + suffix) : hash; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/Bytes.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/Bytes.java new file mode 100644 index 0000000000..d46a22555d --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/Bytes.java @@ -0,0 +1,37 @@ +/* + * Copyright 2012-2023 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.loader.jar; + +/** + * Utilities for dealing with bytes from ZIP files. + * + * @author Phillip Webb + */ +final class Bytes { + + private Bytes() { + } + + static long littleEndianValue(byte[] bytes, int offset, int length) { + long value = 0; + for (int i = length - 1; i >= 0; i--) { + value = ((value << 8) | (bytes[offset + i] & 0xFF)); + } + return value; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryEndRecord.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryEndRecord.java new file mode 100644 index 0000000000..61db0b73f4 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryEndRecord.java @@ -0,0 +1,258 @@ +/* + * Copyright 2012-2023 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.loader.jar; + +import java.io.IOException; + +import org.springframework.boot.loader.data.RandomAccessData; + +/** + * A ZIP File "End of central directory record" (EOCD). + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Camille Vienot + * @see Zip File Format + */ +class CentralDirectoryEndRecord { + + private static final int MINIMUM_SIZE = 22; + + private static final int MAXIMUM_COMMENT_LENGTH = 0xFFFF; + + private static final int MAXIMUM_SIZE = MINIMUM_SIZE + MAXIMUM_COMMENT_LENGTH; + + private static final int SIGNATURE = 0x06054b50; + + private static final int COMMENT_LENGTH_OFFSET = 20; + + private static final int READ_BLOCK_SIZE = 256; + + private final Zip64End zip64End; + + private byte[] block; + + private int offset; + + private int size; + + /** + * Create a new {@link CentralDirectoryEndRecord} instance from the specified + * {@link RandomAccessData}, searching backwards from the end until a valid block is + * located. + * @param data the source data + * @throws IOException in case of I/O errors + */ + CentralDirectoryEndRecord(RandomAccessData data) throws IOException { + this.block = createBlockFromEndOfData(data, READ_BLOCK_SIZE); + this.size = MINIMUM_SIZE; + this.offset = this.block.length - this.size; + while (!isValid()) { + this.size++; + if (this.size > this.block.length) { + if (this.size >= MAXIMUM_SIZE || this.size > data.getSize()) { + throw new IOException( + "Unable to find ZIP central directory records after reading " + this.size + " bytes"); + } + this.block = createBlockFromEndOfData(data, this.size + READ_BLOCK_SIZE); + } + this.offset = this.block.length - this.size; + } + long startOfCentralDirectoryEndRecord = data.getSize() - this.size; + Zip64Locator zip64Locator = Zip64Locator.find(data, startOfCentralDirectoryEndRecord); + this.zip64End = (zip64Locator != null) ? new Zip64End(data, zip64Locator) : null; + } + + private byte[] createBlockFromEndOfData(RandomAccessData data, int size) throws IOException { + int length = (int) Math.min(data.getSize(), size); + return data.read(data.getSize() - length, length); + } + + private boolean isValid() { + if (this.block.length < MINIMUM_SIZE || Bytes.littleEndianValue(this.block, this.offset + 0, 4) != SIGNATURE) { + return false; + } + // Total size must be the structure size + comment + long commentLength = Bytes.littleEndianValue(this.block, this.offset + COMMENT_LENGTH_OFFSET, 2); + return this.size == MINIMUM_SIZE + commentLength; + } + + /** + * Returns the location in the data that the archive actually starts. For most files + * the archive data will start at 0, however, it is possible to have prefixed bytes + * (often used for startup scripts) at the beginning of the data. + * @param data the source data + * @return the offset within the data where the archive begins + */ + long getStartOfArchive(RandomAccessData data) { + long length = Bytes.littleEndianValue(this.block, this.offset + 12, 4); + long specifiedOffset = (this.zip64End != null) ? this.zip64End.centralDirectoryOffset + : Bytes.littleEndianValue(this.block, this.offset + 16, 4); + long zip64EndSize = (this.zip64End != null) ? this.zip64End.getSize() : 0L; + int zip64LocSize = (this.zip64End != null) ? Zip64Locator.ZIP64_LOCSIZE : 0; + long actualOffset = data.getSize() - this.size - length - zip64EndSize - zip64LocSize; + return actualOffset - specifiedOffset; + } + + /** + * Return the bytes of the "Central directory" based on the offset indicated in this + * record. + * @param data the source data + * @return the central directory data + */ + RandomAccessData getCentralDirectory(RandomAccessData data) { + if (this.zip64End != null) { + return this.zip64End.getCentralDirectory(data); + } + long offset = Bytes.littleEndianValue(this.block, this.offset + 16, 4); + long length = Bytes.littleEndianValue(this.block, this.offset + 12, 4); + return data.getSubsection(offset, length); + } + + /** + * Return the number of ZIP entries in the file. + * @return the number of records in the zip + */ + int getNumberOfRecords() { + if (this.zip64End != null) { + return this.zip64End.getNumberOfRecords(); + } + long numberOfRecords = Bytes.littleEndianValue(this.block, this.offset + 10, 2); + return (int) numberOfRecords; + } + + String getComment() { + int commentLength = (int) Bytes.littleEndianValue(this.block, this.offset + COMMENT_LENGTH_OFFSET, 2); + AsciiBytes comment = new AsciiBytes(this.block, this.offset + COMMENT_LENGTH_OFFSET + 2, commentLength); + return comment.toString(); + } + + boolean isZip64() { + return this.zip64End != null; + } + + /** + * A Zip64 end of central directory record. + * + * @see Chapter + * 4.3.14 of Zip64 specification + */ + private static final class Zip64End { + + private static final int ZIP64_ENDTOT = 32; // total number of entries + + private static final int ZIP64_ENDSIZ = 40; // central directory size in bytes + + private static final int ZIP64_ENDOFF = 48; // offset of first CEN header + + private final Zip64Locator locator; + + private final long centralDirectoryOffset; + + private final long centralDirectoryLength; + + private final int numberOfRecords; + + private Zip64End(RandomAccessData data, Zip64Locator locator) throws IOException { + this.locator = locator; + byte[] block = data.read(locator.getZip64EndOffset(), 56); + this.centralDirectoryOffset = Bytes.littleEndianValue(block, ZIP64_ENDOFF, 8); + this.centralDirectoryLength = Bytes.littleEndianValue(block, ZIP64_ENDSIZ, 8); + this.numberOfRecords = (int) Bytes.littleEndianValue(block, ZIP64_ENDTOT, 8); + } + + /** + * Return the size of this zip 64 end of central directory record. + * @return size of this zip 64 end of central directory record + */ + private long getSize() { + return this.locator.getZip64EndSize(); + } + + /** + * Return the bytes of the "Central directory" based on the offset indicated in + * this record. + * @param data the source data + * @return the central directory data + */ + private RandomAccessData getCentralDirectory(RandomAccessData data) { + return data.getSubsection(this.centralDirectoryOffset, this.centralDirectoryLength); + } + + /** + * Return the number of entries in the zip64 archive. + * @return the number of records in the zip + */ + private int getNumberOfRecords() { + return this.numberOfRecords; + } + + } + + /** + * A Zip64 end of central directory locator. + * + * @see Chapter + * 4.3.15 of Zip64 specification + */ + private static final class Zip64Locator { + + static final int SIGNATURE = 0x07064b50; + + static final int ZIP64_LOCSIZE = 20; // locator size + + static final int ZIP64_LOCOFF = 8; // offset of zip64 end + + private final long zip64EndOffset; + + private final long offset; + + private Zip64Locator(long offset, byte[] block) { + this.offset = offset; + this.zip64EndOffset = Bytes.littleEndianValue(block, ZIP64_LOCOFF, 8); + } + + /** + * Return the size of the zip 64 end record located by this zip64 end locator. + * @return size of the zip 64 end record located by this zip64 end locator + */ + private long getZip64EndSize() { + return this.offset - this.zip64EndOffset; + } + + /** + * Return the offset to locate {@link Zip64End}. + * @return offset of the Zip64 end of central directory record + */ + private long getZip64EndOffset() { + return this.zip64EndOffset; + } + + private static Zip64Locator find(RandomAccessData data, long centralDirectoryEndOffset) throws IOException { + long offset = centralDirectoryEndOffset - ZIP64_LOCSIZE; + if (offset >= 0) { + byte[] block = data.read(offset, ZIP64_LOCSIZE); + if (Bytes.littleEndianValue(block, 0, 4) == SIGNATURE) { + return new Zip64Locator(offset, block); + } + } + return null; + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryFileHeader.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryFileHeader.java new file mode 100644 index 0000000000..19c88dda52 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryFileHeader.java @@ -0,0 +1,222 @@ +/* + * Copyright 2012-2023 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.loader.jar; + +import java.io.IOException; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoField; +import java.time.temporal.ChronoUnit; +import java.time.temporal.ValueRange; + +import org.springframework.boot.loader.data.RandomAccessData; + +/** + * A ZIP File "Central directory file header record" (CDFH). + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Dmytro Nosan + * @see Zip File Format + */ + +final class CentralDirectoryFileHeader implements FileHeader { + + private static final AsciiBytes SLASH = new AsciiBytes("/"); + + private static final byte[] NO_EXTRA = {}; + + private static final AsciiBytes NO_COMMENT = new AsciiBytes(""); + + private byte[] header; + + private int headerOffset; + + private AsciiBytes name; + + private byte[] extra; + + private AsciiBytes comment; + + private long localHeaderOffset; + + CentralDirectoryFileHeader() { + } + + CentralDirectoryFileHeader(byte[] header, int headerOffset, AsciiBytes name, byte[] extra, AsciiBytes comment, + long localHeaderOffset) { + this.header = header; + this.headerOffset = headerOffset; + this.name = name; + this.extra = extra; + this.comment = comment; + this.localHeaderOffset = localHeaderOffset; + } + + void load(byte[] data, int dataOffset, RandomAccessData variableData, long variableOffset, JarEntryFilter filter) + throws IOException { + // Load fixed part + this.header = data; + this.headerOffset = dataOffset; + long compressedSize = Bytes.littleEndianValue(data, dataOffset + 20, 4); + long uncompressedSize = Bytes.littleEndianValue(data, dataOffset + 24, 4); + long nameLength = Bytes.littleEndianValue(data, dataOffset + 28, 2); + long extraLength = Bytes.littleEndianValue(data, dataOffset + 30, 2); + long commentLength = Bytes.littleEndianValue(data, dataOffset + 32, 2); + long localHeaderOffset = Bytes.littleEndianValue(data, dataOffset + 42, 4); + // Load variable part + dataOffset += 46; + if (variableData != null) { + data = variableData.read(variableOffset + 46, nameLength + extraLength + commentLength); + dataOffset = 0; + } + this.name = new AsciiBytes(data, dataOffset, (int) nameLength); + if (filter != null) { + this.name = filter.apply(this.name); + } + this.extra = NO_EXTRA; + this.comment = NO_COMMENT; + if (extraLength > 0) { + this.extra = new byte[(int) extraLength]; + System.arraycopy(data, (int) (dataOffset + nameLength), this.extra, 0, this.extra.length); + } + this.localHeaderOffset = getLocalHeaderOffset(compressedSize, uncompressedSize, localHeaderOffset, this.extra); + if (commentLength > 0) { + this.comment = new AsciiBytes(data, (int) (dataOffset + nameLength + extraLength), (int) commentLength); + } + } + + private long getLocalHeaderOffset(long compressedSize, long uncompressedSize, long localHeaderOffset, byte[] extra) + throws IOException { + if (localHeaderOffset != 0xFFFFFFFFL) { + return localHeaderOffset; + } + int extraOffset = 0; + while (extraOffset < extra.length - 2) { + int id = (int) Bytes.littleEndianValue(extra, extraOffset, 2); + int length = (int) Bytes.littleEndianValue(extra, extraOffset, 2); + extraOffset += 4; + if (id == 1) { + int localHeaderExtraOffset = 0; + if (compressedSize == 0xFFFFFFFFL) { + localHeaderExtraOffset += 4; + } + if (uncompressedSize == 0xFFFFFFFFL) { + localHeaderExtraOffset += 4; + } + return Bytes.littleEndianValue(extra, extraOffset + localHeaderExtraOffset, 8); + } + extraOffset += length; + } + throw new IOException("Zip64 Extended Information Extra Field not found"); + } + + AsciiBytes getName() { + return this.name; + } + + @Override + public boolean hasName(CharSequence name, char suffix) { + return this.name.matches(name, suffix); + } + + boolean isDirectory() { + return this.name.endsWith(SLASH); + } + + @Override + public int getMethod() { + return (int) Bytes.littleEndianValue(this.header, this.headerOffset + 10, 2); + } + + long getTime() { + long datetime = Bytes.littleEndianValue(this.header, this.headerOffset + 12, 4); + return decodeMsDosFormatDateTime(datetime); + } + + /** + * Decode MS-DOS Date Time details. See + * Microsoft's documentation for more details of the format. + * @param datetime the date and time + * @return the date and time as milliseconds since the epoch + */ + private long decodeMsDosFormatDateTime(long datetime) { + int year = getChronoValue(((datetime >> 25) & 0x7f) + 1980, ChronoField.YEAR); + int month = getChronoValue((datetime >> 21) & 0x0f, ChronoField.MONTH_OF_YEAR); + int day = getChronoValue((datetime >> 16) & 0x1f, ChronoField.DAY_OF_MONTH); + int hour = getChronoValue((datetime >> 11) & 0x1f, ChronoField.HOUR_OF_DAY); + int minute = getChronoValue((datetime >> 5) & 0x3f, ChronoField.MINUTE_OF_HOUR); + int second = getChronoValue((datetime << 1) & 0x3e, ChronoField.SECOND_OF_MINUTE); + return ZonedDateTime.of(year, month, day, hour, minute, second, 0, ZoneId.systemDefault()) + .toInstant() + .truncatedTo(ChronoUnit.SECONDS) + .toEpochMilli(); + } + + long getCrc() { + return Bytes.littleEndianValue(this.header, this.headerOffset + 16, 4); + } + + @Override + public long getCompressedSize() { + return Bytes.littleEndianValue(this.header, this.headerOffset + 20, 4); + } + + @Override + public long getSize() { + return Bytes.littleEndianValue(this.header, this.headerOffset + 24, 4); + } + + byte[] getExtra() { + return this.extra; + } + + boolean hasExtra() { + return this.extra.length > 0; + } + + AsciiBytes getComment() { + return this.comment; + } + + @Override + public long getLocalHeaderOffset() { + return this.localHeaderOffset; + } + + @Override + public CentralDirectoryFileHeader clone() { + byte[] header = new byte[46]; + System.arraycopy(this.header, this.headerOffset, header, 0, header.length); + return new CentralDirectoryFileHeader(header, 0, this.name, header, this.comment, this.localHeaderOffset); + } + + static CentralDirectoryFileHeader fromRandomAccessData(RandomAccessData data, long offset, JarEntryFilter filter) + throws IOException { + CentralDirectoryFileHeader fileHeader = new CentralDirectoryFileHeader(); + byte[] bytes = data.read(offset, 46); + fileHeader.load(bytes, 0, data, offset, filter); + return fileHeader; + } + + private static int getChronoValue(long value, ChronoField field) { + ValueRange range = field.range(); + return Math.toIntExact(Math.min(Math.max(value, range.getMinimum()), range.getMaximum())); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryParser.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryParser.java new file mode 100644 index 0000000000..eff96a56e2 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryParser.java @@ -0,0 +1,101 @@ +/* + * Copyright 2012-2023 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.loader.jar; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.boot.loader.data.RandomAccessData; + +/** + * Parses the central directory from a JAR file. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @see CentralDirectoryVisitor + */ +class CentralDirectoryParser { + + private static final int CENTRAL_DIRECTORY_HEADER_BASE_SIZE = 46; + + private final List visitors = new ArrayList<>(); + + T addVisitor(T visitor) { + this.visitors.add(visitor); + return visitor; + } + + /** + * Parse the source data, triggering {@link CentralDirectoryVisitor visitors}. + * @param data the source data + * @param skipPrefixBytes if prefix bytes should be skipped + * @return the actual archive data without any prefix bytes + * @throws IOException on error + */ + RandomAccessData parse(RandomAccessData data, boolean skipPrefixBytes) throws IOException { + CentralDirectoryEndRecord endRecord = new CentralDirectoryEndRecord(data); + if (skipPrefixBytes) { + data = getArchiveData(endRecord, data); + } + RandomAccessData centralDirectoryData = endRecord.getCentralDirectory(data); + visitStart(endRecord, centralDirectoryData); + parseEntries(endRecord, centralDirectoryData); + visitEnd(); + return data; + } + + private void parseEntries(CentralDirectoryEndRecord endRecord, RandomAccessData centralDirectoryData) + throws IOException { + byte[] bytes = centralDirectoryData.read(0, centralDirectoryData.getSize()); + CentralDirectoryFileHeader fileHeader = new CentralDirectoryFileHeader(); + int dataOffset = 0; + for (int i = 0; i < endRecord.getNumberOfRecords(); i++) { + fileHeader.load(bytes, dataOffset, null, 0, null); + visitFileHeader(dataOffset, fileHeader); + dataOffset += CENTRAL_DIRECTORY_HEADER_BASE_SIZE + fileHeader.getName().length() + + fileHeader.getComment().length() + fileHeader.getExtra().length; + } + } + + private RandomAccessData getArchiveData(CentralDirectoryEndRecord endRecord, RandomAccessData data) { + long offset = endRecord.getStartOfArchive(data); + if (offset == 0) { + return data; + } + return data.getSubsection(offset, data.getSize() - offset); + } + + private void visitStart(CentralDirectoryEndRecord endRecord, RandomAccessData centralDirectoryData) { + for (CentralDirectoryVisitor visitor : this.visitors) { + visitor.visitStart(endRecord, centralDirectoryData); + } + } + + private void visitFileHeader(long dataOffset, CentralDirectoryFileHeader fileHeader) { + for (CentralDirectoryVisitor visitor : this.visitors) { + visitor.visitFileHeader(fileHeader, dataOffset); + } + } + + private void visitEnd() { + for (CentralDirectoryVisitor visitor : this.visitors) { + visitor.visitEnd(); + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryVisitor.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryVisitor.java new file mode 100644 index 0000000000..22e04b329c --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryVisitor.java @@ -0,0 +1,34 @@ +/* + * Copyright 2012-2023 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.loader.jar; + +import org.springframework.boot.loader.data.RandomAccessData; + +/** + * Callback visitor triggered by {@link CentralDirectoryParser}. + * + * @author Phillip Webb + */ +interface CentralDirectoryVisitor { + + void visitStart(CentralDirectoryEndRecord endRecord, RandomAccessData centralDirectoryData); + + void visitFileHeader(CentralDirectoryFileHeader fileHeader, long dataOffset); + + void visitEnd(); + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/FileHeader.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/FileHeader.java new file mode 100644 index 0000000000..7e4134fe56 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/FileHeader.java @@ -0,0 +1,64 @@ +/* + * Copyright 2012-2023 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.loader.jar; + +import java.util.zip.ZipEntry; + +/** + * A file header record that has been loaded from a Jar file. + * + * @author Phillip Webb + * @see JarEntry + * @see CentralDirectoryFileHeader + */ +interface FileHeader { + + /** + * Returns {@code true} if the header has the given name. + * @param name the name to test + * @param suffix an additional suffix (or {@code 0}) + * @return {@code true} if the header has the given name + */ + boolean hasName(CharSequence name, char suffix); + + /** + * Return the offset of the load file header within the archive data. + * @return the local header offset + */ + long getLocalHeaderOffset(); + + /** + * Return the compressed size of the entry. + * @return the compressed size. + */ + long getCompressedSize(); + + /** + * Return the uncompressed size of the entry. + * @return the uncompressed size. + */ + long getSize(); + + /** + * Return the method used to compress the data. + * @return the zip compression method + * @see ZipEntry#STORED + * @see ZipEntry#DEFLATED + */ + int getMethod(); + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/Handler.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/Handler.java new file mode 100644 index 0000000000..932dea6548 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/Handler.java @@ -0,0 +1,466 @@ +/* + * Copyright 2012-2023 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.loader.jar; + +import java.io.File; +import java.io.IOException; +import java.lang.ref.SoftReference; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLStreamHandler; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.regex.Pattern; + +/** + * {@link URLStreamHandler} for Spring Boot loader {@link JarFile}s. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @since 1.0.0 + * @see JarFile#registerUrlProtocolHandler() + */ +public class Handler extends URLStreamHandler { + + // NOTE: in order to be found as a URL protocol handler, this class must be public, + // must be named Handler and must be in a package ending '.jar' + + private static final String JAR_PROTOCOL = "jar:"; + + private static final String FILE_PROTOCOL = "file:"; + + private static final String TOMCAT_WARFILE_PROTOCOL = "war:file:"; + + private static final String SEPARATOR = "!/"; + + private static final Pattern SEPARATOR_PATTERN = Pattern.compile(SEPARATOR, Pattern.LITERAL); + + private static final String CURRENT_DIR = "/./"; + + private static final Pattern CURRENT_DIR_PATTERN = Pattern.compile(CURRENT_DIR, Pattern.LITERAL); + + private static final String PARENT_DIR = "/../"; + + private static final String PROTOCOL_HANDLER = "java.protocol.handler.pkgs"; + + private static final String[] FALLBACK_HANDLERS = { "sun.net.www.protocol.jar.Handler" }; + + private static URL jarContextUrl; + + private static SoftReference> rootFileCache; + + static { + rootFileCache = new SoftReference<>(null); + } + + private final JarFile jarFile; + + private URLStreamHandler fallbackHandler; + + public Handler() { + this(null); + } + + public Handler(JarFile jarFile) { + this.jarFile = jarFile; + } + + @Override + protected URLConnection openConnection(URL url) throws IOException { + if (this.jarFile != null && isUrlInJarFile(url, this.jarFile)) { + return JarURLConnection.get(url, this.jarFile); + } + try { + return JarURLConnection.get(url, getRootJarFileFromUrl(url)); + } + catch (Exception ex) { + return openFallbackConnection(url, ex); + } + } + + private boolean isUrlInJarFile(URL url, JarFile jarFile) throws MalformedURLException { + // Try the path first to save building a new url string each time + return url.getPath().startsWith(jarFile.getUrl().getPath()) + && url.toString().startsWith(jarFile.getUrlString()); + } + + private URLConnection openFallbackConnection(URL url, Exception reason) throws IOException { + try { + URLConnection connection = openFallbackTomcatConnection(url); + connection = (connection != null) ? connection : openFallbackContextConnection(url); + return (connection != null) ? connection : openFallbackHandlerConnection(url); + } + catch (Exception ex) { + if (reason instanceof IOException ioException) { + log(false, "Unable to open fallback handler", ex); + throw ioException; + } + log(true, "Unable to open fallback handler", ex); + if (reason instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw new IllegalStateException(reason); + } + } + + /** + * Attempt to open a Tomcat formatted 'jar:war:file:...' URL. This method allows us to + * use our own nested JAR support to open the content rather than the logic in + * {@code sun.net.www.protocol.jar.URLJarFile} which will extract the nested jar to + * the temp folder to that its content can be accessed. + * @param url the URL to open + * @return a {@link URLConnection} or {@code null} + */ + private URLConnection openFallbackTomcatConnection(URL url) { + String file = url.getFile(); + if (isTomcatWarUrl(file)) { + file = file.substring(TOMCAT_WARFILE_PROTOCOL.length()); + file = file.replaceFirst("\\*/", "!/"); + try { + URLConnection connection = openConnection(new URL("jar:file:" + file)); + connection.getInputStream().close(); + return connection; + } + catch (IOException ex) { + } + } + return null; + } + + private boolean isTomcatWarUrl(String file) { + if (file.startsWith(TOMCAT_WARFILE_PROTOCOL) || !file.contains("*/")) { + try { + URLConnection connection = new URL(file).openConnection(); + if (connection.getClass().getName().startsWith("org.apache.catalina")) { + return true; + } + } + catch (Exception ex) { + } + } + return false; + } + + /** + * Attempt to open a fallback connection by using a context URL captured before the + * jar handler was replaced with our own version. Since this method doesn't use + * reflection it won't trigger "illegal reflective access operation has occurred" + * warnings on Java 13+. + * @param url the URL to open + * @return a {@link URLConnection} or {@code null} + */ + private URLConnection openFallbackContextConnection(URL url) { + try { + if (jarContextUrl != null) { + return new URL(jarContextUrl, url.toExternalForm()).openConnection(); + } + } + catch (Exception ex) { + } + return null; + } + + /** + * Attempt to open a fallback connection by using reflection to access Java's default + * jar {@link URLStreamHandler}. + * @param url the URL to open + * @return the {@link URLConnection} + * @throws Exception if not connection could be opened + */ + private URLConnection openFallbackHandlerConnection(URL url) throws Exception { + URLStreamHandler fallbackHandler = getFallbackHandler(); + return new URL(null, url.toExternalForm(), fallbackHandler).openConnection(); + } + + private URLStreamHandler getFallbackHandler() { + if (this.fallbackHandler != null) { + return this.fallbackHandler; + } + for (String handlerClassName : FALLBACK_HANDLERS) { + try { + Class handlerClass = Class.forName(handlerClassName); + this.fallbackHandler = (URLStreamHandler) handlerClass.getDeclaredConstructor().newInstance(); + return this.fallbackHandler; + } + catch (Exception ex) { + // Ignore + } + } + throw new IllegalStateException("Unable to find fallback handler"); + } + + private void log(boolean warning, String message, Exception cause) { + try { + Level level = warning ? Level.WARNING : Level.FINEST; + Logger.getLogger(getClass().getName()).log(level, message, cause); + } + catch (Exception ex) { + if (warning) { + System.err.println("WARNING: " + message); + } + } + } + + @Override + protected void parseURL(URL context, String spec, int start, int limit) { + if (spec.regionMatches(true, 0, JAR_PROTOCOL, 0, JAR_PROTOCOL.length())) { + setFile(context, getFileFromSpec(spec.substring(start, limit))); + } + else { + setFile(context, getFileFromContext(context, spec.substring(start, limit))); + } + } + + private String getFileFromSpec(String spec) { + int separatorIndex = spec.lastIndexOf("!/"); + if (separatorIndex == -1) { + throw new IllegalArgumentException("No !/ in spec '" + spec + "'"); + } + try { + new URL(spec.substring(0, separatorIndex)); + return spec; + } + catch (MalformedURLException ex) { + throw new IllegalArgumentException("Invalid spec URL '" + spec + "'", ex); + } + } + + private String getFileFromContext(URL context, String spec) { + String file = context.getFile(); + if (spec.startsWith("/")) { + return trimToJarRoot(file) + SEPARATOR + spec.substring(1); + } + if (file.endsWith("/")) { + return file + spec; + } + int lastSlashIndex = file.lastIndexOf('/'); + if (lastSlashIndex == -1) { + throw new IllegalArgumentException("No / found in context URL's file '" + file + "'"); + } + return file.substring(0, lastSlashIndex + 1) + spec; + } + + private String trimToJarRoot(String file) { + int lastSeparatorIndex = file.lastIndexOf(SEPARATOR); + if (lastSeparatorIndex == -1) { + throw new IllegalArgumentException("No !/ found in context URL's file '" + file + "'"); + } + return file.substring(0, lastSeparatorIndex); + } + + private void setFile(URL context, String file) { + String path = normalize(file); + String query = null; + int queryIndex = path.lastIndexOf('?'); + if (queryIndex != -1) { + query = path.substring(queryIndex + 1); + path = path.substring(0, queryIndex); + } + setURL(context, JAR_PROTOCOL, null, -1, null, null, path, query, context.getRef()); + } + + private String normalize(String file) { + if (!file.contains(CURRENT_DIR) && !file.contains(PARENT_DIR)) { + return file; + } + int afterLastSeparatorIndex = file.lastIndexOf(SEPARATOR) + SEPARATOR.length(); + String afterSeparator = file.substring(afterLastSeparatorIndex); + afterSeparator = replaceParentDir(afterSeparator); + afterSeparator = replaceCurrentDir(afterSeparator); + return file.substring(0, afterLastSeparatorIndex) + afterSeparator; + } + + private String replaceParentDir(String file) { + int parentDirIndex; + while ((parentDirIndex = file.indexOf(PARENT_DIR)) >= 0) { + int precedingSlashIndex = file.lastIndexOf('/', parentDirIndex - 1); + if (precedingSlashIndex >= 0) { + file = file.substring(0, precedingSlashIndex) + file.substring(parentDirIndex + 3); + } + else { + file = file.substring(parentDirIndex + 4); + } + } + return file; + } + + private String replaceCurrentDir(String file) { + return CURRENT_DIR_PATTERN.matcher(file).replaceAll("/"); + } + + @Override + protected int hashCode(URL u) { + return hashCode(u.getProtocol(), u.getFile()); + } + + private int hashCode(String protocol, String file) { + int result = (protocol != null) ? protocol.hashCode() : 0; + int separatorIndex = file.indexOf(SEPARATOR); + if (separatorIndex == -1) { + return result + file.hashCode(); + } + String source = file.substring(0, separatorIndex); + String entry = canonicalize(file.substring(separatorIndex + 2)); + try { + result += new URL(source).hashCode(); + } + catch (MalformedURLException ex) { + result += source.hashCode(); + } + result += entry.hashCode(); + return result; + } + + @Override + protected boolean sameFile(URL u1, URL u2) { + if (!u1.getProtocol().equals("jar") || !u2.getProtocol().equals("jar")) { + return false; + } + int separator1 = u1.getFile().indexOf(SEPARATOR); + int separator2 = u2.getFile().indexOf(SEPARATOR); + if (separator1 == -1 || separator2 == -1) { + return super.sameFile(u1, u2); + } + String nested1 = u1.getFile().substring(separator1 + SEPARATOR.length()); + String nested2 = u2.getFile().substring(separator2 + SEPARATOR.length()); + if (!nested1.equals(nested2)) { + String canonical1 = canonicalize(nested1); + String canonical2 = canonicalize(nested2); + if (!canonical1.equals(canonical2)) { + return false; + } + } + String root1 = u1.getFile().substring(0, separator1); + String root2 = u2.getFile().substring(0, separator2); + try { + return super.sameFile(new URL(root1), new URL(root2)); + } + catch (MalformedURLException ex) { + // Continue + } + return super.sameFile(u1, u2); + } + + private String canonicalize(String path) { + return SEPARATOR_PATTERN.matcher(path).replaceAll("/"); + } + + public JarFile getRootJarFileFromUrl(URL url) throws IOException { + String spec = url.getFile(); + int separatorIndex = spec.indexOf(SEPARATOR); + if (separatorIndex == -1) { + throw new MalformedURLException("Jar URL does not contain !/ separator"); + } + String name = spec.substring(0, separatorIndex); + return getRootJarFile(name); + } + + private JarFile getRootJarFile(String name) throws IOException { + try { + if (!name.startsWith(FILE_PROTOCOL)) { + throw new IllegalStateException("Not a file URL"); + } + File file = new File(URI.create(name)); + Map cache = rootFileCache.get(); + JarFile result = (cache != null) ? cache.get(file) : null; + if (result == null) { + result = new JarFile(file); + addToRootFileCache(file, result); + } + return result; + } + catch (Exception ex) { + throw new IOException("Unable to open root Jar file '" + name + "'", ex); + } + } + + /** + * Add the given {@link JarFile} to the root file cache. + * @param sourceFile the source file to add + * @param jarFile the jar file. + */ + static void addToRootFileCache(File sourceFile, JarFile jarFile) { + Map cache = rootFileCache.get(); + if (cache == null) { + cache = new ConcurrentHashMap<>(); + rootFileCache = new SoftReference<>(cache); + } + cache.put(sourceFile, jarFile); + } + + /** + * If possible, capture a URL that is configured with the original jar handler so that + * we can use it as a fallback context later. We can only do this if we know that we + * can reset the handlers after. + */ + static void captureJarContextUrl() { + if (canResetCachedUrlHandlers()) { + String handlers = System.getProperty(PROTOCOL_HANDLER); + try { + System.clearProperty(PROTOCOL_HANDLER); + try { + resetCachedUrlHandlers(); + jarContextUrl = new URL("jar:file:context.jar!/"); + URLConnection connection = jarContextUrl.openConnection(); + if (connection instanceof JarURLConnection) { + jarContextUrl = null; + } + } + catch (Exception ex) { + } + } + finally { + if (handlers == null) { + System.clearProperty(PROTOCOL_HANDLER); + } + else { + System.setProperty(PROTOCOL_HANDLER, handlers); + } + } + resetCachedUrlHandlers(); + } + } + + private static boolean canResetCachedUrlHandlers() { + try { + resetCachedUrlHandlers(); + return true; + } + catch (Error ex) { + return false; + } + } + + private static void resetCachedUrlHandlers() { + URL.setURLStreamHandlerFactory(null); + } + + /** + * Set if a generic static exception can be thrown when a URL cannot be connected. + * This optimization is used during class loading to save creating lots of exceptions + * which are then swallowed. + * @param useFastConnectionExceptions if fast connection exceptions can be used. + */ + public static void setUseFastConnectionExceptions(boolean useFastConnectionExceptions) { + JarURLConnection.setUseFastExceptions(useFastConnectionExceptions); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarEntry.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarEntry.java new file mode 100644 index 0000000000..8f54dc3070 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarEntry.java @@ -0,0 +1,120 @@ +/* + * Copyright 2012-2023 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.loader.jar; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.security.CodeSigner; +import java.security.cert.Certificate; +import java.util.jar.Attributes; +import java.util.jar.Manifest; + +/** + * Extended variant of {@link java.util.jar.JarEntry} returned by {@link JarFile}s. + * + * @author Phillip Webb + * @author Andy Wilkinson + */ +class JarEntry extends java.util.jar.JarEntry implements FileHeader { + + private final int index; + + private final AsciiBytes name; + + private final AsciiBytes headerName; + + private final JarFile jarFile; + + private final long localHeaderOffset; + + private volatile JarEntryCertification certification; + + JarEntry(JarFile jarFile, int index, CentralDirectoryFileHeader header, AsciiBytes nameAlias) { + super((nameAlias != null) ? nameAlias.toString() : header.getName().toString()); + this.index = index; + this.name = (nameAlias != null) ? nameAlias : header.getName(); + this.headerName = header.getName(); + this.jarFile = jarFile; + this.localHeaderOffset = header.getLocalHeaderOffset(); + setCompressedSize(header.getCompressedSize()); + setMethod(header.getMethod()); + setCrc(header.getCrc()); + setComment(header.getComment().toString()); + setSize(header.getSize()); + setTime(header.getTime()); + if (header.hasExtra()) { + setExtra(header.getExtra()); + } + } + + int getIndex() { + return this.index; + } + + AsciiBytes getAsciiBytesName() { + return this.name; + } + + @Override + public boolean hasName(CharSequence name, char suffix) { + return this.headerName.matches(name, suffix); + } + + /** + * Return a {@link URL} for this {@link JarEntry}. + * @return the URL for the entry + * @throws MalformedURLException if the URL is not valid + */ + URL getUrl() throws MalformedURLException { + return new URL(this.jarFile.getUrl(), getName()); + } + + @Override + public Attributes getAttributes() throws IOException { + Manifest manifest = this.jarFile.getManifest(); + return (manifest != null) ? manifest.getAttributes(getName()) : null; + } + + @Override + public Certificate[] getCertificates() { + return getCertification().getCertificates(); + } + + @Override + public CodeSigner[] getCodeSigners() { + return getCertification().getCodeSigners(); + } + + private JarEntryCertification getCertification() { + if (!this.jarFile.isSigned()) { + return JarEntryCertification.NONE; + } + JarEntryCertification certification = this.certification; + if (certification == null) { + certification = this.jarFile.getCertification(this); + this.certification = certification; + } + return certification; + } + + @Override + public long getLocalHeaderOffset() { + return this.localHeaderOffset; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarEntryCertification.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarEntryCertification.java new file mode 100644 index 0000000000..ffd629e094 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarEntryCertification.java @@ -0,0 +1,58 @@ +/* + * Copyright 2012-2023 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.loader.jar; + +import java.security.CodeSigner; +import java.security.cert.Certificate; + +/** + * {@link Certificate} and {@link CodeSigner} details for a {@link JarEntry} from a signed + * {@link JarFile}. + * + * @author Phillip Webb + */ +class JarEntryCertification { + + static final JarEntryCertification NONE = new JarEntryCertification(null, null); + + private final Certificate[] certificates; + + private final CodeSigner[] codeSigners; + + JarEntryCertification(Certificate[] certificates, CodeSigner[] codeSigners) { + this.certificates = certificates; + this.codeSigners = codeSigners; + } + + Certificate[] getCertificates() { + return (this.certificates != null) ? this.certificates.clone() : null; + } + + CodeSigner[] getCodeSigners() { + return (this.codeSigners != null) ? this.codeSigners.clone() : null; + } + + static JarEntryCertification from(java.util.jar.JarEntry certifiedEntry) { + Certificate[] certificates = (certifiedEntry != null) ? certifiedEntry.getCertificates() : null; + CodeSigner[] codeSigners = (certifiedEntry != null) ? certifiedEntry.getCodeSigners() : null; + if (certificates == null && codeSigners == null) { + return NONE; + } + return new JarEntryCertification(certificates, codeSigners); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarEntryFilter.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarEntryFilter.java new file mode 100644 index 0000000000..6804f0ba37 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarEntryFilter.java @@ -0,0 +1,35 @@ +/* + * Copyright 2012-2023 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.loader.jar; + +/** + * Interface that can be used to filter and optionally rename jar entries. + * + * @author Phillip Webb + */ +interface JarEntryFilter { + + /** + * Apply the jar entry filter. + * @param name the current entry name. This may be different that the original entry + * name if a previous filter has been applied + * @return the new name of the entry or {@code null} if the entry should not be + * included. + */ + AsciiBytes apply(AsciiBytes name); + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarFile.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarFile.java new file mode 100644 index 0000000000..6e548048db --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarFile.java @@ -0,0 +1,475 @@ +/* + * Copyright 2012-2023 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.loader.jar; + +import java.io.File; +import java.io.FilePermission; +import java.io.IOException; +import java.io.InputStream; +import java.lang.ref.SoftReference; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLStreamHandler; +import java.net.URLStreamHandlerFactory; +import java.security.Permission; +import java.util.Enumeration; +import java.util.Iterator; +import java.util.Spliterator; +import java.util.Spliterators; +import java.util.function.Supplier; +import java.util.jar.Manifest; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; +import java.util.zip.ZipEntry; + +import org.springframework.boot.loader.data.RandomAccessData; +import org.springframework.boot.loader.data.RandomAccessDataFile; + +/** + * Extended variant of {@link java.util.jar.JarFile} that behaves in the same way but + * offers the following additional functionality. + *
    + *
  • A nested {@link JarFile} can be {@link #getNestedJarFile(ZipEntry) obtained} based + * on any directory entry.
  • + *
  • A nested {@link JarFile} can be {@link #getNestedJarFile(ZipEntry) obtained} for + * embedded JAR files (as long as their entry is not compressed).
  • + *
+ * + * @author Phillip Webb + * @author Andy Wilkinson + * @since 1.0.0 + */ +public class JarFile extends AbstractJarFile implements Iterable { + + private static final String MANIFEST_NAME = "META-INF/MANIFEST.MF"; + + private static final String PROTOCOL_HANDLER = "java.protocol.handler.pkgs"; + + private static final String HANDLERS_PACKAGE = "org.springframework.boot.loader"; + + private static final AsciiBytes META_INF = new AsciiBytes("META-INF/"); + + private static final AsciiBytes SIGNATURE_FILE_EXTENSION = new AsciiBytes(".SF"); + + private static final String READ_ACTION = "read"; + + private final RandomAccessDataFile rootFile; + + private final String pathFromRoot; + + private final RandomAccessData data; + + private final JarFileType type; + + private URL url; + + private String urlString; + + private final JarFileEntries entries; + + private final Supplier manifestSupplier; + + private SoftReference manifest; + + private boolean signed; + + private String comment; + + private volatile boolean closed; + + private volatile JarFileWrapper wrapper; + + /** + * Create a new {@link JarFile} backed by the specified file. + * @param file the root jar file + * @throws IOException if the file cannot be read + */ + public JarFile(File file) throws IOException { + this(new RandomAccessDataFile(file)); + } + + /** + * Create a new {@link JarFile} backed by the specified file. + * @param file the root jar file + * @throws IOException if the file cannot be read + */ + JarFile(RandomAccessDataFile file) throws IOException { + this(file, "", file, JarFileType.DIRECT); + } + + /** + * Private constructor used to create a new {@link JarFile} either directly or from a + * nested entry. + * @param rootFile the root jar file + * @param pathFromRoot the name of this file + * @param data the underlying data + * @param type the type of the jar file + * @throws IOException if the file cannot be read + */ + private JarFile(RandomAccessDataFile rootFile, String pathFromRoot, RandomAccessData data, JarFileType type) + throws IOException { + this(rootFile, pathFromRoot, data, null, type, null); + } + + private JarFile(RandomAccessDataFile rootFile, String pathFromRoot, RandomAccessData data, JarEntryFilter filter, + JarFileType type, Supplier manifestSupplier) throws IOException { + super(rootFile.getFile()); + super.close(); + this.rootFile = rootFile; + this.pathFromRoot = pathFromRoot; + CentralDirectoryParser parser = new CentralDirectoryParser(); + this.entries = parser.addVisitor(new JarFileEntries(this, filter)); + this.type = type; + parser.addVisitor(centralDirectoryVisitor()); + try { + this.data = parser.parse(data, filter == null); + } + catch (RuntimeException ex) { + try { + this.rootFile.close(); + super.close(); + } + catch (IOException ioex) { + } + throw ex; + } + this.manifestSupplier = (manifestSupplier != null) ? manifestSupplier : () -> { + try (InputStream inputStream = getInputStream(MANIFEST_NAME)) { + if (inputStream == null) { + return null; + } + return new Manifest(inputStream); + } + catch (IOException ex) { + throw new RuntimeException(ex); + } + }; + } + + private CentralDirectoryVisitor centralDirectoryVisitor() { + return new CentralDirectoryVisitor() { + + @Override + public void visitStart(CentralDirectoryEndRecord endRecord, RandomAccessData centralDirectoryData) { + JarFile.this.comment = endRecord.getComment(); + } + + @Override + public void visitFileHeader(CentralDirectoryFileHeader fileHeader, long dataOffset) { + AsciiBytes name = fileHeader.getName(); + if (name.startsWith(META_INF) && name.endsWith(SIGNATURE_FILE_EXTENSION)) { + JarFile.this.signed = true; + } + } + + @Override + public void visitEnd() { + } + + }; + } + + JarFileWrapper getWrapper() throws IOException { + JarFileWrapper wrapper = this.wrapper; + if (wrapper == null) { + wrapper = new JarFileWrapper(this); + this.wrapper = wrapper; + } + return wrapper; + } + + @Override + Permission getPermission() { + return new FilePermission(this.rootFile.getFile().getPath(), READ_ACTION); + } + + protected final RandomAccessDataFile getRootJarFile() { + return this.rootFile; + } + + RandomAccessData getData() { + return this.data; + } + + @Override + public Manifest getManifest() throws IOException { + Manifest manifest = (this.manifest != null) ? this.manifest.get() : null; + if (manifest == null) { + try { + manifest = this.manifestSupplier.get(); + } + catch (RuntimeException ex) { + throw new IOException(ex); + } + this.manifest = new SoftReference<>(manifest); + } + return manifest; + } + + @Override + public Enumeration entries() { + return new JarEntryEnumeration(this.entries.iterator()); + } + + @Override + public Stream stream() { + Spliterator spliterator = Spliterators.spliterator(iterator(), size(), + Spliterator.ORDERED | Spliterator.DISTINCT | Spliterator.IMMUTABLE | Spliterator.NONNULL); + return StreamSupport.stream(spliterator, false); + } + + /** + * Return an iterator for the contained entries. + * @since 2.3.0 + * @see java.lang.Iterable#iterator() + */ + @Override + @SuppressWarnings({ "unchecked", "rawtypes" }) + public Iterator iterator() { + return (Iterator) this.entries.iterator(this::ensureOpen); + } + + public JarEntry getJarEntry(CharSequence name) { + return this.entries.getEntry(name); + } + + @Override + public JarEntry getJarEntry(String name) { + return (JarEntry) getEntry(name); + } + + public boolean containsEntry(String name) { + return this.entries.containsEntry(name); + } + + @Override + public ZipEntry getEntry(String name) { + ensureOpen(); + return this.entries.getEntry(name); + } + + @Override + InputStream getInputStream() throws IOException { + return this.data.getInputStream(); + } + + @Override + public synchronized InputStream getInputStream(ZipEntry entry) throws IOException { + ensureOpen(); + if (entry instanceof JarEntry jarEntry) { + return this.entries.getInputStream(jarEntry); + } + return getInputStream((entry != null) ? entry.getName() : null); + } + + InputStream getInputStream(String name) throws IOException { + return this.entries.getInputStream(name); + } + + /** + * Return a nested {@link JarFile} loaded from the specified entry. + * @param entry the zip entry + * @return a {@link JarFile} for the entry + * @throws IOException if the nested jar file cannot be read + */ + public synchronized JarFile getNestedJarFile(ZipEntry entry) throws IOException { + return getNestedJarFile((JarEntry) entry); + } + + /** + * Return a nested {@link JarFile} loaded from the specified entry. + * @param entry the zip entry + * @return a {@link JarFile} for the entry + * @throws IOException if the nested jar file cannot be read + */ + public synchronized JarFile getNestedJarFile(JarEntry entry) throws IOException { + try { + return createJarFileFromEntry(entry); + } + catch (Exception ex) { + throw new IOException("Unable to open nested jar file '" + entry.getName() + "'", ex); + } + } + + private JarFile createJarFileFromEntry(JarEntry entry) throws IOException { + if (entry.isDirectory()) { + return createJarFileFromDirectoryEntry(entry); + } + return createJarFileFromFileEntry(entry); + } + + private JarFile createJarFileFromDirectoryEntry(JarEntry entry) throws IOException { + AsciiBytes name = entry.getAsciiBytesName(); + JarEntryFilter filter = (candidate) -> { + if (candidate.startsWith(name) && !candidate.equals(name)) { + return candidate.substring(name.length()); + } + return null; + }; + return new JarFile(this.rootFile, this.pathFromRoot + "!/" + entry.getName().substring(0, name.length() - 1), + this.data, filter, JarFileType.NESTED_DIRECTORY, this.manifestSupplier); + } + + private JarFile createJarFileFromFileEntry(JarEntry entry) throws IOException { + if (entry.getMethod() != ZipEntry.STORED) { + throw new IllegalStateException( + "Unable to open nested entry '" + entry.getName() + "'. It has been compressed and nested " + + "jar files must be stored without compression. Please check the " + + "mechanism used to create your executable jar file"); + } + RandomAccessData entryData = this.entries.getEntryData(entry.getName()); + return new JarFile(this.rootFile, this.pathFromRoot + "!/" + entry.getName(), entryData, + JarFileType.NESTED_JAR); + } + + @Override + public String getComment() { + ensureOpen(); + return this.comment; + } + + @Override + public int size() { + ensureOpen(); + return this.entries.getSize(); + } + + @Override + public void close() throws IOException { + if (this.closed) { + return; + } + super.close(); + if (this.type == JarFileType.DIRECT) { + this.rootFile.close(); + } + this.closed = true; + } + + private void ensureOpen() { + if (this.closed) { + throw new IllegalStateException("zip file closed"); + } + } + + boolean isClosed() { + return this.closed; + } + + String getUrlString() throws MalformedURLException { + if (this.urlString == null) { + this.urlString = getUrl().toString(); + } + return this.urlString; + } + + @Override + public URL getUrl() throws MalformedURLException { + if (this.url == null) { + String file = this.rootFile.getFile().toURI() + this.pathFromRoot + "!/"; + file = file.replace("file:////", "file://"); // Fix UNC paths + this.url = new URL("jar", "", -1, file, new Handler(this)); + } + return this.url; + } + + @Override + public String toString() { + return getName(); + } + + @Override + public String getName() { + return this.rootFile.getFile() + this.pathFromRoot; + } + + boolean isSigned() { + return this.signed; + } + + JarEntryCertification getCertification(JarEntry entry) { + try { + return this.entries.getCertification(entry); + } + catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + + public void clearCache() { + this.entries.clearCache(); + } + + protected String getPathFromRoot() { + return this.pathFromRoot; + } + + @Override + JarFileType getType() { + return this.type; + } + + /** + * Register a {@literal 'java.protocol.handler.pkgs'} property so that a + * {@link URLStreamHandler} will be located to deal with jar URLs. + */ + public static void registerUrlProtocolHandler() { + Handler.captureJarContextUrl(); + String handlers = System.getProperty(PROTOCOL_HANDLER, ""); + System.setProperty(PROTOCOL_HANDLER, + ((handlers == null || handlers.isEmpty()) ? HANDLERS_PACKAGE : handlers + "|" + HANDLERS_PACKAGE)); + resetCachedUrlHandlers(); + } + + /** + * Reset any cached handlers just in case a jar protocol has already been used. We + * reset the handler by trying to set a null {@link URLStreamHandlerFactory} which + * should have no effect other than clearing the handlers cache. + */ + private static void resetCachedUrlHandlers() { + try { + URL.setURLStreamHandlerFactory(null); + } + catch (Error ex) { + // Ignore + } + } + + /** + * An {@link Enumeration} on {@linkplain java.util.jar.JarEntry jar entries}. + */ + private static class JarEntryEnumeration implements Enumeration { + + private final Iterator iterator; + + JarEntryEnumeration(Iterator iterator) { + this.iterator = iterator; + } + + @Override + public boolean hasMoreElements() { + return this.iterator.hasNext(); + } + + @Override + public java.util.jar.JarEntry nextElement() { + return this.iterator.next(); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarFileEntries.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarFileEntries.java new file mode 100644 index 0000000000..d151c8d80a --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarFileEntries.java @@ -0,0 +1,491 @@ +/* + * Copyright 2012-2023 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.loader.jar; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.jar.Attributes; +import java.util.jar.Attributes.Name; +import java.util.jar.JarInputStream; +import java.util.jar.Manifest; +import java.util.zip.ZipEntry; + +import org.springframework.boot.loader.data.RandomAccessData; + +/** + * Provides access to entries from a {@link JarFile}. In order to reduce memory + * consumption entry details are stored using arrays. The {@code hashCodes} array stores + * the hash code of the entry name, the {@code centralDirectoryOffsets} provides the + * offset to the central directory record and {@code positions} provides the original + * order position of the entry. The arrays are stored in hashCode order so that a binary + * search can be used to find a name. + *

+ * A typical Spring Boot application will have somewhere in the region of 10,500 entries + * which should consume about 122K. + * + * @author Phillip Webb + * @author Andy Wilkinson + */ +class JarFileEntries implements CentralDirectoryVisitor, Iterable { + + private static final Runnable NO_VALIDATION = () -> { + }; + + private static final String META_INF_PREFIX = "META-INF/"; + + private static final Name MULTI_RELEASE = new Name("Multi-Release"); + + private static final int BASE_VERSION = 8; + + private static final int RUNTIME_VERSION = Runtime.version().feature(); + + private static final long LOCAL_FILE_HEADER_SIZE = 30; + + private static final char SLASH = '/'; + + private static final char NO_SUFFIX = 0; + + protected static final int ENTRY_CACHE_SIZE = 25; + + private final JarFile jarFile; + + private final JarEntryFilter filter; + + private RandomAccessData centralDirectoryData; + + private int size; + + private int[] hashCodes; + + private Offsets centralDirectoryOffsets; + + private int[] positions; + + private Boolean multiReleaseJar; + + private JarEntryCertification[] certifications; + + private final Map entriesCache = Collections + .synchronizedMap(new LinkedHashMap<>(16, 0.75f, true) { + + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() >= ENTRY_CACHE_SIZE; + } + + }); + + JarFileEntries(JarFile jarFile, JarEntryFilter filter) { + this.jarFile = jarFile; + this.filter = filter; + } + + @Override + public void visitStart(CentralDirectoryEndRecord endRecord, RandomAccessData centralDirectoryData) { + int maxSize = endRecord.getNumberOfRecords(); + this.centralDirectoryData = centralDirectoryData; + this.hashCodes = new int[maxSize]; + this.centralDirectoryOffsets = Offsets.from(endRecord); + this.positions = new int[maxSize]; + } + + @Override + public void visitFileHeader(CentralDirectoryFileHeader fileHeader, long dataOffset) { + AsciiBytes name = applyFilter(fileHeader.getName()); + if (name != null) { + add(name, dataOffset); + } + } + + private void add(AsciiBytes name, long dataOffset) { + this.hashCodes[this.size] = name.hashCode(); + this.centralDirectoryOffsets.set(this.size, dataOffset); + this.positions[this.size] = this.size; + this.size++; + } + + @Override + public void visitEnd() { + sort(0, this.size - 1); + int[] positions = this.positions; + this.positions = new int[positions.length]; + for (int i = 0; i < this.size; i++) { + this.positions[positions[i]] = i; + } + } + + int getSize() { + return this.size; + } + + private void sort(int left, int right) { + // Quick sort algorithm, uses hashCodes as the source but sorts all arrays + if (left < right) { + int pivot = this.hashCodes[left + (right - left) / 2]; + int i = left; + int j = right; + while (i <= j) { + while (this.hashCodes[i] < pivot) { + i++; + } + while (this.hashCodes[j] > pivot) { + j--; + } + if (i <= j) { + swap(i, j); + i++; + j--; + } + } + if (left < j) { + sort(left, j); + } + if (right > i) { + sort(i, right); + } + } + } + + private void swap(int i, int j) { + swap(this.hashCodes, i, j); + this.centralDirectoryOffsets.swap(i, j); + swap(this.positions, i, j); + } + + @Override + public Iterator iterator() { + return new EntryIterator(NO_VALIDATION); + } + + Iterator iterator(Runnable validator) { + return new EntryIterator(validator); + } + + boolean containsEntry(CharSequence name) { + return getEntry(name, FileHeader.class, true) != null; + } + + JarEntry getEntry(CharSequence name) { + return getEntry(name, JarEntry.class, true); + } + + InputStream getInputStream(String name) throws IOException { + FileHeader entry = getEntry(name, FileHeader.class, false); + return getInputStream(entry); + } + + InputStream getInputStream(FileHeader entry) throws IOException { + if (entry == null) { + return null; + } + InputStream inputStream = getEntryData(entry).getInputStream(); + if (entry.getMethod() == ZipEntry.DEFLATED) { + inputStream = new ZipInflaterInputStream(inputStream, (int) entry.getSize()); + } + return inputStream; + } + + RandomAccessData getEntryData(String name) throws IOException { + FileHeader entry = getEntry(name, FileHeader.class, false); + if (entry == null) { + return null; + } + return getEntryData(entry); + } + + private RandomAccessData getEntryData(FileHeader entry) throws IOException { + // aspectjrt-1.7.4.jar has a different ext bytes length in the + // local directory to the central directory. We need to re-read + // here to skip them + RandomAccessData data = this.jarFile.getData(); + byte[] localHeader = data.read(entry.getLocalHeaderOffset(), LOCAL_FILE_HEADER_SIZE); + long nameLength = Bytes.littleEndianValue(localHeader, 26, 2); + long extraLength = Bytes.littleEndianValue(localHeader, 28, 2); + return data.getSubsection(entry.getLocalHeaderOffset() + LOCAL_FILE_HEADER_SIZE + nameLength + extraLength, + entry.getCompressedSize()); + } + + private T getEntry(CharSequence name, Class type, boolean cacheEntry) { + T entry = doGetEntry(name, type, cacheEntry, null); + if (!isMetaInfEntry(name) && isMultiReleaseJar()) { + int version = RUNTIME_VERSION; + AsciiBytes nameAlias = (entry instanceof JarEntry jarEntry) ? jarEntry.getAsciiBytesName() + : new AsciiBytes(name.toString()); + while (version > BASE_VERSION) { + T versionedEntry = doGetEntry("META-INF/versions/" + version + "/" + name, type, cacheEntry, nameAlias); + if (versionedEntry != null) { + return versionedEntry; + } + version--; + } + } + return entry; + } + + private boolean isMetaInfEntry(CharSequence name) { + return name.toString().startsWith(META_INF_PREFIX); + } + + private boolean isMultiReleaseJar() { + Boolean multiRelease = this.multiReleaseJar; + if (multiRelease != null) { + return multiRelease; + } + try { + Manifest manifest = this.jarFile.getManifest(); + if (manifest == null) { + multiRelease = false; + } + else { + Attributes attributes = manifest.getMainAttributes(); + multiRelease = attributes.containsKey(MULTI_RELEASE); + } + } + catch (IOException ex) { + multiRelease = false; + } + this.multiReleaseJar = multiRelease; + return multiRelease; + } + + private T doGetEntry(CharSequence name, Class type, boolean cacheEntry, + AsciiBytes nameAlias) { + int hashCode = AsciiBytes.hashCode(name); + T entry = getEntry(hashCode, name, NO_SUFFIX, type, cacheEntry, nameAlias); + if (entry == null) { + hashCode = AsciiBytes.hashCode(hashCode, SLASH); + entry = getEntry(hashCode, name, SLASH, type, cacheEntry, nameAlias); + } + return entry; + } + + private T getEntry(int hashCode, CharSequence name, char suffix, Class type, + boolean cacheEntry, AsciiBytes nameAlias) { + int index = getFirstIndex(hashCode); + while (index >= 0 && index < this.size && this.hashCodes[index] == hashCode) { + T entry = getEntry(index, type, cacheEntry, nameAlias); + if (entry.hasName(name, suffix)) { + return entry; + } + index++; + } + return null; + } + + @SuppressWarnings("unchecked") + private T getEntry(int index, Class type, boolean cacheEntry, AsciiBytes nameAlias) { + try { + long offset = this.centralDirectoryOffsets.get(index); + FileHeader cached = this.entriesCache.get(index); + FileHeader entry = (cached != null) ? cached + : CentralDirectoryFileHeader.fromRandomAccessData(this.centralDirectoryData, offset, this.filter); + if (CentralDirectoryFileHeader.class.equals(entry.getClass()) && type.equals(JarEntry.class)) { + entry = new JarEntry(this.jarFile, index, (CentralDirectoryFileHeader) entry, nameAlias); + } + if (cacheEntry && cached != entry) { + this.entriesCache.put(index, entry); + } + return (T) entry; + } + catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + + private int getFirstIndex(int hashCode) { + int index = Arrays.binarySearch(this.hashCodes, 0, this.size, hashCode); + if (index < 0) { + return -1; + } + while (index > 0 && this.hashCodes[index - 1] == hashCode) { + index--; + } + return index; + } + + void clearCache() { + this.entriesCache.clear(); + } + + private AsciiBytes applyFilter(AsciiBytes name) { + return (this.filter != null) ? this.filter.apply(name) : name; + } + + JarEntryCertification getCertification(JarEntry entry) throws IOException { + JarEntryCertification[] certifications = this.certifications; + if (certifications == null) { + certifications = new JarEntryCertification[this.size]; + // We fall back to use JarInputStream to obtain the certs. This isn't that + // fast, but hopefully doesn't happen too often. + try (JarInputStream certifiedJarStream = new JarInputStream(this.jarFile.getData().getInputStream())) { + java.util.jar.JarEntry certifiedEntry; + while ((certifiedEntry = certifiedJarStream.getNextJarEntry()) != null) { + // Entry must be closed to trigger a read and set entry certificates + certifiedJarStream.closeEntry(); + int index = getEntryIndex(certifiedEntry.getName()); + if (index != -1) { + certifications[index] = JarEntryCertification.from(certifiedEntry); + } + } + } + this.certifications = certifications; + } + JarEntryCertification certification = certifications[entry.getIndex()]; + return (certification != null) ? certification : JarEntryCertification.NONE; + } + + private int getEntryIndex(CharSequence name) { + int hashCode = AsciiBytes.hashCode(name); + int index = getFirstIndex(hashCode); + while (index >= 0 && index < this.size && this.hashCodes[index] == hashCode) { + FileHeader candidate = getEntry(index, FileHeader.class, false, null); + if (candidate.hasName(name, NO_SUFFIX)) { + return index; + } + index++; + } + return -1; + } + + private static void swap(int[] array, int i, int j) { + int temp = array[i]; + array[i] = array[j]; + array[j] = temp; + } + + private static void swap(long[] array, int i, int j) { + long temp = array[i]; + array[i] = array[j]; + array[j] = temp; + } + + /** + * Iterator for contained entries. + */ + private final class EntryIterator implements Iterator { + + private final Runnable validator; + + private int index = 0; + + private EntryIterator(Runnable validator) { + this.validator = validator; + validator.run(); + } + + @Override + public boolean hasNext() { + this.validator.run(); + return this.index < JarFileEntries.this.size; + } + + @Override + public JarEntry next() { + this.validator.run(); + if (!hasNext()) { + throw new NoSuchElementException(); + } + int entryIndex = JarFileEntries.this.positions[this.index]; + this.index++; + return getEntry(entryIndex, JarEntry.class, false, null); + } + + } + + /** + * Interface to manage offsets to central directory records. Regular zip files are + * backed by an {@code int[]} based implementation, Zip64 files are backed by a + * {@code long[]} and will consume more memory. + */ + private interface Offsets { + + void set(int index, long value); + + long get(int index); + + void swap(int i, int j); + + static Offsets from(CentralDirectoryEndRecord endRecord) { + int size = endRecord.getNumberOfRecords(); + return endRecord.isZip64() ? new Zip64Offsets(size) : new ZipOffsets(size); + } + + } + + /** + * {@link Offsets} implementation for regular zip files. + */ + private static final class ZipOffsets implements Offsets { + + private final int[] offsets; + + private ZipOffsets(int size) { + this.offsets = new int[size]; + } + + @Override + public void swap(int i, int j) { + JarFileEntries.swap(this.offsets, i, j); + } + + @Override + public void set(int index, long value) { + this.offsets[index] = (int) value; + } + + @Override + public long get(int index) { + return this.offsets[index]; + } + + } + + /** + * {@link Offsets} implementation for zip64 files. + */ + private static final class Zip64Offsets implements Offsets { + + private final long[] offsets; + + private Zip64Offsets(int size) { + this.offsets = new long[size]; + } + + @Override + public void swap(int i, int j) { + JarFileEntries.swap(this.offsets, i, j); + } + + @Override + public void set(int index, long value) { + this.offsets[index] = value; + } + + @Override + public long get(int index) { + return this.offsets[index]; + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarFileWrapper.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarFileWrapper.java new file mode 100644 index 0000000000..b65358947a --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarFileWrapper.java @@ -0,0 +1,126 @@ +/* + * Copyright 2012-2023 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.loader.jar; + +import java.io.IOException; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URL; +import java.security.Permission; +import java.util.Enumeration; +import java.util.jar.JarEntry; +import java.util.jar.Manifest; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; + +/** + * A wrapper used to create a copy of a {@link JarFile} so that it can be safely closed + * without closing the original. + * + * @author Phillip Webb + */ +class JarFileWrapper extends AbstractJarFile { + + private final JarFile parent; + + JarFileWrapper(JarFile parent) throws IOException { + super(parent.getRootJarFile().getFile()); + this.parent = parent; + super.close(); + } + + @Override + URL getUrl() throws MalformedURLException { + return this.parent.getUrl(); + } + + @Override + JarFileType getType() { + return this.parent.getType(); + } + + @Override + Permission getPermission() { + return this.parent.getPermission(); + } + + @Override + public Manifest getManifest() throws IOException { + return this.parent.getManifest(); + } + + @Override + public Enumeration entries() { + return this.parent.entries(); + } + + @Override + public Stream stream() { + return this.parent.stream(); + } + + @Override + public JarEntry getJarEntry(String name) { + return this.parent.getJarEntry(name); + } + + @Override + public ZipEntry getEntry(String name) { + return this.parent.getEntry(name); + } + + @Override + InputStream getInputStream() throws IOException { + return this.parent.getInputStream(); + } + + @Override + public synchronized InputStream getInputStream(ZipEntry ze) throws IOException { + return this.parent.getInputStream(ze); + } + + @Override + public String getComment() { + return this.parent.getComment(); + } + + @Override + public int size() { + return this.parent.size(); + } + + @Override + public String toString() { + return this.parent.toString(); + } + + @Override + public String getName() { + return this.parent.getName(); + } + + static JarFile unwrap(java.util.jar.JarFile jarFile) { + if (jarFile instanceof JarFile file) { + return file; + } + if (jarFile instanceof JarFileWrapper wrapper) { + return unwrap(wrapper.parent); + } + throw new IllegalStateException("Not a JarFile or Wrapper"); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarURLConnection.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarURLConnection.java new file mode 100644 index 0000000000..859ae88ab0 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarURLConnection.java @@ -0,0 +1,393 @@ +/* + * Copyright 2012-2023 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.loader.jar; + +import java.io.ByteArrayOutputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLEncoder; +import java.net.URLStreamHandler; +import java.security.Permission; + +/** + * {@link java.net.JarURLConnection} used to support {@link JarFile#getUrl()}. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Rostyslav Dudka + */ +final class JarURLConnection extends java.net.JarURLConnection { + + private static final ThreadLocal useFastExceptions = new ThreadLocal<>(); + + private static final FileNotFoundException FILE_NOT_FOUND_EXCEPTION = new FileNotFoundException( + "Jar file or entry not found"); + + private static final IllegalStateException NOT_FOUND_CONNECTION_EXCEPTION = new IllegalStateException( + FILE_NOT_FOUND_EXCEPTION); + + private static final String SEPARATOR = "!/"; + + private static final URL EMPTY_JAR_URL; + + static { + try { + EMPTY_JAR_URL = new URL("jar:", null, 0, "file:!/", new URLStreamHandler() { + @Override + protected URLConnection openConnection(URL u) throws IOException { + // Stub URLStreamHandler to prevent the wrong JAR Handler from being + // Instantiated and cached. + return null; + } + }); + } + catch (MalformedURLException ex) { + throw new IllegalStateException(ex); + } + } + + private static final JarEntryName EMPTY_JAR_ENTRY_NAME = new JarEntryName(new StringSequence("")); + + private static final JarURLConnection NOT_FOUND_CONNECTION = JarURLConnection.notFound(); + + private final AbstractJarFile jarFile; + + private Permission permission; + + private URL jarFileUrl; + + private final JarEntryName jarEntryName; + + private java.util.jar.JarEntry jarEntry; + + private JarURLConnection(URL url, AbstractJarFile jarFile, JarEntryName jarEntryName) throws IOException { + // What we pass to super is ultimately ignored + super(EMPTY_JAR_URL); + this.url = url; + this.jarFile = jarFile; + this.jarEntryName = jarEntryName; + } + + @Override + public void connect() throws IOException { + if (this.jarFile == null) { + throw FILE_NOT_FOUND_EXCEPTION; + } + if (!this.jarEntryName.isEmpty() && this.jarEntry == null) { + this.jarEntry = this.jarFile.getJarEntry(getEntryName()); + if (this.jarEntry == null) { + throwFileNotFound(this.jarEntryName, this.jarFile); + } + } + this.connected = true; + } + + @Override + public java.util.jar.JarFile getJarFile() throws IOException { + connect(); + return this.jarFile; + } + + @Override + public URL getJarFileURL() { + if (this.jarFile == null) { + throw NOT_FOUND_CONNECTION_EXCEPTION; + } + if (this.jarFileUrl == null) { + this.jarFileUrl = buildJarFileUrl(); + } + return this.jarFileUrl; + } + + private URL buildJarFileUrl() { + try { + String spec = this.jarFile.getUrl().getFile(); + if (spec.endsWith(SEPARATOR)) { + spec = spec.substring(0, spec.length() - SEPARATOR.length()); + } + if (!spec.contains(SEPARATOR)) { + return new URL(spec); + } + return new URL("jar:" + spec); + } + catch (MalformedURLException ex) { + throw new IllegalStateException(ex); + } + } + + @Override + public java.util.jar.JarEntry getJarEntry() throws IOException { + if (this.jarEntryName == null || this.jarEntryName.isEmpty()) { + return null; + } + connect(); + return this.jarEntry; + } + + @Override + public String getEntryName() { + if (this.jarFile == null) { + throw NOT_FOUND_CONNECTION_EXCEPTION; + } + return this.jarEntryName.toString(); + } + + @Override + public InputStream getInputStream() throws IOException { + if (this.jarFile == null) { + throw FILE_NOT_FOUND_EXCEPTION; + } + if (this.jarEntryName.isEmpty() && this.jarFile.getType() == JarFile.JarFileType.DIRECT) { + throw new IOException("no entry name specified"); + } + connect(); + InputStream inputStream = (this.jarEntryName.isEmpty() ? this.jarFile.getInputStream() + : this.jarFile.getInputStream(this.jarEntry)); + if (inputStream == null) { + throwFileNotFound(this.jarEntryName, this.jarFile); + } + return inputStream; + } + + private void throwFileNotFound(Object entry, AbstractJarFile jarFile) throws FileNotFoundException { + if (Boolean.TRUE.equals(useFastExceptions.get())) { + throw FILE_NOT_FOUND_EXCEPTION; + } + throw new FileNotFoundException("JAR entry " + entry + " not found in " + jarFile.getName()); + } + + @Override + public int getContentLength() { + long length = getContentLengthLong(); + if (length > Integer.MAX_VALUE) { + return -1; + } + return (int) length; + } + + @Override + public long getContentLengthLong() { + if (this.jarFile == null) { + return -1; + } + try { + if (this.jarEntryName.isEmpty()) { + return this.jarFile.size(); + } + java.util.jar.JarEntry entry = getJarEntry(); + return (entry != null) ? (int) entry.getSize() : -1; + } + catch (IOException ex) { + return -1; + } + } + + @Override + public Object getContent() throws IOException { + connect(); + return this.jarEntryName.isEmpty() ? this.jarFile : super.getContent(); + } + + @Override + public String getContentType() { + return (this.jarEntryName != null) ? this.jarEntryName.getContentType() : null; + } + + @Override + public Permission getPermission() throws IOException { + if (this.jarFile == null) { + throw FILE_NOT_FOUND_EXCEPTION; + } + if (this.permission == null) { + this.permission = this.jarFile.getPermission(); + } + return this.permission; + } + + @Override + public long getLastModified() { + if (this.jarFile == null || this.jarEntryName.isEmpty()) { + return 0; + } + try { + java.util.jar.JarEntry entry = getJarEntry(); + return (entry != null) ? entry.getTime() : 0; + } + catch (IOException ex) { + return 0; + } + } + + static void setUseFastExceptions(boolean useFastExceptions) { + JarURLConnection.useFastExceptions.set(useFastExceptions); + } + + static JarURLConnection get(URL url, JarFile jarFile) throws IOException { + StringSequence spec = new StringSequence(url.getFile()); + int index = indexOfRootSpec(spec, jarFile.getPathFromRoot()); + if (index == -1) { + return (Boolean.TRUE.equals(useFastExceptions.get()) ? NOT_FOUND_CONNECTION + : new JarURLConnection(url, null, EMPTY_JAR_ENTRY_NAME)); + } + int separator; + while ((separator = spec.indexOf(SEPARATOR, index)) > 0) { + JarEntryName entryName = JarEntryName.get(spec.subSequence(index, separator)); + JarEntry jarEntry = jarFile.getJarEntry(entryName.toCharSequence()); + if (jarEntry == null) { + return JarURLConnection.notFound(jarFile, entryName); + } + jarFile = jarFile.getNestedJarFile(jarEntry); + index = separator + SEPARATOR.length(); + } + JarEntryName jarEntryName = JarEntryName.get(spec, index); + if (Boolean.TRUE.equals(useFastExceptions.get()) && !jarEntryName.isEmpty() + && !jarFile.containsEntry(jarEntryName.toString())) { + return NOT_FOUND_CONNECTION; + } + return new JarURLConnection(url, jarFile.getWrapper(), jarEntryName); + } + + private static int indexOfRootSpec(StringSequence file, String pathFromRoot) { + int separatorIndex = file.indexOf(SEPARATOR); + if (separatorIndex < 0 || !file.startsWith(pathFromRoot, separatorIndex)) { + return -1; + } + return separatorIndex + SEPARATOR.length() + pathFromRoot.length(); + } + + private static JarURLConnection notFound() { + try { + return notFound(null, null); + } + catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + + private static JarURLConnection notFound(JarFile jarFile, JarEntryName jarEntryName) throws IOException { + if (Boolean.TRUE.equals(useFastExceptions.get())) { + return NOT_FOUND_CONNECTION; + } + return new JarURLConnection(null, jarFile, jarEntryName); + } + + /** + * A JarEntryName parsed from a URL String. + */ + static class JarEntryName { + + private final StringSequence name; + + private String contentType; + + JarEntryName(StringSequence spec) { + this.name = decode(spec); + } + + private StringSequence decode(StringSequence source) { + if (source.isEmpty() || (source.indexOf('%') < 0)) { + return source; + } + ByteArrayOutputStream bos = new ByteArrayOutputStream(source.length()); + write(source.toString(), bos); + // AsciiBytes is what is used to store the JarEntries so make it symmetric + return new StringSequence(AsciiBytes.toString(bos.toByteArray())); + } + + private void write(String source, ByteArrayOutputStream outputStream) { + int length = source.length(); + for (int i = 0; i < length; i++) { + int c = source.charAt(i); + if (c > 127) { + try { + String encoded = URLEncoder.encode(String.valueOf((char) c), "UTF-8"); + write(encoded, outputStream); + } + catch (UnsupportedEncodingException ex) { + throw new IllegalStateException(ex); + } + } + else { + if (c == '%') { + if ((i + 2) >= length) { + throw new IllegalArgumentException( + "Invalid encoded sequence \"" + source.substring(i) + "\""); + } + c = decodeEscapeSequence(source, i); + i += 2; + } + outputStream.write(c); + } + } + } + + private char decodeEscapeSequence(String source, int i) { + int hi = Character.digit(source.charAt(i + 1), 16); + int lo = Character.digit(source.charAt(i + 2), 16); + if (hi == -1 || lo == -1) { + throw new IllegalArgumentException("Invalid encoded sequence \"" + source.substring(i) + "\""); + } + return ((char) ((hi << 4) + lo)); + } + + CharSequence toCharSequence() { + return this.name; + } + + @Override + public String toString() { + return this.name.toString(); + } + + boolean isEmpty() { + return this.name.isEmpty(); + } + + String getContentType() { + if (this.contentType == null) { + this.contentType = deduceContentType(); + } + return this.contentType; + } + + private String deduceContentType() { + // Guess the content type, don't bother with streams as mark is not supported + String type = isEmpty() ? "x-java/jar" : null; + type = (type != null) ? type : guessContentTypeFromName(toString()); + type = (type != null) ? type : "content/unknown"; + return type; + } + + static JarEntryName get(StringSequence spec) { + return get(spec, 0); + } + + static JarEntryName get(StringSequence spec, int beginIndex) { + if (spec.length() <= beginIndex) { + return EMPTY_JAR_ENTRY_NAME; + } + return new JarEntryName(spec.subSequence(beginIndex)); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/StringSequence.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/StringSequence.java new file mode 100644 index 0000000000..12850a4ebe --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/StringSequence.java @@ -0,0 +1,157 @@ +/* + * Copyright 2012-2023 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.loader.jar; + +import java.util.Objects; + +/** + * A {@link CharSequence} backed by a single shared {@link String}. Unlike a regular + * {@link String}, {@link #subSequence(int, int)} operations will not copy the underlying + * character array. + * + * @author Phillip Webb + */ +final class StringSequence implements CharSequence { + + private final String source; + + private final int start; + + private final int end; + + private int hash; + + StringSequence(String source) { + this(source, 0, (source != null) ? source.length() : -1); + } + + StringSequence(String source, int start, int end) { + Objects.requireNonNull(source, "Source must not be null"); + if (start < 0) { + throw new StringIndexOutOfBoundsException(start); + } + if (end > source.length()) { + throw new StringIndexOutOfBoundsException(end); + } + this.source = source; + this.start = start; + this.end = end; + } + + StringSequence subSequence(int start) { + return subSequence(start, length()); + } + + @Override + public StringSequence subSequence(int start, int end) { + int subSequenceStart = this.start + start; + int subSequenceEnd = this.start + end; + if (subSequenceStart > this.end) { + throw new StringIndexOutOfBoundsException(start); + } + if (subSequenceEnd > this.end) { + throw new StringIndexOutOfBoundsException(end); + } + if (start == 0 && subSequenceEnd == this.end) { + return this; + } + return new StringSequence(this.source, subSequenceStart, subSequenceEnd); + } + + /** + * Returns {@code true} if the sequence is empty. Public to be compatible with JDK 15. + * @return {@code true} if {@link #length()} is {@code 0}, otherwise {@code false} + */ + public boolean isEmpty() { + return length() == 0; + } + + @Override + public int length() { + return this.end - this.start; + } + + @Override + public char charAt(int index) { + return this.source.charAt(this.start + index); + } + + int indexOf(char ch) { + return this.source.indexOf(ch, this.start) - this.start; + } + + int indexOf(String str) { + return this.source.indexOf(str, this.start) - this.start; + } + + int indexOf(String str, int fromIndex) { + return this.source.indexOf(str, this.start + fromIndex) - this.start; + } + + boolean startsWith(String prefix) { + return startsWith(prefix, 0); + } + + boolean startsWith(String prefix, int offset) { + int prefixLength = prefix.length(); + int length = length(); + if (length - prefixLength - offset < 0) { + return false; + } + return this.source.startsWith(prefix, this.start + offset); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof CharSequence other)) { + return false; + } + int n = length(); + if (n != other.length()) { + return false; + } + int i = 0; + while (n-- != 0) { + if (charAt(i) != other.charAt(i)) { + return false; + } + i++; + } + return true; + } + + @Override + public int hashCode() { + int hash = this.hash; + if (hash == 0 && length() > 0) { + for (int i = this.start; i < this.end; i++) { + hash = 31 * hash + this.source.charAt(i); + } + this.hash = hash; + } + return hash; + } + + @Override + public String toString() { + return this.source.substring(this.start, this.end); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/ZipInflaterInputStream.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/ZipInflaterInputStream.java new file mode 100644 index 0000000000..67624460cc --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/ZipInflaterInputStream.java @@ -0,0 +1,88 @@ +/* + * Copyright 2012-2023 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.loader.jar; + +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.util.zip.Inflater; +import java.util.zip.InflaterInputStream; + +/** + * {@link InflaterInputStream} that supports the writing of an extra "dummy" byte (which + * is required with JDK 6) and returns accurate available() results. + * + * @author Phillip Webb + */ +class ZipInflaterInputStream extends InflaterInputStream { + + private int available; + + private boolean extraBytesWritten; + + ZipInflaterInputStream(InputStream inputStream, int size) { + super(inputStream, new Inflater(true), getInflaterBufferSize(size)); + this.available = size; + } + + @Override + public int available() throws IOException { + if (this.available < 0) { + return super.available(); + } + return this.available; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + int result = super.read(b, off, len); + if (result != -1) { + this.available -= result; + } + return result; + } + + @Override + public void close() throws IOException { + super.close(); + this.inf.end(); + } + + @Override + protected void fill() throws IOException { + try { + super.fill(); + } + catch (EOFException ex) { + if (this.extraBytesWritten) { + throw ex; + } + this.len = 1; + this.buf[0] = 0x0; + this.extraBytesWritten = true; + this.inf.setInput(this.buf, 0, this.len); + } + } + + private static int getInflaterBufferSize(long size) { + size += 2; // inflater likes some space + size = (size > 65536) ? 8192 : size; + size = (size <= 0) ? 4096 : size; + return (int) size; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/package-info.java new file mode 100644 index 0000000000..638afe45f4 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2023 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. + */ + +/** + * Support for loading and manipulating JAR/WAR files. + */ +package org.springframework.boot.loader.jar; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/JarMode.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/JarMode.java new file mode 100644 index 0000000000..162e4a6a73 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/JarMode.java @@ -0,0 +1,42 @@ +/* + * Copyright 2012-2023 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.loader.jarmode; + +/** + * Interface registered in {@code spring.factories} to provides extended 'jarmode' + * support. + * + * @author Phillip Webb + * @since 2.3.0 + */ +public interface JarMode { + + /** + * Returns if this accepts and can run the given mode. + * @param mode the mode to check + * @return if this instance accepts the mode + */ + boolean accepts(String mode); + + /** + * Run the jar in the given mode. + * @param mode the mode to use + * @param args any program arguments + */ + void run(String mode, String[] args); + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/JarModeLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/JarModeLauncher.java new file mode 100644 index 0000000000..44fcb7902e --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/JarModeLauncher.java @@ -0,0 +1,53 @@ +/* + * Copyright 2012-2023 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.loader.jarmode; + +import java.util.List; + +import org.springframework.core.io.support.SpringFactoriesLoader; +import org.springframework.util.ClassUtils; + +/** + * Delegate class used to launch the fat jar in a specific mode. + * + * @author Phillip Webb + * @since 2.3.0 + */ +public final class JarModeLauncher { + + static final String DISABLE_SYSTEM_EXIT = JarModeLauncher.class.getName() + ".DISABLE_SYSTEM_EXIT"; + + private JarModeLauncher() { + } + + public static void main(String[] args) { + String mode = System.getProperty("jarmode"); + List candidates = SpringFactoriesLoader.loadFactories(JarMode.class, + ClassUtils.getDefaultClassLoader()); + for (JarMode candidate : candidates) { + if (candidate.accepts(mode)) { + candidate.run(mode, args); + return; + } + } + System.err.println("Unsupported jarmode '" + mode + "'"); + if (!Boolean.getBoolean(DISABLE_SYSTEM_EXIT)) { + System.exit(1); + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/TestJarMode.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/TestJarMode.java new file mode 100644 index 0000000000..2e17175690 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/TestJarMode.java @@ -0,0 +1,38 @@ +/* + * Copyright 2012-2023 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.loader.jarmode; + +import java.util.Arrays; + +/** + * {@link JarMode} for testing. + * + * @author Phillip Webb + */ +class TestJarMode implements JarMode { + + @Override + public boolean accepts(String mode) { + return "test".equals(mode); + } + + @Override + public void run(String mode, String[] args) { + System.out.println("running in " + mode + " jar mode " + Arrays.asList(args)); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/package-info.java new file mode 100644 index 0000000000..2f3b5a74e8 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/package-info.java @@ -0,0 +1,22 @@ +/* + * Copyright 2012-2023 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. + */ + +/** + * Support for launching the JAR using jarmode. + * + * @see org.springframework.boot.loader.jarmode.JarModeLauncher + */ +package org.springframework.boot.loader.jarmode; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/JarLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/JarLauncher.java new file mode 100644 index 0000000000..5beb8d1096 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/JarLauncher.java @@ -0,0 +1,34 @@ +/* + * Copyright 2012-2023 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.loader.launch; + +/** + * Repackaged {@link org.springframework.boot.loader.JarLauncher}. + * + * @author Phillip Webb + * @since 3.2.0 + */ +public final class JarLauncher { + + private JarLauncher() { + } + + public static void main(String[] args) throws Exception { + org.springframework.boot.loader.JarLauncher.main(args); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/PropertiesLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/PropertiesLauncher.java new file mode 100644 index 0000000000..d80fb0bb71 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/PropertiesLauncher.java @@ -0,0 +1,34 @@ +/* + * Copyright 2012-2023 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.loader.launch; + +/** + * Repackaged {@link org.springframework.boot.loader.PropertiesLauncher}. + * + * @author Phillip Webb + * @since 3.2.0 + */ +public final class PropertiesLauncher { + + private PropertiesLauncher() { + } + + public static void main(String[] args) throws Exception { + org.springframework.boot.loader.PropertiesLauncher.main(args); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/WarLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/WarLauncher.java new file mode 100644 index 0000000000..9392d3bf2b --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/WarLauncher.java @@ -0,0 +1,34 @@ +/* + * Copyright 2012-2023 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.loader.launch; + +/** + * Repackaged {@link org.springframework.boot.loader.WarLauncher}. + * + * @author Phillip Webb + * @since 3.2.0 + */ +public final class WarLauncher { + + private WarLauncher() { + } + + public static void main(String[] args) throws Exception { + org.springframework.boot.loader.WarLauncher.main(args); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/package-info.java new file mode 100644 index 0000000000..7968d509a2 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright 2012-2023 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. + */ + +/** + * Repackaged launcher classes. + * + * @see org.springframework.boot.loader.launch.JarLauncher + * @see org.springframework.boot.loader.launch.WarLauncher + */ +package org.springframework.boot.loader.launch; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/package-info.java new file mode 100644 index 0000000000..4b32f644f5 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/package-info.java @@ -0,0 +1,26 @@ +/* + * Copyright 2012-2023 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. + */ + +/** + * System that allows self-contained JAR/WAR archives to be launched using + * {@code java -jar}. Archives can include nested packaged dependency JARs (there is no + * need to create shade style jars) and are executed without unpacking. The only + * constraint is that nested JARs must be stored in the archive uncompressed. + * + * @see org.springframework.boot.loader.JarLauncher + * @see org.springframework.boot.loader.WarLauncher + */ +package org.springframework.boot.loader; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/util/SystemPropertyUtils.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/util/SystemPropertyUtils.java new file mode 100644 index 0000000000..df00705e9e --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/util/SystemPropertyUtils.java @@ -0,0 +1,232 @@ +/* + * Copyright 2012-2023 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.loader.util; + +import java.util.HashSet; +import java.util.Locale; +import java.util.Properties; +import java.util.Set; + +/** + * Helper class for resolving placeholders in texts. Usually applied to file paths. + *

+ * A text may contain {@code $ ...} placeholders, to be resolved as system properties: + * e.g. {@code $ user.dir}. Default values can be supplied using the ":" separator between + * key and value. + *

+ * Adapted from Spring. + * + * @author Juergen Hoeller + * @author Rob Harrop + * @author Dave Syer + * @since 1.0.0 + * @see System#getProperty(String) + */ +public abstract class SystemPropertyUtils { + + /** + * Prefix for system property placeholders: "${". + */ + public static final String PLACEHOLDER_PREFIX = "${"; + + /** + * Suffix for system property placeholders: "}". + */ + public static final String PLACEHOLDER_SUFFIX = "}"; + + /** + * Value separator for system property placeholders: ":". + */ + public static final String VALUE_SEPARATOR = ":"; + + private static final String SIMPLE_PREFIX = PLACEHOLDER_PREFIX.substring(1); + + /** + * Resolve ${...} placeholders in the given text, replacing them with corresponding + * system property values. + * @param text the String to resolve + * @return the resolved String + * @throws IllegalArgumentException if there is an unresolvable placeholder + * @see #PLACEHOLDER_PREFIX + * @see #PLACEHOLDER_SUFFIX + */ + public static String resolvePlaceholders(String text) { + if (text == null) { + return text; + } + return parseStringValue(null, text, text, new HashSet<>()); + } + + /** + * Resolve ${...} placeholders in the given text, replacing them with corresponding + * system property values. + * @param properties a properties instance to use in addition to System + * @param text the String to resolve + * @return the resolved String + * @throws IllegalArgumentException if there is an unresolvable placeholder + * @see #PLACEHOLDER_PREFIX + * @see #PLACEHOLDER_SUFFIX + */ + public static String resolvePlaceholders(Properties properties, String text) { + if (text == null) { + return text; + } + return parseStringValue(properties, text, text, new HashSet<>()); + } + + private static String parseStringValue(Properties properties, String value, String current, + Set visitedPlaceholders) { + + StringBuilder buf = new StringBuilder(current); + + int startIndex = current.indexOf(PLACEHOLDER_PREFIX); + while (startIndex != -1) { + int endIndex = findPlaceholderEndIndex(buf, startIndex); + if (endIndex != -1) { + String placeholder = buf.substring(startIndex + PLACEHOLDER_PREFIX.length(), endIndex); + String originalPlaceholder = placeholder; + if (!visitedPlaceholders.add(originalPlaceholder)) { + throw new IllegalArgumentException( + "Circular placeholder reference '" + originalPlaceholder + "' in property definitions"); + } + // Recursive invocation, parsing placeholders contained in the + // placeholder + // key. + placeholder = parseStringValue(properties, value, placeholder, visitedPlaceholders); + // Now obtain the value for the fully resolved key... + String propVal = resolvePlaceholder(properties, value, placeholder); + if (propVal == null) { + int separatorIndex = placeholder.indexOf(VALUE_SEPARATOR); + if (separatorIndex != -1) { + String actualPlaceholder = placeholder.substring(0, separatorIndex); + String defaultValue = placeholder.substring(separatorIndex + VALUE_SEPARATOR.length()); + propVal = resolvePlaceholder(properties, value, actualPlaceholder); + if (propVal == null) { + propVal = defaultValue; + } + } + } + if (propVal != null) { + // Recursive invocation, parsing placeholders contained in the + // previously resolved placeholder value. + propVal = parseStringValue(properties, value, propVal, visitedPlaceholders); + buf.replace(startIndex, endIndex + PLACEHOLDER_SUFFIX.length(), propVal); + startIndex = buf.indexOf(PLACEHOLDER_PREFIX, startIndex + propVal.length()); + } + else { + // Proceed with unprocessed value. + startIndex = buf.indexOf(PLACEHOLDER_PREFIX, endIndex + PLACEHOLDER_SUFFIX.length()); + } + visitedPlaceholders.remove(originalPlaceholder); + } + else { + startIndex = -1; + } + } + + return buf.toString(); + } + + private static String resolvePlaceholder(Properties properties, String text, String placeholderName) { + String propVal = getProperty(placeholderName, null, text); + if (propVal != null) { + return propVal; + } + return (properties != null) ? properties.getProperty(placeholderName) : null; + } + + public static String getProperty(String key) { + return getProperty(key, null, ""); + } + + public static String getProperty(String key, String defaultValue) { + return getProperty(key, defaultValue, ""); + } + + /** + * Search the System properties and environment variables for a value with the + * provided key. Environment variables in {@code UPPER_CASE} style are allowed where + * System properties would normally be {@code lower.case}. + * @param key the key to resolve + * @param defaultValue the default value + * @param text optional extra context for an error message if the key resolution fails + * (e.g. if System properties are not accessible) + * @return a static property value or null of not found + */ + public static String getProperty(String key, String defaultValue, String text) { + try { + String propVal = System.getProperty(key); + if (propVal == null) { + // Fall back to searching the system environment. + propVal = System.getenv(key); + } + if (propVal == null) { + // Try with underscores. + String name = key.replace('.', '_'); + propVal = System.getenv(name); + } + if (propVal == null) { + // Try uppercase with underscores as well. + String name = key.toUpperCase(Locale.ENGLISH).replace('.', '_'); + propVal = System.getenv(name); + } + if (propVal != null) { + return propVal; + } + } + catch (Throwable ex) { + System.err.println("Could not resolve key '" + key + "' in '" + text + + "' as system property or in environment: " + ex); + } + return defaultValue; + } + + private static int findPlaceholderEndIndex(CharSequence buf, int startIndex) { + int index = startIndex + PLACEHOLDER_PREFIX.length(); + int withinNestedPlaceholder = 0; + while (index < buf.length()) { + if (substringMatch(buf, index, PLACEHOLDER_SUFFIX)) { + if (withinNestedPlaceholder > 0) { + withinNestedPlaceholder--; + index = index + PLACEHOLDER_SUFFIX.length(); + } + else { + return index; + } + } + else if (substringMatch(buf, index, SIMPLE_PREFIX)) { + withinNestedPlaceholder++; + index = index + SIMPLE_PREFIX.length(); + } + else { + index++; + } + } + return -1; + } + + private static boolean substringMatch(CharSequence str, int index, CharSequence substring) { + for (int j = 0; j < substring.length(); j++) { + int i = index + j; + if (i >= str.length() || str.charAt(i) != substring.charAt(j)) { + return false; + } + } + return true; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/util/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/util/package-info.java new file mode 100644 index 0000000000..d3d7eef2d9 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/util/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2023 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. + */ + +/** + * Utilities used by Spring Boot's JAR loading. + */ +package org.springframework.boot.loader.util; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/AbstractExecutableArchiveLauncherTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/AbstractExecutableArchiveLauncherTests.java new file mode 100644 index 0000000000..60e3cb2765 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/AbstractExecutableArchiveLauncherTests.java @@ -0,0 +1,149 @@ +/* + * Copyright 2012-2023 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.loader; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Enumeration; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; +import java.util.zip.CRC32; +import java.util.zip.ZipEntry; + +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.archive.Archive; +import org.springframework.util.FileCopyUtils; + +/** + * Base class for testing {@link ExecutableArchiveLauncher} implementations. + * + * @author Andy Wilkinson + * @author Madhura Bhave + * @author Scott Frederick + */ +public abstract class AbstractExecutableArchiveLauncherTests { + + @TempDir + File tempDir; + + protected File createJarArchive(String name, String entryPrefix) throws IOException { + return createJarArchive(name, entryPrefix, false, Collections.emptyList()); + } + + @SuppressWarnings("resource") + protected File createJarArchive(String name, String entryPrefix, boolean indexed, List extraLibs) + throws IOException { + return createJarArchive(name, null, entryPrefix, indexed, extraLibs); + } + + @SuppressWarnings("resource") + protected File createJarArchive(String name, Manifest manifest, String entryPrefix, boolean indexed, + List extraLibs) throws IOException { + File archive = new File(this.tempDir, name); + JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(archive)); + if (manifest != null) { + jarOutputStream.putNextEntry(new JarEntry("META-INF/")); + jarOutputStream.putNextEntry(new JarEntry("META-INF/MANIFEST.MF")); + manifest.write(jarOutputStream); + jarOutputStream.closeEntry(); + } + jarOutputStream.putNextEntry(new JarEntry(entryPrefix + "/")); + jarOutputStream.putNextEntry(new JarEntry(entryPrefix + "/classes/")); + jarOutputStream.putNextEntry(new JarEntry(entryPrefix + "/lib/")); + if (indexed) { + jarOutputStream.putNextEntry(new JarEntry(entryPrefix + "/classpath.idx")); + Writer writer = new OutputStreamWriter(jarOutputStream, StandardCharsets.UTF_8); + writer.write("- \"" + entryPrefix + "/lib/foo.jar\"\n"); + writer.write("- \"" + entryPrefix + "/lib/bar.jar\"\n"); + writer.write("- \"" + entryPrefix + "/lib/baz.jar\"\n"); + writer.flush(); + jarOutputStream.closeEntry(); + } + addNestedJars(entryPrefix, "/lib/foo.jar", jarOutputStream); + addNestedJars(entryPrefix, "/lib/bar.jar", jarOutputStream); + addNestedJars(entryPrefix, "/lib/baz.jar", jarOutputStream); + for (String lib : extraLibs) { + addNestedJars(entryPrefix, "/lib/" + lib, jarOutputStream); + } + jarOutputStream.close(); + return archive; + } + + private void addNestedJars(String entryPrefix, String lib, JarOutputStream jarOutputStream) throws IOException { + JarEntry libFoo = new JarEntry(entryPrefix + lib); + libFoo.setMethod(ZipEntry.STORED); + ByteArrayOutputStream fooJarStream = new ByteArrayOutputStream(); + new JarOutputStream(fooJarStream).close(); + libFoo.setSize(fooJarStream.size()); + CRC32 crc32 = new CRC32(); + crc32.update(fooJarStream.toByteArray()); + libFoo.setCrc(crc32.getValue()); + jarOutputStream.putNextEntry(libFoo); + jarOutputStream.write(fooJarStream.toByteArray()); + } + + protected File explode(File archive) throws IOException { + File exploded = new File(this.tempDir, "exploded"); + exploded.mkdirs(); + JarFile jarFile = new JarFile(archive); + Enumeration entries = jarFile.entries(); + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + File entryFile = new File(exploded, entry.getName()); + if (entry.isDirectory()) { + entryFile.mkdirs(); + } + else { + FileCopyUtils.copy(jarFile.getInputStream(entry), new FileOutputStream(entryFile)); + } + } + jarFile.close(); + return exploded; + } + + protected Set getUrls(List archives) throws MalformedURLException { + Set urls = new LinkedHashSet<>(archives.size()); + for (Archive archive : archives) { + urls.add(archive.getUrl()); + } + return urls; + } + + protected final URL toUrl(File file) { + try { + return file.toURI().toURL(); + } + catch (MalformedURLException ex) { + throw new IllegalStateException(ex); + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/ClassPathIndexFileTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/ClassPathIndexFileTests.java new file mode 100644 index 0000000000..4cd1b4e8d2 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/ClassPathIndexFileTests.java @@ -0,0 +1,109 @@ +/* + * Copyright 2012-2023 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.loader; + +import java.io.File; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link ClassPathIndexFile}. + * + * @author Madhura Bhave + * @author Phillip Webb + */ +class ClassPathIndexFileTests { + + @TempDir + File temp; + + @Test + void loadIfPossibleWhenRootIsNotFileReturnsNull() { + assertThatIllegalArgumentException() + .isThrownBy(() -> ClassPathIndexFile.loadIfPossible(new URL("https://example.com/file"), "test.idx")) + .withMessage("URL does not reference a file"); + } + + @Test + void loadIfPossibleWhenRootDoesNotExistReturnsNull() throws Exception { + File root = new File(this.temp, "missing"); + assertThat(ClassPathIndexFile.loadIfPossible(root.toURI().toURL(), "test.idx")).isNull(); + } + + @Test + void loadIfPossibleWhenRootIsDirectoryThrowsException() throws Exception { + File root = new File(this.temp, "directory"); + root.mkdirs(); + assertThat(ClassPathIndexFile.loadIfPossible(root.toURI().toURL(), "test.idx")).isNull(); + } + + @Test + void loadIfPossibleReturnsInstance() throws Exception { + ClassPathIndexFile indexFile = copyAndLoadTestIndexFile(); + assertThat(indexFile).isNotNull(); + } + + @Test + void sizeReturnsNumberOfLines() throws Exception { + ClassPathIndexFile indexFile = copyAndLoadTestIndexFile(); + assertThat(indexFile.size()).isEqualTo(5); + } + + @Test + void getUrlsReturnsUrls() throws Exception { + ClassPathIndexFile indexFile = copyAndLoadTestIndexFile(); + List urls = indexFile.getUrls(); + List expected = new ArrayList<>(); + expected.add(new File(this.temp, "BOOT-INF/layers/one/lib/a.jar")); + expected.add(new File(this.temp, "BOOT-INF/layers/one/lib/b.jar")); + expected.add(new File(this.temp, "BOOT-INF/layers/one/lib/c.jar")); + expected.add(new File(this.temp, "BOOT-INF/layers/two/lib/d.jar")); + expected.add(new File(this.temp, "BOOT-INF/layers/two/lib/e.jar")); + assertThat(urls).containsExactly(expected.stream().map(this::toUrl).toArray(URL[]::new)); + } + + private URL toUrl(File file) { + try { + return file.toURI().toURL(); + } + catch (MalformedURLException ex) { + throw new IllegalStateException(ex); + } + } + + private ClassPathIndexFile copyAndLoadTestIndexFile() throws IOException { + copyTestIndexFile(); + ClassPathIndexFile indexFile = ClassPathIndexFile.loadIfPossible(this.temp.toURI().toURL(), "test.idx"); + return indexFile; + } + + private void copyTestIndexFile() throws IOException { + Files.copy(getClass().getResourceAsStream("classpath-index-file.idx"), + new File(this.temp, "test.idx").toPath()); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/JarLauncherTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/JarLauncherTests.java new file mode 100644 index 0000000000..afa32a7c4f --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/JarLauncherTests.java @@ -0,0 +1,154 @@ +/* + * Copyright 2012-2023 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.loader; + +import java.io.File; +import java.io.FileOutputStream; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.jar.Attributes; +import java.util.jar.Attributes.Name; +import java.util.jar.Manifest; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.loader.archive.Archive; +import org.springframework.boot.loader.archive.ExplodedArchive; +import org.springframework.boot.loader.archive.JarFileArchive; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.test.tools.SourceFile; +import org.springframework.core.test.tools.TestCompiler; +import org.springframework.util.FileCopyUtils; +import org.springframework.util.function.ThrowingConsumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link JarLauncher}. + * + * @author Andy Wilkinson + * @author Madhura Bhave + */ +class JarLauncherTests extends AbstractExecutableArchiveLauncherTests { + + @Test + void explodedJarHasOnlyBootInfClassesAndContentsOfBootInfLibOnClasspath() throws Exception { + File explodedRoot = explode(createJarArchive("archive.jar", "BOOT-INF")); + JarLauncher launcher = new JarLauncher(new ExplodedArchive(explodedRoot, true)); + List archives = new ArrayList<>(); + launcher.getClassPathArchivesIterator().forEachRemaining(archives::add); + assertThat(getUrls(archives)).containsExactlyInAnyOrder(getExpectedFileUrls(explodedRoot)); + for (Archive archive : archives) { + archive.close(); + } + } + + @Test + void archivedJarHasOnlyBootInfClassesAndContentsOfBootInfLibOnClasspath() throws Exception { + File jarRoot = createJarArchive("archive.jar", "BOOT-INF"); + try (JarFileArchive archive = new JarFileArchive(jarRoot)) { + JarLauncher launcher = new JarLauncher(archive); + List classPathArchives = new ArrayList<>(); + launcher.getClassPathArchivesIterator().forEachRemaining(classPathArchives::add); + assertThat(classPathArchives).hasSize(4); + assertThat(getUrls(classPathArchives)).containsOnly( + new URL("jar:" + jarRoot.toURI().toURL() + "!/BOOT-INF/classes!/"), + new URL("jar:" + jarRoot.toURI().toURL() + "!/BOOT-INF/lib/foo.jar!/"), + new URL("jar:" + jarRoot.toURI().toURL() + "!/BOOT-INF/lib/bar.jar!/"), + new URL("jar:" + jarRoot.toURI().toURL() + "!/BOOT-INF/lib/baz.jar!/")); + for (Archive classPathArchive : classPathArchives) { + classPathArchive.close(); + } + } + } + + @Test + void explodedJarShouldPreserveClasspathOrderWhenIndexPresent() throws Exception { + File explodedRoot = explode(createJarArchive("archive.jar", "BOOT-INF", true, Collections.emptyList())); + JarLauncher launcher = new JarLauncher(new ExplodedArchive(explodedRoot, true)); + Iterator archives = launcher.getClassPathArchivesIterator(); + URLClassLoader classLoader = (URLClassLoader) launcher.createClassLoader(archives); + URL[] urls = classLoader.getURLs(); + assertThat(urls).containsExactly(getExpectedFileUrls(explodedRoot)); + } + + @Test + void jarFilesPresentInBootInfLibsAndNotInClasspathIndexShouldBeAddedAfterBootInfClasses() throws Exception { + ArrayList extraLibs = new ArrayList<>(Arrays.asList("extra-1.jar", "extra-2.jar")); + File explodedRoot = explode(createJarArchive("archive.jar", "BOOT-INF", true, extraLibs)); + JarLauncher launcher = new JarLauncher(new ExplodedArchive(explodedRoot, true)); + Iterator archives = launcher.getClassPathArchivesIterator(); + URLClassLoader classLoader = (URLClassLoader) launcher.createClassLoader(archives); + URL[] urls = classLoader.getURLs(); + List expectedFiles = getExpectedFilesWithExtraLibs(explodedRoot); + URL[] expectedFileUrls = expectedFiles.stream().map(this::toUrl).toArray(URL[]::new); + assertThat(urls).containsExactly(expectedFileUrls); + } + + @Test + void explodedJarDefinedPackagesIncludeManifestAttributes() { + Manifest manifest = new Manifest(); + Attributes attributes = manifest.getMainAttributes(); + attributes.put(Name.MANIFEST_VERSION, "1.0"); + attributes.put(Name.IMPLEMENTATION_TITLE, "test"); + SourceFile sourceFile = SourceFile.of("explodedsample/ExampleClass.java", + new ClassPathResource("explodedsample/ExampleClass.txt")); + TestCompiler.forSystem().compile(sourceFile, ThrowingConsumer.of((compiled) -> { + File explodedRoot = explode( + createJarArchive("archive.jar", manifest, "BOOT-INF", true, Collections.emptyList())); + File target = new File(explodedRoot, "BOOT-INF/classes/explodedsample/ExampleClass.class"); + target.getParentFile().mkdirs(); + FileCopyUtils.copy(compiled.getClassLoader().getResourceAsStream("explodedsample/ExampleClass.class"), + new FileOutputStream(target)); + JarLauncher launcher = new JarLauncher(new ExplodedArchive(explodedRoot, true)); + Iterator archives = launcher.getClassPathArchivesIterator(); + URLClassLoader classLoader = (URLClassLoader) launcher.createClassLoader(archives); + Class loaded = classLoader.loadClass("explodedsample.ExampleClass"); + assertThat(loaded.getPackage().getImplementationTitle()).isEqualTo("test"); + })); + } + + protected final URL[] getExpectedFileUrls(File explodedRoot) { + return getExpectedFiles(explodedRoot).stream().map(this::toUrl).toArray(URL[]::new); + } + + protected final List getExpectedFiles(File parent) { + List expected = new ArrayList<>(); + expected.add(new File(parent, "BOOT-INF/classes")); + expected.add(new File(parent, "BOOT-INF/lib/foo.jar")); + expected.add(new File(parent, "BOOT-INF/lib/bar.jar")); + expected.add(new File(parent, "BOOT-INF/lib/baz.jar")); + return expected; + } + + protected final List getExpectedFilesWithExtraLibs(File parent) { + List expected = new ArrayList<>(); + expected.add(new File(parent, "BOOT-INF/classes")); + expected.add(new File(parent, "BOOT-INF/lib/extra-1.jar")); + expected.add(new File(parent, "BOOT-INF/lib/extra-2.jar")); + expected.add(new File(parent, "BOOT-INF/lib/foo.jar")); + expected.add(new File(parent, "BOOT-INF/lib/bar.jar")); + expected.add(new File(parent, "BOOT-INF/lib/baz.jar")); + return expected; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/LaunchedURLClassLoaderTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/LaunchedURLClassLoaderTests.java new file mode 100644 index 0000000000..58084bba8a --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/LaunchedURLClassLoaderTests.java @@ -0,0 +1,111 @@ +/* + * Copyright 2012-2023 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.loader; + +import java.io.File; +import java.io.InputStream; +import java.net.JarURLConnection; +import java.net.URL; +import java.net.URLConnection; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.jar.JarFile; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link LaunchedURLClassLoader}. + * + * @author Dave Syer + * @author Phillip Webb + * @author Andy Wilkinson + */ +@SuppressWarnings("resource") +class LaunchedURLClassLoaderTests { + + @TempDir + File tempDir; + + @Test + void resolveResourceFromArchive() throws Exception { + LaunchedURLClassLoader loader = new LaunchedURLClassLoader( + new URL[] { new URL("jar:file:src/test/resources/jars/app.jar!/") }, getClass().getClassLoader()); + assertThat(loader.getResource("demo/Application.java")).isNotNull(); + } + + @Test + void resolveResourcesFromArchive() throws Exception { + LaunchedURLClassLoader loader = new LaunchedURLClassLoader( + new URL[] { new URL("jar:file:src/test/resources/jars/app.jar!/") }, getClass().getClassLoader()); + assertThat(loader.getResources("demo/Application.java").hasMoreElements()).isTrue(); + } + + @Test + void resolveRootPathFromArchive() throws Exception { + LaunchedURLClassLoader loader = new LaunchedURLClassLoader( + new URL[] { new URL("jar:file:src/test/resources/jars/app.jar!/") }, getClass().getClassLoader()); + assertThat(loader.getResource("")).isNotNull(); + } + + @Test + void resolveRootResourcesFromArchive() throws Exception { + LaunchedURLClassLoader loader = new LaunchedURLClassLoader( + new URL[] { new URL("jar:file:src/test/resources/jars/app.jar!/") }, getClass().getClassLoader()); + assertThat(loader.getResources("").hasMoreElements()).isTrue(); + } + + @Test + void resolveFromNested() throws Exception { + File file = new File(this.tempDir, "test.jar"); + TestJarCreator.createTestJar(file); + try (JarFile jarFile = new JarFile(file)) { + URL url = jarFile.getUrl(); + try (LaunchedURLClassLoader loader = new LaunchedURLClassLoader(new URL[] { url }, null)) { + URL resource = loader.getResource("nested.jar!/3.dat"); + assertThat(resource).hasToString(url + "nested.jar!/3.dat"); + try (InputStream input = resource.openConnection().getInputStream()) { + assertThat(input.read()).isEqualTo(3); + } + } + } + } + + @Test + void resolveFromNestedWhileThreadIsInterrupted() throws Exception { + File file = new File(this.tempDir, "test.jar"); + TestJarCreator.createTestJar(file); + try (JarFile jarFile = new JarFile(file)) { + URL url = jarFile.getUrl(); + try (LaunchedURLClassLoader loader = new LaunchedURLClassLoader(new URL[] { url }, null)) { + Thread.currentThread().interrupt(); + URL resource = loader.getResource("nested.jar!/3.dat"); + assertThat(resource).hasToString(url + "nested.jar!/3.dat"); + URLConnection connection = resource.openConnection(); + try (InputStream input = connection.getInputStream()) { + assertThat(input.read()).isEqualTo(3); + } + ((JarURLConnection) connection).getJarFile().close(); + } + finally { + Thread.interrupted(); + } + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/PropertiesLauncherTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/PropertiesLauncherTests.java new file mode 100644 index 0000000000..ab7c296b38 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/PropertiesLauncherTests.java @@ -0,0 +1,433 @@ +/* + * Copyright 2012-2023 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.loader; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.lang.ref.SoftReference; +import java.net.URL; +import java.net.URLClassLoader; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.jar.Attributes; +import java.util.jar.Manifest; + +import org.assertj.core.api.Condition; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.archive.Archive; +import org.springframework.boot.loader.archive.ExplodedArchive; +import org.springframework.boot.loader.archive.JarFileArchive; +import org.springframework.boot.loader.jar.Handler; +import org.springframework.boot.loader.jar.JarFile; +import org.springframework.boot.testsupport.system.CapturedOutput; +import org.springframework.boot.testsupport.system.OutputCaptureExtension; +import org.springframework.core.io.FileSystemResource; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.util.FileCopyUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.hamcrest.Matchers.containsString; + +/** + * Tests for {@link PropertiesLauncher}. + * + * @author Dave Syer + * @author Andy Wilkinson + */ +@ExtendWith(OutputCaptureExtension.class) +class PropertiesLauncherTests { + + @TempDir + File tempDir; + + private PropertiesLauncher launcher; + + private ClassLoader contextClassLoader; + + private CapturedOutput output; + + @BeforeEach + void setup(CapturedOutput capturedOutput) throws Exception { + this.contextClassLoader = Thread.currentThread().getContextClassLoader(); + clearHandlerCache(); + System.setProperty("loader.home", new File("src/test/resources").getAbsolutePath()); + this.output = capturedOutput; + } + + @AfterEach + void close() throws Exception { + Thread.currentThread().setContextClassLoader(this.contextClassLoader); + System.clearProperty("loader.home"); + System.clearProperty("loader.path"); + System.clearProperty("loader.main"); + System.clearProperty("loader.config.name"); + System.clearProperty("loader.config.location"); + System.clearProperty("loader.system"); + System.clearProperty("loader.classLoader"); + clearHandlerCache(); + if (this.launcher != null) { + this.launcher.close(); + } + } + + @SuppressWarnings("unchecked") + private void clearHandlerCache() throws Exception { + Map rootFileCache = ((SoftReference>) ReflectionTestUtils + .getField(Handler.class, "rootFileCache")).get(); + if (rootFileCache != null) { + for (JarFile rootJarFile : rootFileCache.values()) { + rootJarFile.close(); + } + rootFileCache.clear(); + } + } + + @Test + void testDefaultHome() { + System.clearProperty("loader.home"); + this.launcher = new PropertiesLauncher(); + assertThat(this.launcher.getHomeDirectory()).isEqualTo(new File(System.getProperty("user.dir"))); + } + + @Test + void testAlternateHome() throws Exception { + System.setProperty("loader.home", "src/test/resources/home"); + this.launcher = new PropertiesLauncher(); + assertThat(this.launcher.getHomeDirectory()).isEqualTo(new File(System.getProperty("loader.home"))); + assertThat(this.launcher.getMainClass()).isEqualTo("demo.HomeApplication"); + } + + @Test + void testNonExistentHome() { + System.setProperty("loader.home", "src/test/resources/nonexistent"); + assertThatIllegalStateException().isThrownBy(PropertiesLauncher::new) + .withMessageContaining("Invalid source directory") + .withCauseInstanceOf(IllegalArgumentException.class); + } + + @Test + void testUserSpecifiedMain() throws Exception { + this.launcher = new PropertiesLauncher(); + assertThat(this.launcher.getMainClass()).isEqualTo("demo.Application"); + assertThat(System.getProperty("loader.main")).isNull(); + } + + @Test + void testUserSpecifiedConfigName() throws Exception { + System.setProperty("loader.config.name", "foo"); + this.launcher = new PropertiesLauncher(); + assertThat(this.launcher.getMainClass()).isEqualTo("my.Application"); + assertThat(ReflectionTestUtils.getField(this.launcher, "paths")).hasToString("[etc/]"); + } + + @Test + void testRootOfClasspathFirst() throws Exception { + System.setProperty("loader.config.name", "bar"); + this.launcher = new PropertiesLauncher(); + assertThat(this.launcher.getMainClass()).isEqualTo("my.BarApplication"); + } + + @Test + void testUserSpecifiedDotPath() { + System.setProperty("loader.path", "."); + this.launcher = new PropertiesLauncher(); + assertThat(ReflectionTestUtils.getField(this.launcher, "paths")).hasToString("[.]"); + } + + @Test + void testUserSpecifiedSlashPath() throws Exception { + System.setProperty("loader.path", "jars/"); + this.launcher = new PropertiesLauncher(); + assertThat(ReflectionTestUtils.getField(this.launcher, "paths")).hasToString("[jars/]"); + List archives = new ArrayList<>(); + this.launcher.getClassPathArchivesIterator().forEachRemaining(archives::add); + assertThat(archives).areExactly(1, endingWith("app.jar")); + } + + @Test + void testUserSpecifiedWildcardPath() throws Exception { + System.setProperty("loader.path", "jars/*"); + System.setProperty("loader.main", "demo.Application"); + this.launcher = new PropertiesLauncher(); + assertThat(ReflectionTestUtils.getField(this.launcher, "paths")).hasToString("[jars/]"); + this.launcher.launch(new String[0]); + waitFor("Hello World"); + } + + @Test + void testUserSpecifiedJarPath() throws Exception { + System.setProperty("loader.path", "jars/app.jar"); + System.setProperty("loader.main", "demo.Application"); + this.launcher = new PropertiesLauncher(); + assertThat(ReflectionTestUtils.getField(this.launcher, "paths")).hasToString("[jars/app.jar]"); + this.launcher.launch(new String[0]); + waitFor("Hello World"); + } + + @Test + void testUserSpecifiedRootOfJarPath() throws Exception { + System.setProperty("loader.path", "jar:file:./src/test/resources/nested-jars/app.jar!/"); + this.launcher = new PropertiesLauncher(); + assertThat(ReflectionTestUtils.getField(this.launcher, "paths")) + .hasToString("[jar:file:./src/test/resources/nested-jars/app.jar!/]"); + List archives = new ArrayList<>(); + this.launcher.getClassPathArchivesIterator().forEachRemaining(archives::add); + assertThat(archives).areExactly(1, endingWith("foo.jar!/")); + assertThat(archives).areExactly(1, endingWith("app.jar")); + } + + @Test + void testUserSpecifiedRootOfJarPathWithDot() throws Exception { + System.setProperty("loader.path", "nested-jars/app.jar!/./"); + this.launcher = new PropertiesLauncher(); + List archives = new ArrayList<>(); + this.launcher.getClassPathArchivesIterator().forEachRemaining(archives::add); + assertThat(archives).areExactly(1, endingWith("foo.jar!/")); + assertThat(archives).areExactly(1, endingWith("app.jar")); + } + + @Test + void testUserSpecifiedRootOfJarPathWithDotAndJarPrefix() throws Exception { + System.setProperty("loader.path", "jar:file:./src/test/resources/nested-jars/app.jar!/./"); + this.launcher = new PropertiesLauncher(); + List archives = new ArrayList<>(); + this.launcher.getClassPathArchivesIterator().forEachRemaining(archives::add); + assertThat(archives).areExactly(1, endingWith("foo.jar!/")); + } + + @Test + void testUserSpecifiedJarFileWithNestedArchives() throws Exception { + System.setProperty("loader.path", "nested-jars/app.jar"); + System.setProperty("loader.main", "demo.Application"); + this.launcher = new PropertiesLauncher(); + List archives = new ArrayList<>(); + this.launcher.getClassPathArchivesIterator().forEachRemaining(archives::add); + assertThat(archives).areExactly(1, endingWith("foo.jar!/")); + assertThat(archives).areExactly(1, endingWith("app.jar")); + } + + @Test + void testUserSpecifiedNestedJarPath() throws Exception { + System.setProperty("loader.path", "nested-jars/nested-jar-app.jar!/BOOT-INF/classes/"); + System.setProperty("loader.main", "demo.Application"); + this.launcher = new PropertiesLauncher(); + assertThat(ReflectionTestUtils.getField(this.launcher, "paths")) + .hasToString("[nested-jars/nested-jar-app.jar!/BOOT-INF/classes/]"); + this.launcher.launch(new String[0]); + waitFor("Hello World"); + } + + @Test + void testUserSpecifiedDirectoryContainingJarFileWithNestedArchives() throws Exception { + System.setProperty("loader.path", "nested-jars"); + System.setProperty("loader.main", "demo.Application"); + this.launcher = new PropertiesLauncher(); + this.launcher.launch(new String[0]); + waitFor("Hello World"); + } + + @Test + void testUserSpecifiedJarPathWithDot() throws Exception { + System.setProperty("loader.path", "./jars/app.jar"); + System.setProperty("loader.main", "demo.Application"); + this.launcher = new PropertiesLauncher(); + assertThat(ReflectionTestUtils.getField(this.launcher, "paths")).hasToString("[jars/app.jar]"); + this.launcher.launch(new String[0]); + waitFor("Hello World"); + } + + @Test + void testUserSpecifiedClassLoader() throws Exception { + System.setProperty("loader.path", "jars/app.jar"); + System.setProperty("loader.classLoader", URLClassLoader.class.getName()); + this.launcher = new PropertiesLauncher(); + assertThat(ReflectionTestUtils.getField(this.launcher, "paths")).hasToString("[jars/app.jar]"); + this.launcher.launch(new String[0]); + waitFor("Hello World"); + } + + @Test + void testUserSpecifiedClassPathOrder() throws Exception { + System.setProperty("loader.path", "more-jars/app.jar,jars/app.jar"); + System.setProperty("loader.classLoader", URLClassLoader.class.getName()); + this.launcher = new PropertiesLauncher(); + assertThat(ReflectionTestUtils.getField(this.launcher, "paths")) + .hasToString("[more-jars/app.jar, jars/app.jar]"); + this.launcher.launch(new String[0]); + waitFor("Hello Other World"); + } + + @Test + void testCustomClassLoaderCreation() throws Exception { + System.setProperty("loader.classLoader", TestLoader.class.getName()); + this.launcher = new PropertiesLauncher(); + ClassLoader loader = this.launcher.createClassLoader(archives()); + assertThat(loader).isNotNull(); + assertThat(loader.getClass().getName()).isEqualTo(TestLoader.class.getName()); + } + + private Iterator archives() throws Exception { + List archives = new ArrayList<>(); + String path = System.getProperty("java.class.path"); + for (String url : path.split(File.pathSeparator)) { + Archive archive = archive(url); + if (archive != null) { + archives.add(archive); + } + } + return archives.iterator(); + } + + private Archive archive(String url) throws IOException { + File file = new FileSystemResource(url).getFile(); + if (!file.exists()) { + return null; + } + if (url.endsWith(".jar")) { + return new JarFileArchive(file); + } + return new ExplodedArchive(file); + } + + @Test + void testUserSpecifiedConfigPathWins() throws Exception { + System.setProperty("loader.config.name", "foo"); + System.setProperty("loader.config.location", "classpath:bar.properties"); + this.launcher = new PropertiesLauncher(); + assertThat(this.launcher.getMainClass()).isEqualTo("my.BarApplication"); + } + + @Test + void testSystemPropertySpecifiedMain() throws Exception { + System.setProperty("loader.main", "foo.Bar"); + this.launcher = new PropertiesLauncher(); + assertThat(this.launcher.getMainClass()).isEqualTo("foo.Bar"); + } + + @Test + void testSystemPropertiesSet() { + System.setProperty("loader.system", "true"); + new PropertiesLauncher(); + assertThat(System.getProperty("loader.main")).isEqualTo("demo.Application"); + } + + @Test + void testArgsEnhanced() throws Exception { + System.setProperty("loader.args", "foo"); + this.launcher = new PropertiesLauncher(); + assertThat(Arrays.asList(this.launcher.getArgs("bar"))).hasToString("[foo, bar]"); + } + + @SuppressWarnings("unchecked") + @Test + void testLoadPathCustomizedUsingManifest() throws Exception { + System.setProperty("loader.home", this.tempDir.getAbsolutePath()); + Manifest manifest = new Manifest(); + manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); + manifest.getMainAttributes().putValue("Loader-Path", "/foo.jar, /bar"); + File manifestFile = new File(this.tempDir, "META-INF/MANIFEST.MF"); + manifestFile.getParentFile().mkdirs(); + try (FileOutputStream manifestStream = new FileOutputStream(manifestFile)) { + manifest.write(manifestStream); + } + this.launcher = new PropertiesLauncher(); + assertThat((List) ReflectionTestUtils.getField(this.launcher, "paths")).containsExactly("/foo.jar", + "/bar/"); + } + + @Test + void testManifestWithPlaceholders() throws Exception { + System.setProperty("loader.home", "src/test/resources/placeholders"); + this.launcher = new PropertiesLauncher(); + assertThat(this.launcher.getMainClass()).isEqualTo("demo.FooApplication"); + } + + @Test + void encodedFileUrlLoaderPathIsHandledCorrectly() throws Exception { + File loaderPath = new File(this.tempDir, "loader path"); + loaderPath.mkdir(); + System.setProperty("loader.path", loaderPath.toURI().toURL().toString()); + this.launcher = new PropertiesLauncher(); + List archives = new ArrayList<>(); + this.launcher.getClassPathArchivesIterator().forEachRemaining(archives::add); + assertThat(archives).hasSize(1); + File archiveRoot = (File) ReflectionTestUtils.getField(archives.get(0), "root"); + assertThat(archiveRoot).isEqualTo(loaderPath); + } + + @Test // gh-21575 + void loadResourceFromJarFile() throws Exception { + File jarFile = new File(this.tempDir, "app.jar"); + TestJarCreator.createTestJar(jarFile); + System.setProperty("loader.home", this.tempDir.getAbsolutePath()); + System.setProperty("loader.path", "app.jar"); + this.launcher = new PropertiesLauncher(); + try { + this.launcher.launch(new String[0]); + } + catch (Exception ex) { + // Expected ClassNotFoundException + LaunchedURLClassLoader classLoader = (LaunchedURLClassLoader) Thread.currentThread() + .getContextClassLoader(); + classLoader.close(); + } + URL resource = new URL("jar:" + jarFile.toURI() + "!/nested.jar!/3.dat"); + byte[] bytes = FileCopyUtils.copyToByteArray(resource.openStream()); + assertThat(bytes).isNotEmpty(); + } + + private void waitFor(String value) { + Awaitility.waitAtMost(Duration.ofSeconds(5)).until(this.output::toString, containsString(value)); + } + + private Condition endingWith(String value) { + return new Condition<>() { + + @Override + public boolean matches(Archive archive) { + return archive.toString().endsWith(value); + } + + }; + } + + static class TestLoader extends URLClassLoader { + + TestLoader(ClassLoader parent) { + super(new URL[0], parent); + } + + @Override + protected Class findClass(String name) throws ClassNotFoundException { + return super.findClass(name); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/TestJarCreator.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/TestJarCreator.java new file mode 100644 index 0000000000..c5c5fd3b95 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/TestJarCreator.java @@ -0,0 +1,151 @@ +/* + * Copyright 2012-2023 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.loader; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; +import java.util.zip.CRC32; +import java.util.zip.ZipEntry; + +/** + * Creates a simple test jar. + * + * @author Phillip Webb + */ +public abstract class TestJarCreator { + + private static final int BASE_VERSION = 8; + + private static final int RUNTIME_VERSION; + + static { + int version; + try { + Object runtimeVersion = Runtime.class.getMethod("version").invoke(null); + version = (int) runtimeVersion.getClass().getMethod("major").invoke(runtimeVersion); + } + catch (Throwable ex) { + version = BASE_VERSION; + } + RUNTIME_VERSION = version; + } + + public static void createTestJar(File file) throws Exception { + createTestJar(file, false); + } + + public static void createTestJar(File file, boolean unpackNested) throws Exception { + FileOutputStream fileOutputStream = new FileOutputStream(file); + try (JarOutputStream jarOutputStream = new JarOutputStream(fileOutputStream)) { + jarOutputStream.setComment("outer"); + writeManifest(jarOutputStream, "j1"); + writeEntry(jarOutputStream, "1.dat", 1); + writeEntry(jarOutputStream, "2.dat", 2); + writeDirEntry(jarOutputStream, "d/"); + writeEntry(jarOutputStream, "d/9.dat", 9); + writeDirEntry(jarOutputStream, "special/"); + writeEntry(jarOutputStream, "special/\u00EB.dat", '\u00EB'); + writeNestedEntry("nested.jar", unpackNested, jarOutputStream); + writeNestedEntry("another-nested.jar", unpackNested, jarOutputStream); + writeNestedEntry("space nested.jar", unpackNested, jarOutputStream); + writeNestedMultiReleaseEntry("multi-release.jar", unpackNested, jarOutputStream); + } + } + + private static void writeNestedEntry(String name, boolean unpackNested, JarOutputStream jarOutputStream) + throws Exception { + writeNestedEntry(name, unpackNested, jarOutputStream, false); + } + + private static void writeNestedMultiReleaseEntry(String name, boolean unpackNested, JarOutputStream jarOutputStream) + throws Exception { + writeNestedEntry(name, unpackNested, jarOutputStream, true); + } + + private static void writeNestedEntry(String name, boolean unpackNested, JarOutputStream jarOutputStream, + boolean multiRelease) throws Exception { + JarEntry nestedEntry = new JarEntry(name); + byte[] nestedJarData = getNestedJarData(multiRelease); + nestedEntry.setSize(nestedJarData.length); + nestedEntry.setCompressedSize(nestedJarData.length); + if (unpackNested) { + nestedEntry.setComment("UNPACK:0000000000000000000000000000000000000000"); + } + CRC32 crc32 = new CRC32(); + crc32.update(nestedJarData); + nestedEntry.setCrc(crc32.getValue()); + nestedEntry.setMethod(ZipEntry.STORED); + jarOutputStream.putNextEntry(nestedEntry); + jarOutputStream.write(nestedJarData); + jarOutputStream.closeEntry(); + } + + private static byte[] getNestedJarData(boolean multiRelease) throws Exception { + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + JarOutputStream jarOutputStream = new JarOutputStream(byteArrayOutputStream); + jarOutputStream.setComment("nested"); + writeManifest(jarOutputStream, "j2", multiRelease); + if (multiRelease) { + writeEntry(jarOutputStream, "multi-release.dat", BASE_VERSION); + writeEntry(jarOutputStream, String.format("META-INF/versions/%d/multi-release.dat", RUNTIME_VERSION), + RUNTIME_VERSION); + } + else { + writeEntry(jarOutputStream, "3.dat", 3); + writeEntry(jarOutputStream, "4.dat", 4); + writeEntry(jarOutputStream, "\u00E4.dat", '\u00E4'); + } + jarOutputStream.close(); + return byteArrayOutputStream.toByteArray(); + } + + private static void writeManifest(JarOutputStream jarOutputStream, String name) throws Exception { + writeManifest(jarOutputStream, name, false); + } + + private static void writeManifest(JarOutputStream jarOutputStream, String name, boolean multiRelease) + throws Exception { + writeDirEntry(jarOutputStream, "META-INF/"); + Manifest manifest = new Manifest(); + manifest.getMainAttributes().putValue("Built-By", name); + manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); + if (multiRelease) { + manifest.getMainAttributes().putValue("Multi-Release", Boolean.toString(true)); + } + jarOutputStream.putNextEntry(new ZipEntry("META-INF/MANIFEST.MF")); + manifest.write(jarOutputStream); + jarOutputStream.closeEntry(); + } + + private static void writeDirEntry(JarOutputStream jarOutputStream, String name) throws IOException { + jarOutputStream.putNextEntry(new JarEntry(name)); + jarOutputStream.closeEntry(); + } + + private static void writeEntry(JarOutputStream jarOutputStream, String name, int data) throws IOException { + jarOutputStream.putNextEntry(new JarEntry(name)); + jarOutputStream.write(new byte[] { (byte) data }); + jarOutputStream.closeEntry(); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/WarLauncherTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/WarLauncherTests.java new file mode 100644 index 0000000000..fbab8d36ed --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/WarLauncherTests.java @@ -0,0 +1,121 @@ +/* + * Copyright 2012-2023 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.loader; + +import java.io.File; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.loader.archive.Archive; +import org.springframework.boot.loader.archive.ExplodedArchive; +import org.springframework.boot.loader.archive.JarFileArchive; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link WarLauncher}. + * + * @author Andy Wilkinson + * @author Scott Frederick + */ +class WarLauncherTests extends AbstractExecutableArchiveLauncherTests { + + @Test + void explodedWarHasOnlyWebInfClassesAndContentsOfWebInfLibOnClasspath() throws Exception { + File explodedRoot = explode(createJarArchive("archive.war", "WEB-INF")); + WarLauncher launcher = new WarLauncher(new ExplodedArchive(explodedRoot, true)); + List archives = new ArrayList<>(); + launcher.getClassPathArchivesIterator().forEachRemaining(archives::add); + assertThat(getUrls(archives)).containsExactlyInAnyOrder(getExpectedFileUrls(explodedRoot)); + for (Archive archive : archives) { + archive.close(); + } + } + + @Test + void archivedWarHasOnlyWebInfClassesAndContentsOfWebInfLibOnClasspath() throws Exception { + File jarRoot = createJarArchive("archive.war", "WEB-INF"); + try (JarFileArchive archive = new JarFileArchive(jarRoot)) { + WarLauncher launcher = new WarLauncher(archive); + List classPathArchives = new ArrayList<>(); + launcher.getClassPathArchivesIterator().forEachRemaining(classPathArchives::add); + assertThat(getUrls(classPathArchives)).containsOnly( + new URL("jar:" + jarRoot.toURI().toURL() + "!/WEB-INF/classes!/"), + new URL("jar:" + jarRoot.toURI().toURL() + "!/WEB-INF/lib/foo.jar!/"), + new URL("jar:" + jarRoot.toURI().toURL() + "!/WEB-INF/lib/bar.jar!/"), + new URL("jar:" + jarRoot.toURI().toURL() + "!/WEB-INF/lib/baz.jar!/")); + for (Archive classPathArchive : classPathArchives) { + classPathArchive.close(); + } + } + } + + @Test + void explodedWarShouldPreserveClasspathOrderWhenIndexPresent() throws Exception { + File explodedRoot = explode(createJarArchive("archive.war", "WEB-INF", true, Collections.emptyList())); + WarLauncher launcher = new WarLauncher(new ExplodedArchive(explodedRoot, true)); + Iterator archives = launcher.getClassPathArchivesIterator(); + URLClassLoader classLoader = (URLClassLoader) launcher.createClassLoader(archives); + URL[] urls = classLoader.getURLs(); + assertThat(urls).containsExactly(getExpectedFileUrls(explodedRoot)); + } + + @Test + void warFilesPresentInWebInfLibsAndNotInClasspathIndexShouldBeAddedAfterWebInfClasses() throws Exception { + ArrayList extraLibs = new ArrayList<>(Arrays.asList("extra-1.jar", "extra-2.jar")); + File explodedRoot = explode(createJarArchive("archive.war", "WEB-INF", true, extraLibs)); + WarLauncher launcher = new WarLauncher(new ExplodedArchive(explodedRoot, true)); + Iterator archives = launcher.getClassPathArchivesIterator(); + URLClassLoader classLoader = (URLClassLoader) launcher.createClassLoader(archives); + URL[] urls = classLoader.getURLs(); + List expectedFiles = getExpectedFilesWithExtraLibs(explodedRoot); + URL[] expectedFileUrls = expectedFiles.stream().map(this::toUrl).toArray(URL[]::new); + assertThat(urls).containsExactly(expectedFileUrls); + } + + protected final URL[] getExpectedFileUrls(File explodedRoot) { + return getExpectedFiles(explodedRoot).stream().map(this::toUrl).toArray(URL[]::new); + } + + protected final List getExpectedFiles(File parent) { + List expected = new ArrayList<>(); + expected.add(new File(parent, "WEB-INF/classes")); + expected.add(new File(parent, "WEB-INF/lib/foo.jar")); + expected.add(new File(parent, "WEB-INF/lib/bar.jar")); + expected.add(new File(parent, "WEB-INF/lib/baz.jar")); + return expected; + } + + protected final List getExpectedFilesWithExtraLibs(File parent) { + List expected = new ArrayList<>(); + expected.add(new File(parent, "WEB-INF/classes")); + expected.add(new File(parent, "WEB-INF/lib/extra-1.jar")); + expected.add(new File(parent, "WEB-INF/lib/extra-2.jar")); + expected.add(new File(parent, "WEB-INF/lib/foo.jar")); + expected.add(new File(parent, "WEB-INF/lib/bar.jar")); + expected.add(new File(parent, "WEB-INF/lib/baz.jar")); + return expected; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/archive/ExplodedArchiveTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/archive/ExplodedArchiveTests.java new file mode 100755 index 0000000000..77d2ce185c --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/archive/ExplodedArchiveTests.java @@ -0,0 +1,189 @@ +/* + * Copyright 2012-2023 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.loader.archive; + +import java.io.File; +import java.io.FileOutputStream; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.TestJarCreator; +import org.springframework.boot.loader.archive.Archive.Entry; +import org.springframework.util.FileCopyUtils; +import org.springframework.util.StringUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ExplodedArchive}. + * + * @author Phillip Webb + * @author Dave Syer + * @author Andy Wilkinson + */ +class ExplodedArchiveTests { + + @TempDir + File tempDir; + + private File rootDirectory; + + private ExplodedArchive archive; + + @BeforeEach + void setup() throws Exception { + createArchive(); + } + + @AfterEach + void tearDown() throws Exception { + if (this.archive != null) { + this.archive.close(); + } + } + + private void createArchive() throws Exception { + createArchive(null); + } + + private void createArchive(String directoryName) throws Exception { + File file = new File(this.tempDir, "test.jar"); + TestJarCreator.createTestJar(file); + this.rootDirectory = (StringUtils.hasText(directoryName) ? new File(this.tempDir, directoryName) + : new File(this.tempDir, UUID.randomUUID().toString())); + JarFile jarFile = new JarFile(file); + Enumeration entries = jarFile.entries(); + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + File destination = new File(this.rootDirectory.getAbsolutePath() + File.separator + entry.getName()); + destination.getParentFile().mkdirs(); + if (entry.isDirectory()) { + destination.mkdir(); + } + else { + FileCopyUtils.copy(jarFile.getInputStream(entry), new FileOutputStream(destination)); + } + } + this.archive = new ExplodedArchive(this.rootDirectory); + jarFile.close(); + } + + @Test + void getManifest() throws Exception { + assertThat(this.archive.getManifest().getMainAttributes().getValue("Built-By")).isEqualTo("j1"); + } + + @Test + void getEntries() { + Map entries = getEntriesMap(this.archive); + assertThat(entries).hasSize(12); + } + + @Test + void getUrl() throws Exception { + assertThat(this.archive.getUrl()).isEqualTo(this.rootDirectory.toURI().toURL()); + } + + @Test + void getUrlWithSpaceInPath() throws Exception { + createArchive("spaces in the name"); + assertThat(this.archive.getUrl()).isEqualTo(this.rootDirectory.toURI().toURL()); + } + + @Test + void getNestedArchive() throws Exception { + Entry entry = getEntriesMap(this.archive).get("nested.jar"); + Archive nested = this.archive.getNestedArchive(entry); + assertThat(nested.getUrl()).hasToString(this.rootDirectory.toURI() + "nested.jar"); + nested.close(); + } + + @Test + void nestedDirArchive() throws Exception { + Entry entry = getEntriesMap(this.archive).get("d/"); + Archive nested = this.archive.getNestedArchive(entry); + Map nestedEntries = getEntriesMap(nested); + assertThat(nestedEntries).hasSize(1); + assertThat(nested.getUrl()).hasToString("file:" + this.rootDirectory.toURI().getPath() + "d/"); + } + + @Test + void getNonRecursiveEntriesForRoot() throws Exception { + try (ExplodedArchive explodedArchive = new ExplodedArchive(new File("/"), false)) { + Map entries = getEntriesMap(explodedArchive); + assertThat(entries).hasSizeGreaterThan(1); + } + } + + @Test + void getNonRecursiveManifest() throws Exception { + try (ExplodedArchive explodedArchive = new ExplodedArchive(new File("src/test/resources/root"))) { + assertThat(explodedArchive.getManifest()).isNotNull(); + Map entries = getEntriesMap(explodedArchive); + assertThat(entries).hasSize(4); + } + } + + @Test + void getNonRecursiveManifestEvenIfNonRecursive() throws Exception { + try (ExplodedArchive explodedArchive = new ExplodedArchive(new File("src/test/resources/root"), false)) { + assertThat(explodedArchive.getManifest()).isNotNull(); + Map entries = getEntriesMap(explodedArchive); + assertThat(entries).hasSize(3); + } + } + + @Test + void getResourceAsStream() throws Exception { + try (ExplodedArchive explodedArchive = new ExplodedArchive(new File("src/test/resources/root"))) { + assertThat(explodedArchive.getManifest()).isNotNull(); + URLClassLoader loader = new URLClassLoader(new URL[] { explodedArchive.getUrl() }); + assertThat(loader.getResourceAsStream("META-INF/spring/application.xml")).isNotNull(); + loader.close(); + } + } + + @Test + void getResourceAsStreamNonRecursive() throws Exception { + try (ExplodedArchive explodedArchive = new ExplodedArchive(new File("src/test/resources/root"), false)) { + assertThat(explodedArchive.getManifest()).isNotNull(); + URLClassLoader loader = new URLClassLoader(new URL[] { explodedArchive.getUrl() }); + assertThat(loader.getResourceAsStream("META-INF/spring/application.xml")).isNotNull(); + loader.close(); + } + } + + private Map getEntriesMap(Archive archive) { + Map entries = new HashMap<>(); + for (Archive.Entry entry : archive) { + entries.put(entry.getName(), entry); + } + return entries; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/archive/JarFileArchiveTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/archive/JarFileArchiveTests.java new file mode 100755 index 0000000000..4b2ce93af6 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/archive/JarFileArchiveTests.java @@ -0,0 +1,207 @@ +/* + * Copyright 2012-2023 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.loader.archive; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.net.URL; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import java.util.zip.CRC32; +import java.util.zip.ZipEntry; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.TestJarCreator; +import org.springframework.boot.loader.archive.Archive.Entry; +import org.springframework.boot.loader.jar.JarFile; +import org.springframework.util.FileCopyUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link JarFileArchive}. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Camille Vienot + */ +class JarFileArchiveTests { + + @TempDir + File tempDir; + + private File rootJarFile; + + private JarFileArchive archive; + + private String rootJarFileUrl; + + @BeforeEach + void setup() throws Exception { + setup(false); + } + + @AfterEach + void tearDown() throws Exception { + this.archive.close(); + } + + private void setup(boolean unpackNested) throws Exception { + this.rootJarFile = new File(this.tempDir, "root.jar"); + this.rootJarFileUrl = this.rootJarFile.toURI().toString(); + TestJarCreator.createTestJar(this.rootJarFile, unpackNested); + if (this.archive != null) { + this.archive.close(); + } + this.archive = new JarFileArchive(this.rootJarFile); + } + + @Test + void getManifest() throws Exception { + assertThat(this.archive.getManifest().getMainAttributes().getValue("Built-By")).isEqualTo("j1"); + } + + @Test + void getEntries() { + Map entries = getEntriesMap(this.archive); + assertThat(entries).hasSize(12); + } + + @Test + void getUrl() throws Exception { + URL url = this.archive.getUrl(); + assertThat(url).hasToString(this.rootJarFileUrl); + } + + @Test + void getNestedArchive() throws Exception { + Entry entry = getEntriesMap(this.archive).get("nested.jar"); + try (Archive nested = this.archive.getNestedArchive(entry)) { + assertThat(nested.getUrl()).hasToString("jar:" + this.rootJarFileUrl + "!/nested.jar!/"); + } + } + + @Test + void getNestedUnpackedArchive() throws Exception { + setup(true); + Entry entry = getEntriesMap(this.archive).get("nested.jar"); + try (Archive nested = this.archive.getNestedArchive(entry)) { + assertThat(nested.getUrl().toString()).startsWith("file:"); + assertThat(nested.getUrl().toString()).endsWith("/nested.jar"); + } + } + + @Test + void unpackedLocationsAreUniquePerArchive() throws Exception { + setup(true); + Entry entry = getEntriesMap(this.archive).get("nested.jar"); + URL firstNestedUrl; + try (Archive firstNested = this.archive.getNestedArchive(entry)) { + firstNestedUrl = firstNested.getUrl(); + } + this.archive.close(); + setup(true); + entry = getEntriesMap(this.archive).get("nested.jar"); + try (Archive secondNested = this.archive.getNestedArchive(entry)) { + URL secondNestedUrl = secondNested.getUrl(); + assertThat(secondNestedUrl).isNotEqualTo(firstNestedUrl); + } + } + + @Test + void unpackedLocationsFromSameArchiveShareSameParent() throws Exception { + setup(true); + try (Archive nestedArchive = this.archive.getNestedArchive(getEntriesMap(this.archive).get("nested.jar")); + Archive anotherNestedArchive = this.archive + .getNestedArchive(getEntriesMap(this.archive).get("another-nested.jar"))) { + File nested = new File(nestedArchive.getUrl().toURI()); + File anotherNested = new File(anotherNestedArchive.getUrl().toURI()); + assertThat(nested).hasParent(anotherNested.getParent()); + } + } + + @Test + void filesInZip64ArchivesAreAllListed() throws IOException { + File file = new File(this.tempDir, "test.jar"); + FileCopyUtils.copy(writeZip64Jar(), file); + try (JarFileArchive zip64Archive = new JarFileArchive(file)) { + @SuppressWarnings("deprecation") + Iterator entries = zip64Archive.iterator(); + for (int i = 0; i < 65537; i++) { + assertThat(entries.hasNext()).as(i + "nth file is present").isTrue(); + entries.next(); + } + } + } + + @Test + void nestedZip64ArchivesAreHandledGracefully() throws Exception { + File file = new File(this.tempDir, "test.jar"); + try (JarOutputStream output = new JarOutputStream(new FileOutputStream(file))) { + JarEntry zip64JarEntry = new JarEntry("nested/zip64.jar"); + output.putNextEntry(zip64JarEntry); + byte[] zip64JarData = writeZip64Jar(); + zip64JarEntry.setSize(zip64JarData.length); + zip64JarEntry.setCompressedSize(zip64JarData.length); + zip64JarEntry.setMethod(ZipEntry.STORED); + CRC32 crc32 = new CRC32(); + crc32.update(zip64JarData); + zip64JarEntry.setCrc(crc32.getValue()); + output.write(zip64JarData); + output.closeEntry(); + } + try (JarFile jarFile = new JarFile(file)) { + ZipEntry nestedEntry = jarFile.getEntry("nested/zip64.jar"); + try (JarFile nestedJarFile = jarFile.getNestedJarFile(nestedEntry)) { + Iterator iterator = nestedJarFile.iterator(); + for (int i = 0; i < 65537; i++) { + assertThat(iterator.hasNext()).as(i + "nth file is present").isTrue(); + iterator.next(); + } + } + } + } + + private byte[] writeZip64Jar() throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (JarOutputStream jarOutput = new JarOutputStream(bytes)) { + for (int i = 0; i < 65537; i++) { + jarOutput.putNextEntry(new JarEntry(i + ".dat")); + jarOutput.closeEntry(); + } + } + return bytes.toByteArray(); + } + + private Map getEntriesMap(Archive archive) { + Map entries = new HashMap<>(); + for (Archive.Entry entry : archive) { + entries.put(entry.getName(), entry); + } + return entries; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/data/RandomAccessDataFileTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/data/RandomAccessDataFileTests.java new file mode 100644 index 0000000000..6713814def --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/data/RandomAccessDataFileTests.java @@ -0,0 +1,300 @@ +/* + * Copyright 2012-2023 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.loader.data; + +import java.io.EOFException; +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +/** + * Tests for {@link RandomAccessDataFile}. + * + * @author Phillip Webb + * @author Andy Wilkinson + */ +class RandomAccessDataFileTests { + + private static final byte[] BYTES; + + static { + BYTES = new byte[256]; + for (int i = 0; i < BYTES.length; i++) { + BYTES[i] = (byte) i; + } + } + + private File tempFile; + + private RandomAccessDataFile file; + + private InputStream inputStream; + + @BeforeEach + void setup(@TempDir File tempDir) throws Exception { + this.tempFile = new File(tempDir, "tempFile"); + FileOutputStream outputStream = new FileOutputStream(this.tempFile); + outputStream.write(BYTES); + outputStream.close(); + this.file = new RandomAccessDataFile(this.tempFile); + this.inputStream = this.file.getInputStream(); + } + + @AfterEach + void cleanup() throws Exception { + this.inputStream.close(); + this.file.close(); + } + + @Test + void fileNotNull() { + assertThatIllegalArgumentException().isThrownBy(() -> new RandomAccessDataFile(null)) + .withMessageContaining("File must not be null"); + } + + @Test + void fileExists() { + File file = new File("/does/not/exist"); + assertThatIllegalArgumentException().isThrownBy(() -> new RandomAccessDataFile(file)) + .withMessageContaining(String.format("File %s must exist", file.getAbsolutePath())); + } + + @Test + void readWithOffsetAndLengthShouldRead() throws Exception { + byte[] read = this.file.read(2, 3); + assertThat(read).isEqualTo(new byte[] { 2, 3, 4 }); + } + + @Test + void readWhenOffsetIsBeyondEOFShouldThrowException() { + assertThatExceptionOfType(IndexOutOfBoundsException.class).isThrownBy(() -> this.file.read(257, 0)); + } + + @Test + void readWhenOffsetIsBeyondEndOfSubsectionShouldThrowException() { + RandomAccessData subsection = this.file.getSubsection(0, 10); + assertThatExceptionOfType(IndexOutOfBoundsException.class).isThrownBy(() -> subsection.read(11, 0)); + } + + @Test + void readWhenOffsetPlusLengthGreaterThanEOFShouldThrowException() { + assertThatExceptionOfType(EOFException.class).isThrownBy(() -> this.file.read(256, 1)); + } + + @Test + void readWhenOffsetPlusLengthGreaterThanEndOfSubsectionShouldThrowException() { + RandomAccessData subsection = this.file.getSubsection(0, 10); + assertThatExceptionOfType(EOFException.class).isThrownBy(() -> subsection.read(10, 1)); + } + + @Test + void inputStreamRead() throws Exception { + for (int i = 0; i <= 255; i++) { + assertThat(this.inputStream.read()).isEqualTo(i); + } + } + + @Test + void inputStreamReadNullBytes() { + assertThatNullPointerException().isThrownBy(() -> this.inputStream.read(null)) + .withMessage("Bytes must not be null"); + } + + @Test + void inputStreamReadNullBytesWithOffset() { + assertThatNullPointerException().isThrownBy(() -> this.inputStream.read(null, 0, 1)) + .withMessage("Bytes must not be null"); + } + + @Test + void inputStreamReadBytes() throws Exception { + byte[] b = new byte[256]; + int amountRead = this.inputStream.read(b); + assertThat(b).isEqualTo(BYTES); + assertThat(amountRead).isEqualTo(256); + } + + @Test + void inputStreamReadOffsetBytes() throws Exception { + byte[] b = new byte[7]; + this.inputStream.skip(1); + int amountRead = this.inputStream.read(b, 2, 3); + assertThat(b).isEqualTo(new byte[] { 0, 0, 1, 2, 3, 0, 0 }); + assertThat(amountRead).isEqualTo(3); + } + + @Test + void inputStreamReadMoreBytesThanAvailable() throws Exception { + byte[] b = new byte[257]; + int amountRead = this.inputStream.read(b); + assertThat(b).startsWith(BYTES); + assertThat(amountRead).isEqualTo(256); + } + + @Test + void inputStreamReadPastEnd() throws Exception { + this.inputStream.skip(255); + assertThat(this.inputStream.read()).isEqualTo(0xFF); + assertThat(this.inputStream.read()).isEqualTo(-1); + assertThat(this.inputStream.read()).isEqualTo(-1); + } + + @Test + void inputStreamReadZeroLength() throws Exception { + byte[] b = new byte[] { 0x0F }; + int amountRead = this.inputStream.read(b, 0, 0); + assertThat(b).isEqualTo(new byte[] { 0x0F }); + assertThat(amountRead).isZero(); + assertThat(this.inputStream.read()).isZero(); + } + + @Test + void inputStreamSkip() throws Exception { + long amountSkipped = this.inputStream.skip(4); + assertThat(this.inputStream.read()).isEqualTo(4); + assertThat(amountSkipped).isEqualTo(4L); + } + + @Test + void inputStreamSkipMoreThanAvailable() throws Exception { + long amountSkipped = this.inputStream.skip(257); + assertThat(this.inputStream.read()).isEqualTo(-1); + assertThat(amountSkipped).isEqualTo(256L); + } + + @Test + void inputStreamSkipPastEnd() throws Exception { + this.inputStream.skip(256); + long amountSkipped = this.inputStream.skip(1); + assertThat(amountSkipped).isZero(); + } + + @Test + void inputStreamAvailable() throws Exception { + assertThat(this.inputStream.available()).isEqualTo(256); + this.inputStream.skip(56); + assertThat(this.inputStream.available()).isEqualTo(200); + this.inputStream.skip(200); + assertThat(this.inputStream.available()).isZero(); + } + + @Test + void subsectionNegativeOffset() { + assertThatExceptionOfType(IndexOutOfBoundsException.class).isThrownBy(() -> this.file.getSubsection(-1, 1)); + } + + @Test + void subsectionNegativeLength() { + assertThatExceptionOfType(IndexOutOfBoundsException.class).isThrownBy(() -> this.file.getSubsection(0, -1)); + } + + @Test + void subsectionZeroLength() throws Exception { + RandomAccessData subsection = this.file.getSubsection(0, 0); + assertThat(subsection.getInputStream().read()).isEqualTo(-1); + } + + @Test + void subsectionTooBig() { + this.file.getSubsection(0, 256); + assertThatExceptionOfType(IndexOutOfBoundsException.class).isThrownBy(() -> this.file.getSubsection(0, 257)); + } + + @Test + void subsectionTooBigWithOffset() { + this.file.getSubsection(1, 255); + assertThatExceptionOfType(IndexOutOfBoundsException.class).isThrownBy(() -> this.file.getSubsection(1, 256)); + } + + @Test + void subsection() throws Exception { + RandomAccessData subsection = this.file.getSubsection(1, 1); + assertThat(subsection.getInputStream().read()).isOne(); + } + + @Test + void inputStreamReadPastSubsection() throws Exception { + RandomAccessData subsection = this.file.getSubsection(1, 2); + InputStream inputStream = subsection.getInputStream(); + assertThat(inputStream.read()).isOne(); + assertThat(inputStream.read()).isEqualTo(2); + assertThat(inputStream.read()).isEqualTo(-1); + } + + @Test + void inputStreamReadBytesPastSubsection() throws Exception { + RandomAccessData subsection = this.file.getSubsection(1, 2); + InputStream inputStream = subsection.getInputStream(); + byte[] b = new byte[3]; + int amountRead = inputStream.read(b); + assertThat(b).isEqualTo(new byte[] { 1, 2, 0 }); + assertThat(amountRead).isEqualTo(2); + } + + @Test + void inputStreamSkipPastSubsection() throws Exception { + RandomAccessData subsection = this.file.getSubsection(1, 2); + InputStream inputStream = subsection.getInputStream(); + assertThat(inputStream.skip(3)).isEqualTo(2L); + assertThat(inputStream.read()).isEqualTo(-1); + } + + @Test + void inputStreamSkipNegative() throws Exception { + assertThat(this.inputStream.skip(-1)).isZero(); + } + + @Test + void getFile() { + assertThat(this.file.getFile()).isEqualTo(this.tempFile); + } + + @Test + void concurrentReads() throws Exception { + ExecutorService executorService = Executors.newFixedThreadPool(20); + List> results = new ArrayList<>(); + for (int i = 0; i < 100; i++) { + results.add(executorService.submit(() -> { + InputStream subsectionInputStream = RandomAccessDataFileTests.this.file.getSubsection(0, 256) + .getInputStream(); + byte[] b = new byte[256]; + subsectionInputStream.read(b); + return Arrays.equals(b, BYTES); + })); + } + for (Future future : results) { + assertThat(future.get()).isTrue(); + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/AsciiBytesTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/AsciiBytesTests.java new file mode 100644 index 0000000000..dd25050163 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/AsciiBytesTests.java @@ -0,0 +1,196 @@ +/* + * Copyright 2012-2023 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.loader.jar; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link AsciiBytes}. + * + * @author Phillip Webb + * @author Andy Wilkinson + */ +class AsciiBytesTests { + + private static final char NO_SUFFIX = 0; + + @Test + void createFromBytes() { + AsciiBytes bytes = new AsciiBytes(new byte[] { 65, 66 }); + assertThat(bytes).hasToString("AB"); + } + + @Test + void createFromBytesWithOffset() { + AsciiBytes bytes = new AsciiBytes(new byte[] { 65, 66, 67, 68 }, 1, 2); + assertThat(bytes).hasToString("BC"); + } + + @Test + void createFromString() { + AsciiBytes bytes = new AsciiBytes("AB"); + assertThat(bytes).hasToString("AB"); + } + + @Test + void length() { + AsciiBytes b1 = new AsciiBytes(new byte[] { 65, 66 }); + AsciiBytes b2 = new AsciiBytes(new byte[] { 65, 66, 67, 68 }, 1, 2); + assertThat(b1.length()).isEqualTo(2); + assertThat(b2.length()).isEqualTo(2); + } + + @Test + void startWith() { + AsciiBytes abc = new AsciiBytes(new byte[] { 65, 66, 67 }); + AsciiBytes ab = new AsciiBytes(new byte[] { 65, 66 }); + AsciiBytes bc = new AsciiBytes(new byte[] { 65, 66, 67 }, 1, 2); + AsciiBytes abcd = new AsciiBytes(new byte[] { 65, 66, 67, 68 }); + assertThat(abc.startsWith(abc)).isTrue(); + assertThat(abc.startsWith(ab)).isTrue(); + assertThat(abc.startsWith(bc)).isFalse(); + assertThat(abc.startsWith(abcd)).isFalse(); + } + + @Test + void endsWith() { + AsciiBytes abc = new AsciiBytes(new byte[] { 65, 66, 67 }); + AsciiBytes bc = new AsciiBytes(new byte[] { 65, 66, 67 }, 1, 2); + AsciiBytes ab = new AsciiBytes(new byte[] { 65, 66 }); + AsciiBytes aabc = new AsciiBytes(new byte[] { 65, 65, 66, 67 }); + assertThat(abc.endsWith(abc)).isTrue(); + assertThat(abc.endsWith(bc)).isTrue(); + assertThat(abc.endsWith(ab)).isFalse(); + assertThat(abc.endsWith(aabc)).isFalse(); + } + + @Test + void substringFromBeingIndex() { + AsciiBytes abcd = new AsciiBytes(new byte[] { 65, 66, 67, 68 }); + assertThat(abcd.substring(0)).hasToString("ABCD"); + assertThat(abcd.substring(1)).hasToString("BCD"); + assertThat(abcd.substring(2)).hasToString("CD"); + assertThat(abcd.substring(3)).hasToString("D"); + assertThat(abcd.substring(4).toString()).isEmpty(); + assertThatExceptionOfType(IndexOutOfBoundsException.class).isThrownBy(() -> abcd.substring(5)); + } + + @Test + void substring() { + AsciiBytes abcd = new AsciiBytes(new byte[] { 65, 66, 67, 68 }); + assertThat(abcd.substring(0, 4)).hasToString("ABCD"); + assertThat(abcd.substring(1, 3)).hasToString("BC"); + assertThat(abcd.substring(3, 4)).hasToString("D"); + assertThat(abcd.substring(3, 3).toString()).isEmpty(); + assertThatExceptionOfType(IndexOutOfBoundsException.class).isThrownBy(() -> abcd.substring(3, 5)); + } + + @Test + void hashCodeAndEquals() { + AsciiBytes abcd = new AsciiBytes(new byte[] { 65, 66, 67, 68 }); + AsciiBytes bc = new AsciiBytes(new byte[] { 66, 67 }); + AsciiBytes bc_substring = new AsciiBytes(new byte[] { 65, 66, 67, 68 }).substring(1, 3); + AsciiBytes bc_string = new AsciiBytes("BC"); + assertThat(bc).hasSameHashCodeAs(bc); + assertThat(bc).hasSameHashCodeAs(bc_substring); + assertThat(bc).hasSameHashCodeAs(bc_string); + assertThat(bc).isEqualTo(bc); + assertThat(bc).isEqualTo(bc_substring); + assertThat(bc).isEqualTo(bc_string); + assertThat(bc.hashCode()).isNotEqualTo(abcd.hashCode()); + assertThat(bc).isNotEqualTo(abcd); + } + + @Test + void hashCodeSameAsString() { + hashCodeSameAsString("abcABC123xyz!"); + } + + @Test + void hashCodeSameAsStringWithSpecial() { + hashCodeSameAsString("special/\u00EB.dat"); + } + + @Test + void hashCodeSameAsStringWithCyrillicCharacters() { + hashCodeSameAsString("\u0432\u0435\u0441\u043D\u0430"); + } + + @Test + void hashCodeSameAsStringWithEmoji() { + hashCodeSameAsString("\ud83d\udca9"); + } + + private void hashCodeSameAsString(String input) { + assertThat(new AsciiBytes(input)).hasSameHashCodeAs(input); + } + + @Test + void matchesSameAsString() { + matchesSameAsString("abcABC123xyz!"); + } + + @Test + void matchesSameAsStringWithSpecial() { + matchesSameAsString("special/\u00EB.dat"); + } + + @Test + void matchesSameAsStringWithCyrillicCharacters() { + matchesSameAsString("\u0432\u0435\u0441\u043D\u0430"); + } + + @Test + void matchesDifferentLengths() { + assertThat(new AsciiBytes("abc").matches("ab", NO_SUFFIX)).isFalse(); + assertThat(new AsciiBytes("abc").matches("abcd", NO_SUFFIX)).isFalse(); + assertThat(new AsciiBytes("abc").matches("abc", NO_SUFFIX)).isTrue(); + assertThat(new AsciiBytes("abc").matches("a", 'b')).isFalse(); + assertThat(new AsciiBytes("abc").matches("abc", 'd')).isFalse(); + assertThat(new AsciiBytes("abc").matches("ab", 'c')).isTrue(); + } + + @Test + void matchesSuffix() { + assertThat(new AsciiBytes("ab").matches("a", 'b')).isTrue(); + } + + @Test + void matchesSameAsStringWithEmoji() { + matchesSameAsString("\ud83d\udca9"); + } + + @Test + void hashCodeFromInstanceMatchesHashCodeFromString() { + String name = "fonts/宋体/simsun.ttf"; + assertThat(new AsciiBytes(name).hashCode()).isEqualTo(AsciiBytes.hashCode(name)); + } + + @Test + void instanceCreatedFromCharSequenceMatchesSameCharSequence() { + String name = "fonts/宋体/simsun.ttf"; + assertThat(new AsciiBytes(name).matches(name, NO_SUFFIX)).isTrue(); + } + + private void matchesSameAsString(String input) { + assertThat(new AsciiBytes(input).matches(input, NO_SUFFIX)).isTrue(); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/CentralDirectoryParserTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/CentralDirectoryParserTests.java new file mode 100644 index 0000000000..4d15c21fe3 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/CentralDirectoryParserTests.java @@ -0,0 +1,139 @@ +/* + * Copyright 2012-2023 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.loader.jar; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.TestJarCreator; +import org.springframework.boot.loader.data.RandomAccessData; +import org.springframework.boot.loader.data.RandomAccessDataFile; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link CentralDirectoryParser}. + * + * @author Phillip Webb + */ +class CentralDirectoryParserTests { + + private File jarFile; + + private RandomAccessDataFile jarData; + + @BeforeEach + void setup(@TempDir File tempDir) throws Exception { + this.jarFile = new File(tempDir, "test.jar"); + TestJarCreator.createTestJar(this.jarFile); + this.jarData = new RandomAccessDataFile(this.jarFile); + } + + @AfterEach + void tearDown() throws IOException { + this.jarData.close(); + } + + @Test + void visitsInOrder() throws Exception { + MockCentralDirectoryVisitor visitor = new MockCentralDirectoryVisitor(); + CentralDirectoryParser parser = new CentralDirectoryParser(); + parser.addVisitor(visitor); + parser.parse(this.jarData, false); + List invocations = visitor.getInvocations(); + assertThat(invocations).startsWith("visitStart").endsWith("visitEnd").contains("visitFileHeader"); + } + + @Test + void visitRecords() throws Exception { + Collector collector = new Collector(); + CentralDirectoryParser parser = new CentralDirectoryParser(); + parser.addVisitor(collector); + parser.parse(this.jarData, false); + Iterator headers = collector.getHeaders().iterator(); + assertThat(headers.next().getName()).hasToString("META-INF/"); + assertThat(headers.next().getName()).hasToString("META-INF/MANIFEST.MF"); + assertThat(headers.next().getName()).hasToString("1.dat"); + assertThat(headers.next().getName()).hasToString("2.dat"); + assertThat(headers.next().getName()).hasToString("d/"); + assertThat(headers.next().getName()).hasToString("d/9.dat"); + assertThat(headers.next().getName()).hasToString("special/"); + assertThat(headers.next().getName()).hasToString("special/\u00EB.dat"); + assertThat(headers.next().getName()).hasToString("nested.jar"); + assertThat(headers.next().getName()).hasToString("another-nested.jar"); + assertThat(headers.next().getName()).hasToString("space nested.jar"); + assertThat(headers.next().getName()).hasToString("multi-release.jar"); + assertThat(headers.hasNext()).isFalse(); + } + + static class Collector implements CentralDirectoryVisitor { + + private final List headers = new ArrayList<>(); + + @Override + public void visitStart(CentralDirectoryEndRecord endRecord, RandomAccessData centralDirectoryData) { + } + + @Override + public void visitFileHeader(CentralDirectoryFileHeader fileHeader, long dataOffset) { + this.headers.add(fileHeader.clone()); + } + + @Override + public void visitEnd() { + } + + List getHeaders() { + return this.headers; + } + + } + + static class MockCentralDirectoryVisitor implements CentralDirectoryVisitor { + + private final List invocations = new ArrayList<>(); + + @Override + public void visitStart(CentralDirectoryEndRecord endRecord, RandomAccessData centralDirectoryData) { + this.invocations.add("visitStart"); + } + + @Override + public void visitFileHeader(CentralDirectoryFileHeader fileHeader, long dataOffset) { + this.invocations.add("visitFileHeader"); + } + + @Override + public void visitEnd() { + this.invocations.add("visitEnd"); + } + + List getInvocations() { + return this.invocations; + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/HandlerTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/HandlerTests.java new file mode 100644 index 0000000000..1a64de6431 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/HandlerTests.java @@ -0,0 +1,210 @@ +/* + * Copyright 2012-2023 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.loader.jar; + +import java.io.File; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLConnection; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.TestJarCreator; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link Handler}. + * + * @author Andy Wilkinson + */ +@ExtendWith(JarUrlProtocolHandler.class) +class HandlerTests { + + private final Handler handler = new Handler(); + + @Test + void parseUrlWithJarRootContextAndAbsoluteSpecThatUsesContext() throws MalformedURLException { + String spec = "/entry.txt"; + URL context = createUrl("file:example.jar!/"); + this.handler.parseURL(context, spec, 0, spec.length()); + assertThat(context.toExternalForm()).isEqualTo("jar:file:example.jar!/entry.txt"); + } + + @Test + void parseUrlWithDirectoryEntryContextAndAbsoluteSpecThatUsesContext() throws MalformedURLException { + String spec = "/entry.txt"; + URL context = createUrl("file:example.jar!/dir/"); + this.handler.parseURL(context, spec, 0, spec.length()); + assertThat(context.toExternalForm()).isEqualTo("jar:file:example.jar!/entry.txt"); + } + + @Test + void parseUrlWithJarRootContextAndRelativeSpecThatUsesContext() throws MalformedURLException { + String spec = "entry.txt"; + URL context = createUrl("file:example.jar!/"); + this.handler.parseURL(context, spec, 0, spec.length()); + assertThat(context.toExternalForm()).isEqualTo("jar:file:example.jar!/entry.txt"); + } + + @Test + void parseUrlWithDirectoryEntryContextAndRelativeSpecThatUsesContext() throws MalformedURLException { + String spec = "entry.txt"; + URL context = createUrl("file:example.jar!/dir/"); + this.handler.parseURL(context, spec, 0, spec.length()); + assertThat(context.toExternalForm()).isEqualTo("jar:file:example.jar!/dir/entry.txt"); + } + + @Test + void parseUrlWithFileEntryContextAndRelativeSpecThatUsesContext() throws MalformedURLException { + String spec = "entry.txt"; + URL context = createUrl("file:example.jar!/dir/file"); + this.handler.parseURL(context, spec, 0, spec.length()); + assertThat(context.toExternalForm()).isEqualTo("jar:file:example.jar!/dir/entry.txt"); + } + + @Test + void parseUrlWithSpecThatIgnoresContext() throws MalformedURLException { + JarFile.registerUrlProtocolHandler(); + String spec = "jar:file:/other.jar!/nested!/entry.txt"; + URL context = createUrl("file:example.jar!/dir/file"); + this.handler.parseURL(context, spec, 0, spec.length()); + assertThat(context.toExternalForm()).isEqualTo("jar:jar:file:/other.jar!/nested!/entry.txt"); + } + + @Test + void sameFileReturnsFalseForUrlsWithDifferentProtocols() throws MalformedURLException { + assertThat(this.handler.sameFile(new URL("jar:file:foo.jar!/content.txt"), new URL("file:/foo.jar"))).isFalse(); + } + + @Test + void sameFileReturnsFalseForDifferentFileInSameJar() throws MalformedURLException { + assertThat(this.handler.sameFile(new URL("jar:file:foo.jar!/the/path/to/the/first/content.txt"), + new URL("jar:file:/foo.jar!/content.txt"))) + .isFalse(); + } + + @Test + void sameFileReturnsFalseForSameFileInDifferentJars() throws MalformedURLException { + assertThat(this.handler.sameFile(new URL("jar:file:/the/path/to/the/first.jar!/content.txt"), + new URL("jar:file:/second.jar!/content.txt"))) + .isFalse(); + } + + @Test + void sameFileReturnsTrueForSameFileInSameJar() throws MalformedURLException { + assertThat(this.handler.sameFile(new URL("jar:file:/the/path/to/the/first.jar!/content.txt"), + new URL("jar:file:/the/path/to/the/first.jar!/content.txt"))) + .isTrue(); + } + + @Test + void sameFileReturnsTrueForUrlsThatReferenceSameFileViaNestedArchiveAndFromRootOfJar() + throws MalformedURLException { + assertThat(this.handler.sameFile(new URL("jar:file:/test.jar!/BOOT-INF/classes!/foo.txt"), + new URL("jar:file:/test.jar!/BOOT-INF/classes/foo.txt"))) + .isTrue(); + } + + @Test + void hashCodesAreEqualForUrlsThatReferenceSameFileViaNestedArchiveAndFromRootOfJar() throws MalformedURLException { + assertThat(this.handler.hashCode(new URL("jar:file:/test.jar!/BOOT-INF/classes!/foo.txt"))) + .isEqualTo(this.handler.hashCode(new URL("jar:file:/test.jar!/BOOT-INF/classes/foo.txt"))); + } + + @Test + void urlWithSpecReferencingParentDirectory() throws MalformedURLException { + assertStandardAndCustomHandlerUrlsAreEqual("file:/test.jar!/BOOT-INF/classes!/xsd/directoryA/a.xsd", + "../directoryB/c/d/e.xsd"); + } + + @Test + void urlWithSpecReferencingAncestorDirectoryOutsideJarStopsAtJarRoot() throws MalformedURLException { + assertStandardAndCustomHandlerUrlsAreEqual("file:/test.jar!/BOOT-INF/classes!/xsd/directoryA/a.xsd", + "../../../../../../directoryB/b.xsd"); + } + + @Test + void urlWithSpecReferencingCurrentDirectory() throws MalformedURLException { + assertStandardAndCustomHandlerUrlsAreEqual("file:/test.jar!/BOOT-INF/classes!/xsd/directoryA/a.xsd", + "./directoryB/c/d/e.xsd"); + } + + @Test + void urlWithRef() throws MalformedURLException { + assertStandardAndCustomHandlerUrlsAreEqual("file:/test.jar!/BOOT-INF/classes", "!/foo.txt#alpha"); + } + + @Test + void urlWithQuery() throws MalformedURLException { + assertStandardAndCustomHandlerUrlsAreEqual("file:/test.jar!/BOOT-INF/classes", "!/foo.txt?alpha"); + } + + @Test + void fallbackToJdksJarUrlStreamHandler(@TempDir File tempDir) throws Exception { + File testJar = new File(tempDir, "test.jar"); + TestJarCreator.createTestJar(testJar); + URLConnection connection = new URL(null, "jar:" + testJar.toURI().toURL() + "!/nested.jar!/", this.handler) + .openConnection(); + assertThat(connection).isInstanceOf(JarURLConnection.class); + ((JarURLConnection) connection).getJarFile().close(); + URLConnection jdkConnection = new URL(null, "jar:file:" + testJar.toURI().toURL() + "!/nested.jar!/", + this.handler) + .openConnection(); + assertThat(jdkConnection).isNotInstanceOf(JarURLConnection.class); + assertThat(jdkConnection.getClass().getName()).endsWith(".JarURLConnection"); + } + + @Test + void whenJarHasAPlusInItsPathConnectionJarFileMatchesOriginalJarFile(@TempDir File tempDir) throws Exception { + File testJar = new File(tempDir, "t+e+s+t.jar"); + TestJarCreator.createTestJar(testJar); + URL url = new URL(null, "jar:" + testJar.toURI().toURL() + "!/nested.jar!/3.dat", this.handler); + JarURLConnection connection = (JarURLConnection) url.openConnection(); + try (JarFile jarFile = JarFileWrapper.unwrap(connection.getJarFile())) { + assertThat(jarFile.getRootJarFile().getFile()).isEqualTo(testJar); + } + } + + @Test + void whenJarHasASpaceInItsPathConnectionJarFileMatchesOriginalJarFile(@TempDir File tempDir) throws Exception { + File testJar = new File(tempDir, "t e s t.jar"); + TestJarCreator.createTestJar(testJar); + URL url = new URL(null, "jar:" + testJar.toURI().toURL() + "!/nested.jar!/3.dat", this.handler); + JarURLConnection connection = (JarURLConnection) url.openConnection(); + try (JarFile jarFile = JarFileWrapper.unwrap(connection.getJarFile())) { + assertThat(jarFile.getRootJarFile().getFile()).isEqualTo(testJar); + } + } + + private void assertStandardAndCustomHandlerUrlsAreEqual(String context, String spec) throws MalformedURLException { + URL standardUrl = new URL(new URL("jar:" + context), spec); + URL customHandlerUrl = new URL(new URL("jar", null, -1, context, this.handler), spec); + assertThat(customHandlerUrl).hasToString(standardUrl.toString()); + assertThat(customHandlerUrl.getFile()).isEqualTo(standardUrl.getFile()); + assertThat(customHandlerUrl.getPath()).isEqualTo(standardUrl.getPath()); + assertThat(customHandlerUrl.getQuery()).isEqualTo(standardUrl.getQuery()); + assertThat(customHandlerUrl.getRef()).isEqualTo(standardUrl.getRef()); + } + + private URL createUrl(String file) throws MalformedURLException { + return new URL("jar", null, -1, file, this.handler); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarFileTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarFileTests.java new file mode 100644 index 0000000000..b37a99183a --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarFileTests.java @@ -0,0 +1,736 @@ +/* + * Copyright 2012-2023 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.loader.jar; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.FilePermission; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.attribute.FileTime; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Enumeration; +import java.util.Iterator; +import java.util.List; +import java.util.Random; +import java.util.jar.JarEntry; +import java.util.jar.JarInputStream; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; +import java.util.stream.Stream; +import java.util.zip.CRC32; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +import org.assertj.core.api.ThrowableAssert.ThrowingCallable; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.TestJarCreator; +import org.springframework.boot.loader.data.RandomAccessDataFile; +import org.springframework.util.FileCopyUtils; +import org.springframework.util.StopWatch; +import org.springframework.util.StreamUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIOException; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.spy; + +/** + * Tests for {@link JarFile}. + * + * @author Phillip Webb + * @author Martin Lau + * @author Andy Wilkinson + * @author Madhura Bhave + */ +@ExtendWith(JarUrlProtocolHandler.class) +class JarFileTests { + + private static final String PROTOCOL_HANDLER = "java.protocol.handler.pkgs"; + + private static final String HANDLERS_PACKAGE = "org.springframework.boot.loader"; + + @TempDir + File tempDir; + + private File rootJarFile; + + private JarFile jarFile; + + @BeforeEach + void setup() throws Exception { + this.rootJarFile = new File(this.tempDir, "root.jar"); + TestJarCreator.createTestJar(this.rootJarFile); + this.jarFile = new JarFile(this.rootJarFile); + } + + @AfterEach + void tearDown() throws Exception { + this.jarFile.close(); + } + + @Test + void jdkJarFile() throws Exception { + // Sanity checks to see how the default jar file operates + java.util.jar.JarFile jarFile = new java.util.jar.JarFile(this.rootJarFile); + assertThat(jarFile.getComment()).isEqualTo("outer"); + Enumeration entries = jarFile.entries(); + assertThat(entries.nextElement().getName()).isEqualTo("META-INF/"); + assertThat(entries.nextElement().getName()).isEqualTo("META-INF/MANIFEST.MF"); + assertThat(entries.nextElement().getName()).isEqualTo("1.dat"); + assertThat(entries.nextElement().getName()).isEqualTo("2.dat"); + assertThat(entries.nextElement().getName()).isEqualTo("d/"); + assertThat(entries.nextElement().getName()).isEqualTo("d/9.dat"); + assertThat(entries.nextElement().getName()).isEqualTo("special/"); + assertThat(entries.nextElement().getName()).isEqualTo("special/\u00EB.dat"); + assertThat(entries.nextElement().getName()).isEqualTo("nested.jar"); + assertThat(entries.nextElement().getName()).isEqualTo("another-nested.jar"); + assertThat(entries.nextElement().getName()).isEqualTo("space nested.jar"); + assertThat(entries.nextElement().getName()).isEqualTo("multi-release.jar"); + assertThat(entries.hasMoreElements()).isFalse(); + URL jarUrl = new URL("jar:" + this.rootJarFile.toURI() + "!/"); + URLClassLoader urlClassLoader = new URLClassLoader(new URL[] { jarUrl }); + assertThat(urlClassLoader.getResource("special/\u00EB.dat")).isNotNull(); + assertThat(urlClassLoader.getResource("d/9.dat")).isNotNull(); + urlClassLoader.close(); + jarFile.close(); + } + + @Test + void createFromFile() throws Exception { + JarFile jarFile = new JarFile(this.rootJarFile); + assertThat(jarFile.getName()).isNotNull(); + jarFile.close(); + } + + @Test + void getManifest() throws Exception { + assertThat(this.jarFile.getManifest().getMainAttributes().getValue("Built-By")).isEqualTo("j1"); + } + + @Test + void getManifestEntry() throws Exception { + ZipEntry entry = this.jarFile.getJarEntry("META-INF/MANIFEST.MF"); + Manifest manifest = new Manifest(this.jarFile.getInputStream(entry)); + assertThat(manifest.getMainAttributes().getValue("Built-By")).isEqualTo("j1"); + } + + @Test + void getEntries() { + Enumeration entries = this.jarFile.entries(); + assertThat(entries.nextElement().getName()).isEqualTo("META-INF/"); + assertThat(entries.nextElement().getName()).isEqualTo("META-INF/MANIFEST.MF"); + assertThat(entries.nextElement().getName()).isEqualTo("1.dat"); + assertThat(entries.nextElement().getName()).isEqualTo("2.dat"); + assertThat(entries.nextElement().getName()).isEqualTo("d/"); + assertThat(entries.nextElement().getName()).isEqualTo("d/9.dat"); + assertThat(entries.nextElement().getName()).isEqualTo("special/"); + assertThat(entries.nextElement().getName()).isEqualTo("special/\u00EB.dat"); + assertThat(entries.nextElement().getName()).isEqualTo("nested.jar"); + assertThat(entries.nextElement().getName()).isEqualTo("another-nested.jar"); + assertThat(entries.nextElement().getName()).isEqualTo("space nested.jar"); + assertThat(entries.nextElement().getName()).isEqualTo("multi-release.jar"); + assertThat(entries.hasMoreElements()).isFalse(); + } + + @Test + void getSpecialResourceViaClassLoader() throws Exception { + URLClassLoader urlClassLoader = new URLClassLoader(new URL[] { this.jarFile.getUrl() }); + assertThat(urlClassLoader.getResource("special/\u00EB.dat")).isNotNull(); + urlClassLoader.close(); + } + + @Test + void getJarEntry() { + java.util.jar.JarEntry entry = this.jarFile.getJarEntry("1.dat"); + assertThat(entry).isNotNull(); + assertThat(entry.getName()).isEqualTo("1.dat"); + } + + @Test + void getJarEntryWhenClosed() throws Exception { + this.jarFile.close(); + assertThatZipFileClosedIsThrownBy(() -> this.jarFile.getJarEntry("1.dat")); + } + + @Test + void getInputStream() throws Exception { + InputStream inputStream = this.jarFile.getInputStream(this.jarFile.getEntry("1.dat")); + assertThat(inputStream.available()).isOne(); + assertThat(inputStream.read()).isOne(); + assertThat(inputStream.available()).isZero(); + assertThat(inputStream.read()).isEqualTo(-1); + } + + @Test + void getInputStreamWhenClosed() throws Exception { + ZipEntry entry = this.jarFile.getEntry("1.dat"); + this.jarFile.close(); + assertThatZipFileClosedIsThrownBy(() -> this.jarFile.getInputStream(entry)); + } + + @Test + void getComment() { + assertThat(this.jarFile.getComment()).isEqualTo("outer"); + } + + @Test + void getCommentWhenClosed() throws Exception { + this.jarFile.close(); + assertThatZipFileClosedIsThrownBy(() -> this.jarFile.getComment()); + } + + @Test + void getName() { + assertThat(this.jarFile.getName()).isEqualTo(this.rootJarFile.getPath()); + } + + @Test + void size() throws Exception { + try (ZipFile zip = new ZipFile(this.rootJarFile)) { + assertThat(this.jarFile).hasSize(zip.size()); + } + } + + @Test + void sizeWhenClosed() throws Exception { + this.jarFile.close(); + assertThatZipFileClosedIsThrownBy(() -> this.jarFile.size()); + } + + @Test + void getEntryTime() throws Exception { + java.util.jar.JarFile jdkJarFile = new java.util.jar.JarFile(this.rootJarFile); + assertThat(this.jarFile.getEntry("META-INF/MANIFEST.MF").getTime()) + .isEqualTo(jdkJarFile.getEntry("META-INF/MANIFEST.MF").getTime()); + jdkJarFile.close(); + } + + @Test + void close() throws Exception { + RandomAccessDataFile randomAccessDataFile = spy(new RandomAccessDataFile(this.rootJarFile)); + JarFile jarFile = new JarFile(randomAccessDataFile); + jarFile.close(); + then(randomAccessDataFile).should().close(); + } + + @Test + void getUrl() throws Exception { + URL url = this.jarFile.getUrl(); + assertThat(url).hasToString("jar:" + this.rootJarFile.toURI() + "!/"); + JarURLConnection jarURLConnection = (JarURLConnection) url.openConnection(); + assertThat(JarFileWrapper.unwrap(jarURLConnection.getJarFile())).isSameAs(this.jarFile); + assertThat(jarURLConnection.getJarEntry()).isNull(); + assertThat(jarURLConnection.getContentLength()).isGreaterThan(1); + assertThat(JarFileWrapper.unwrap((java.util.jar.JarFile) jarURLConnection.getContent())).isSameAs(this.jarFile); + assertThat(jarURLConnection.getContentType()).isEqualTo("x-java/jar"); + assertThat(jarURLConnection.getJarFileURL().toURI()).isEqualTo(this.rootJarFile.toURI()); + } + + @Test + void createEntryUrl() throws Exception { + URL url = new URL(this.jarFile.getUrl(), "1.dat"); + assertThat(url).hasToString("jar:" + this.rootJarFile.toURI() + "!/1.dat"); + JarURLConnection jarURLConnection = (JarURLConnection) url.openConnection(); + assertThat(JarFileWrapper.unwrap(jarURLConnection.getJarFile())).isSameAs(this.jarFile); + assertThat(jarURLConnection.getJarEntry()).isSameAs(this.jarFile.getJarEntry("1.dat")); + assertThat(jarURLConnection.getContentLength()).isOne(); + assertThat(jarURLConnection.getContent()).isInstanceOf(InputStream.class); + assertThat(jarURLConnection.getContentType()).isEqualTo("content/unknown"); + assertThat(jarURLConnection.getPermission()).isInstanceOf(FilePermission.class); + FilePermission permission = (FilePermission) jarURLConnection.getPermission(); + assertThat(permission.getActions()).isEqualTo("read"); + assertThat(permission.getName()).isEqualTo(this.rootJarFile.getPath()); + } + + @Test + void getMissingEntryUrl() throws Exception { + URL url = new URL(this.jarFile.getUrl(), "missing.dat"); + assertThat(url).hasToString("jar:" + this.rootJarFile.toURI() + "!/missing.dat"); + assertThatExceptionOfType(FileNotFoundException.class) + .isThrownBy(((JarURLConnection) url.openConnection())::getJarEntry); + } + + @Test + void getUrlStream() throws Exception { + URL url = this.jarFile.getUrl(); + url.openConnection(); + assertThatIOException().isThrownBy(url::openStream); + } + + @Test + void getEntryUrlStream() throws Exception { + URL url = new URL(this.jarFile.getUrl(), "1.dat"); + url.openConnection(); + try (InputStream stream = url.openStream()) { + assertThat(stream.read()).isOne(); + assertThat(stream.read()).isEqualTo(-1); + } + } + + @Test + void getNestedJarFile() throws Exception { + try (JarFile nestedJarFile = this.jarFile.getNestedJarFile(this.jarFile.getEntry("nested.jar"))) { + assertThat(nestedJarFile.getComment()).isEqualTo("nested"); + Enumeration entries = nestedJarFile.entries(); + assertThat(entries.nextElement().getName()).isEqualTo("META-INF/"); + assertThat(entries.nextElement().getName()).isEqualTo("META-INF/MANIFEST.MF"); + assertThat(entries.nextElement().getName()).isEqualTo("3.dat"); + assertThat(entries.nextElement().getName()).isEqualTo("4.dat"); + assertThat(entries.nextElement().getName()).isEqualTo("\u00E4.dat"); + assertThat(entries.hasMoreElements()).isFalse(); + + InputStream inputStream = nestedJarFile.getInputStream(nestedJarFile.getEntry("3.dat")); + assertThat(inputStream.read()).isEqualTo(3); + assertThat(inputStream.read()).isEqualTo(-1); + + URL url = nestedJarFile.getUrl(); + assertThat(url).hasToString("jar:" + this.rootJarFile.toURI() + "!/nested.jar!/"); + JarURLConnection conn = (JarURLConnection) url.openConnection(); + assertThat(JarFileWrapper.unwrap(conn.getJarFile())).isSameAs(nestedJarFile); + assertThat(conn.getJarFileURL()).hasToString("jar:" + this.rootJarFile.toURI() + "!/nested.jar"); + assertThat(conn.getInputStream()).isNotNull(); + JarInputStream jarInputStream = new JarInputStream(conn.getInputStream()); + assertThat(jarInputStream.getNextJarEntry().getName()).isEqualTo("3.dat"); + assertThat(jarInputStream.getNextJarEntry().getName()).isEqualTo("4.dat"); + assertThat(jarInputStream.getNextJarEntry().getName()).isEqualTo("\u00E4.dat"); + jarInputStream.close(); + assertThat(conn.getPermission()).isInstanceOf(FilePermission.class); + FilePermission permission = (FilePermission) conn.getPermission(); + assertThat(permission.getActions()).isEqualTo("read"); + assertThat(permission.getName()).isEqualTo(this.rootJarFile.getPath()); + } + } + + @Test + void getNestedJarDirectory() throws Exception { + try (JarFile nestedJarFile = this.jarFile.getNestedJarFile(this.jarFile.getEntry("d/"))) { + Enumeration entries = nestedJarFile.entries(); + assertThat(entries.nextElement().getName()).isEqualTo("9.dat"); + assertThat(entries.hasMoreElements()).isFalse(); + + try (InputStream inputStream = nestedJarFile.getInputStream(nestedJarFile.getEntry("9.dat"))) { + assertThat(inputStream.read()).isEqualTo(9); + assertThat(inputStream.read()).isEqualTo(-1); + } + + URL url = nestedJarFile.getUrl(); + assertThat(url).hasToString("jar:" + this.rootJarFile.toURI() + "!/d!/"); + JarURLConnection connection = (JarURLConnection) url.openConnection(); + assertThat(JarFileWrapper.unwrap(connection.getJarFile())).isSameAs(nestedJarFile); + } + } + + @Test + void getNestedJarEntryUrl() throws Exception { + try (JarFile nestedJarFile = this.jarFile.getNestedJarFile(this.jarFile.getEntry("nested.jar"))) { + URL url = nestedJarFile.getJarEntry("3.dat").getUrl(); + assertThat(url).hasToString("jar:" + this.rootJarFile.toURI() + "!/nested.jar!/3.dat"); + try (InputStream inputStream = url.openStream()) { + assertThat(inputStream).isNotNull(); + assertThat(inputStream.read()).isEqualTo(3); + } + } + } + + @Test + void createUrlFromString() throws Exception { + String spec = "jar:" + this.rootJarFile.toURI() + "!/nested.jar!/3.dat"; + URL url = new URL(spec); + assertThat(url).hasToString(spec); + JarURLConnection connection = (JarURLConnection) url.openConnection(); + try (InputStream inputStream = connection.getInputStream()) { + assertThat(inputStream).isNotNull(); + assertThat(inputStream.read()).isEqualTo(3); + assertThat(connection.getURL()).hasToString(spec); + assertThat(connection.getJarFileURL()).hasToString("jar:" + this.rootJarFile.toURI() + "!/nested.jar"); + assertThat(connection.getEntryName()).isEqualTo("3.dat"); + connection.getJarFile().close(); + } + } + + @Test + void createNonNestedUrlFromString() throws Exception { + nonNestedJarFileFromString("jar:" + this.rootJarFile.toURI() + "!/2.dat"); + } + + @Test + void createNonNestedUrlFromPathString() throws Exception { + nonNestedJarFileFromString("jar:" + this.rootJarFile.toPath().toUri() + "!/2.dat"); + } + + private void nonNestedJarFileFromString(String spec) throws Exception { + JarFile.registerUrlProtocolHandler(); + URL url = new URL(spec); + assertThat(url).hasToString(spec); + JarURLConnection connection = (JarURLConnection) url.openConnection(); + try (InputStream inputStream = connection.getInputStream()) { + assertThat(inputStream).isNotNull(); + assertThat(inputStream.read()).isEqualTo(2); + assertThat(connection.getURL()).hasToString(spec); + assertThat(connection.getJarFileURL().toURI()).isEqualTo(this.rootJarFile.toURI()); + assertThat(connection.getEntryName()).isEqualTo("2.dat"); + } + connection.getJarFile().close(); + } + + @Test + void getDirectoryInputStream() throws Exception { + InputStream inputStream = this.jarFile.getInputStream(this.jarFile.getEntry("d/")); + assertThat(inputStream).isNotNull(); + assertThat(inputStream.read()).isEqualTo(-1); + } + + @Test + void getDirectoryInputStreamWithoutSlash() throws Exception { + InputStream inputStream = this.jarFile.getInputStream(this.jarFile.getEntry("d")); + assertThat(inputStream).isNotNull(); + assertThat(inputStream.read()).isEqualTo(-1); + } + + @Test + void sensibleToString() throws Exception { + assertThat(this.jarFile).hasToString(this.rootJarFile.getPath()); + try (JarFile nested = this.jarFile.getNestedJarFile(this.jarFile.getEntry("nested.jar"))) { + assertThat(nested).hasToString(this.rootJarFile.getPath() + "!/nested.jar"); + } + } + + @Test + void verifySignedJar() throws Exception { + File signedJarFile = getSignedJarFile(); + assertThat(signedJarFile).exists(); + try (java.util.jar.JarFile expected = new java.util.jar.JarFile(signedJarFile)) { + try (JarFile actual = new JarFile(signedJarFile)) { + StopWatch stopWatch = new StopWatch(); + Enumeration actualEntries = actual.entries(); + while (actualEntries.hasMoreElements()) { + JarEntry actualEntry = actualEntries.nextElement(); + java.util.jar.JarEntry expectedEntry = expected.getJarEntry(actualEntry.getName()); + StreamUtils.drain(expected.getInputStream(expectedEntry)); + if (!actualEntry.getName().equals("META-INF/MANIFEST.MF")) { + assertThat(actualEntry.getCertificates()).as(actualEntry.getName()) + .isEqualTo(expectedEntry.getCertificates()); + assertThat(actualEntry.getCodeSigners()).as(actualEntry.getName()) + .isEqualTo(expectedEntry.getCodeSigners()); + } + } + assertThat(stopWatch.getTotalTimeSeconds()).isLessThan(3.0); + } + } + } + + private File getSignedJarFile() { + String[] entries = System.getProperty("java.class.path").split(System.getProperty("path.separator")); + for (String entry : entries) { + if (entry.contains("bcprov")) { + return new File(entry); + } + } + return null; + } + + @Test + void jarFileWithScriptAtTheStart() throws Exception { + File file = new File(this.tempDir, "test.jar"); + InputStream sourceJarContent = new FileInputStream(this.rootJarFile); + FileOutputStream outputStream = new FileOutputStream(file); + StreamUtils.copy("#/bin/bash", Charset.defaultCharset(), outputStream); + FileCopyUtils.copy(sourceJarContent, outputStream); + this.rootJarFile = file; + this.jarFile.close(); + this.jarFile = new JarFile(file); + // Call some other tests to verify + getEntries(); + getNestedJarFile(); + } + + @Test + void cannotLoadMissingJar() throws Exception { + // relates to gh-1070 + try (JarFile nestedJarFile = this.jarFile.getNestedJarFile(this.jarFile.getEntry("nested.jar"))) { + URL nestedUrl = nestedJarFile.getUrl(); + URL url = new URL(nestedUrl, nestedJarFile.getUrl() + "missing.jar!/3.dat"); + assertThatExceptionOfType(FileNotFoundException.class).isThrownBy(url.openConnection()::getInputStream); + } + } + + @Test + void registerUrlProtocolHandlerWithNoExistingRegistration() { + String original = System.getProperty(PROTOCOL_HANDLER); + try { + System.clearProperty(PROTOCOL_HANDLER); + JarFile.registerUrlProtocolHandler(); + String protocolHandler = System.getProperty(PROTOCOL_HANDLER); + assertThat(protocolHandler).isEqualTo(HANDLERS_PACKAGE); + } + finally { + if (original == null) { + System.clearProperty(PROTOCOL_HANDLER); + } + else { + System.setProperty(PROTOCOL_HANDLER, original); + } + } + } + + @Test + void registerUrlProtocolHandlerAddsToExistingRegistration() { + String original = System.getProperty(PROTOCOL_HANDLER); + try { + System.setProperty(PROTOCOL_HANDLER, "com.example"); + JarFile.registerUrlProtocolHandler(); + String protocolHandler = System.getProperty(PROTOCOL_HANDLER); + assertThat(protocolHandler).isEqualTo("com.example|" + HANDLERS_PACKAGE); + } + finally { + if (original == null) { + System.clearProperty(PROTOCOL_HANDLER); + } + else { + System.setProperty(PROTOCOL_HANDLER, original); + } + } + } + + @Test + void jarFileCanBeDeletedOnceItHasBeenClosed() throws Exception { + File jar = new File(this.tempDir, "test.jar"); + TestJarCreator.createTestJar(jar); + JarFile jf = new JarFile(jar); + jf.close(); + assertThat(jar.delete()).isTrue(); + } + + @Test + void createUrlFromStringWithContextWhenNotFound() throws Exception { + // gh-12483 + JarURLConnection.setUseFastExceptions(true); + try { + try (JarFile nested = this.jarFile.getNestedJarFile(this.jarFile.getEntry("nested.jar"))) { + URL context = nested.getUrl(); + new URL(context, "jar:" + this.rootJarFile.toURI() + "!/nested.jar!/3.dat").openConnection() + .getInputStream() + .close(); + assertThatExceptionOfType(FileNotFoundException.class) + .isThrownBy(new URL(context, "jar:" + this.rootJarFile.toURI() + "!/no.dat") + .openConnection()::getInputStream); + } + } + finally { + JarURLConnection.setUseFastExceptions(false); + } + } + + @Test + void multiReleaseEntry() throws Exception { + try (JarFile multiRelease = this.jarFile.getNestedJarFile(this.jarFile.getEntry("multi-release.jar"))) { + ZipEntry entry = multiRelease.getEntry("multi-release.dat"); + assertThat(entry.getName()).isEqualTo("multi-release.dat"); + InputStream inputStream = multiRelease.getInputStream(entry); + assertThat(inputStream.available()).isOne(); + assertThat(inputStream.read()).isEqualTo(Runtime.version().feature()); + } + } + + @Test + void zip64JarThatExceedsZipEntryLimitCanBeRead() throws Exception { + File zip64Jar = new File(this.tempDir, "zip64.jar"); + FileCopyUtils.copy(zip64Jar(), zip64Jar); + try (JarFile zip64JarFile = new JarFile(zip64Jar)) { + List entries = Collections.list(zip64JarFile.entries()); + assertThat(entries).hasSize(65537); + for (int i = 0; i < entries.size(); i++) { + JarEntry entry = entries.get(i); + InputStream entryInput = zip64JarFile.getInputStream(entry); + assertThat(entryInput).hasContent("Entry " + (i + 1)); + } + } + } + + @Test + void zip64JarThatExceedsZipSizeLimitCanBeRead() throws Exception { + Assumptions.assumeTrue(this.tempDir.getFreeSpace() > 6 * 1024 * 1024 * 1024, "Insufficient disk space"); + File zip64Jar = new File(this.tempDir, "zip64.jar"); + File entry = new File(this.tempDir, "entry.dat"); + CRC32 crc32 = new CRC32(); + try (FileOutputStream entryOut = new FileOutputStream(entry)) { + byte[] data = new byte[1024 * 1024]; + new Random().nextBytes(data); + for (int i = 0; i < 1024; i++) { + entryOut.write(data); + crc32.update(data); + } + } + try (JarOutputStream jarOutput = new JarOutputStream(new FileOutputStream(zip64Jar))) { + for (int i = 0; i < 6; i++) { + JarEntry storedEntry = new JarEntry("huge-" + i); + storedEntry.setSize(entry.length()); + storedEntry.setCompressedSize(entry.length()); + storedEntry.setCrc(crc32.getValue()); + storedEntry.setMethod(ZipEntry.STORED); + jarOutput.putNextEntry(storedEntry); + try (FileInputStream entryIn = new FileInputStream(entry)) { + StreamUtils.copy(entryIn, jarOutput); + } + jarOutput.closeEntry(); + } + } + try (JarFile zip64JarFile = new JarFile(zip64Jar)) { + assertThat(Collections.list(zip64JarFile.entries())).hasSize(6); + } + } + + @Test + void nestedZip64JarCanBeRead() throws Exception { + File outer = new File(this.tempDir, "outer.jar"); + try (JarOutputStream jarOutput = new JarOutputStream(new FileOutputStream(outer))) { + JarEntry nestedEntry = new JarEntry("nested-zip64.jar"); + byte[] contents = zip64Jar(); + nestedEntry.setSize(contents.length); + nestedEntry.setCompressedSize(contents.length); + CRC32 crc32 = new CRC32(); + crc32.update(contents); + nestedEntry.setCrc(crc32.getValue()); + nestedEntry.setMethod(ZipEntry.STORED); + jarOutput.putNextEntry(nestedEntry); + jarOutput.write(contents); + jarOutput.closeEntry(); + } + try (JarFile outerJarFile = new JarFile(outer)) { + try (JarFile nestedZip64JarFile = outerJarFile + .getNestedJarFile(outerJarFile.getJarEntry("nested-zip64.jar"))) { + List entries = Collections.list(nestedZip64JarFile.entries()); + assertThat(entries).hasSize(65537); + for (int i = 0; i < entries.size(); i++) { + JarEntry entry = entries.get(i); + InputStream entryInput = nestedZip64JarFile.getInputStream(entry); + assertThat(entryInput).hasContent("Entry " + (i + 1)); + } + } + } + } + + private byte[] zip64Jar() throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + JarOutputStream jarOutput = new JarOutputStream(bytes); + for (int i = 0; i < 65537; i++) { + jarOutput.putNextEntry(new JarEntry(i + ".dat")); + jarOutput.write(("Entry " + (i + 1)).getBytes(StandardCharsets.UTF_8)); + jarOutput.closeEntry(); + } + jarOutput.close(); + return bytes.toByteArray(); + } + + @Test + void jarFileEntryWithEpochTimeOfZeroShouldNotFail() throws Exception { + File file = createJarFileWithEpochTimeOfZero(); + try (JarFile jar = new JarFile(file)) { + Enumeration entries = jar.entries(); + JarEntry entry = entries.nextElement(); + assertThat(entry.getLastModifiedTime().toInstant()).isEqualTo(Instant.EPOCH); + assertThat(entry.getName()).isEqualTo("1.dat"); + } + } + + private File createJarFileWithEpochTimeOfZero() throws Exception { + File jarFile = new File(this.tempDir, "temp.jar"); + FileOutputStream fileOutputStream = new FileOutputStream(jarFile); + String comment = "outer"; + try (JarOutputStream jarOutputStream = new JarOutputStream(fileOutputStream)) { + jarOutputStream.setComment(comment); + JarEntry entry = new JarEntry("1.dat"); + entry.setLastModifiedTime(FileTime.from(Instant.EPOCH)); + jarOutputStream.putNextEntry(entry); + jarOutputStream.write(new byte[] { (byte) 1 }); + jarOutputStream.closeEntry(); + } + + byte[] data = Files.readAllBytes(jarFile.toPath()); + int headerPosition = data.length - ZipFile.ENDHDR - comment.getBytes().length; + int centralHeaderPosition = (int) Bytes.littleEndianValue(data, headerPosition + ZipFile.ENDOFF, 1); + int localHeaderPosition = (int) Bytes.littleEndianValue(data, centralHeaderPosition + ZipFile.CENOFF, 1); + writeTimeBlock(data, centralHeaderPosition + ZipFile.CENTIM, 0); + writeTimeBlock(data, localHeaderPosition + ZipFile.LOCTIM, 0); + + File jar = new File(this.tempDir, "zerotimed.jar"); + Files.write(jar.toPath(), data); + return jar; + } + + private static void writeTimeBlock(byte[] data, int pos, int value) { + data[pos] = (byte) (value & 0xff); + data[pos + 1] = (byte) ((value >> 8) & 0xff); + data[pos + 2] = (byte) ((value >> 16) & 0xff); + data[pos + 3] = (byte) ((value >> 24) & 0xff); + } + + @Test + void iterator() { + Iterator iterator = this.jarFile.iterator(); + List names = new ArrayList<>(); + while (iterator.hasNext()) { + names.add(iterator.next().getName()); + } + assertThat(names).hasSize(12).contains("1.dat"); + } + + @Test + void iteratorWhenClosed() throws IOException { + this.jarFile.close(); + assertThatZipFileClosedIsThrownBy(() -> this.jarFile.iterator()); + } + + @Test + void iteratorWhenClosedLater() throws IOException { + Iterator iterator = this.jarFile.iterator(); + iterator.next(); + this.jarFile.close(); + assertThatZipFileClosedIsThrownBy(() -> iterator.hasNext()); + } + + @Test + void stream() { + Stream stream = this.jarFile.stream().map(JarEntry::getName); + assertThat(stream).hasSize(12).contains("1.dat"); + + } + + private void assertThatZipFileClosedIsThrownBy(ThrowingCallable throwingCallable) { + assertThatIllegalStateException().isThrownBy(throwingCallable).withMessage("zip file closed"); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarFileWrapperTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarFileWrapperTests.java new file mode 100644 index 0000000000..8ae25b72e1 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarFileWrapperTests.java @@ -0,0 +1,281 @@ +/* + * Copyright 2012-2023 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.loader.jar; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URL; +import java.security.Permission; +import java.util.EnumSet; +import java.util.Enumeration; +import java.util.Set; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.jar.JarFileWrapperTests.SpyJarFile.Call; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link JarFileWrapper}. + * + * @author Phillip Webb + */ +class JarFileWrapperTests { + + private SpyJarFile parent; + + private JarFileWrapper wrapper; + + @BeforeEach + void setup(@TempDir File temp) throws Exception { + this.parent = new SpyJarFile(createTempJar(temp)); + this.wrapper = new JarFileWrapper(this.parent); + } + + @AfterEach + void cleanup() throws Exception { + this.parent.close(); + } + + private File createTempJar(File temp) throws IOException { + File file = new File(temp, "temp.jar"); + new JarOutputStream(new FileOutputStream(file)).close(); + return file; + } + + @Test + void getUrlDelegatesToParent() throws MalformedURLException { + this.wrapper.getUrl(); + this.parent.verify(Call.GET_URL); + } + + @Test + void getTypeDelegatesToParent() { + this.wrapper.getType(); + this.parent.verify(Call.GET_TYPE); + } + + @Test + void getPermissionDelegatesToParent() { + this.wrapper.getPermission(); + this.parent.verify(Call.GET_PERMISSION); + } + + @Test + void getManifestDelegatesToParent() throws IOException { + this.wrapper.getManifest(); + this.parent.verify(Call.GET_MANIFEST); + } + + @Test + void entriesDelegatesToParent() { + this.wrapper.entries(); + this.parent.verify(Call.ENTRIES); + } + + @Test + void getJarEntryDelegatesToParent() { + this.wrapper.getJarEntry("test"); + this.parent.verify(Call.GET_JAR_ENTRY); + } + + @Test + void getEntryDelegatesToParent() { + this.wrapper.getEntry("test"); + this.parent.verify(Call.GET_ENTRY); + } + + @Test + void getInputStreamDelegatesToParent() throws IOException { + this.wrapper.getInputStream(); + this.parent.verify(Call.GET_INPUT_STREAM); + } + + @Test + void getEntryInputStreamDelegatesToParent() throws IOException { + ZipEntry entry = new ZipEntry("test"); + this.wrapper.getInputStream(entry); + this.parent.verify(Call.GET_ENTRY_INPUT_STREAM); + } + + @Test + void getCommentDelegatesToParent() { + this.wrapper.getComment(); + this.parent.verify(Call.GET_COMMENT); + } + + @Test + void sizeDelegatesToParent() { + this.wrapper.size(); + this.parent.verify(Call.SIZE); + } + + @Test + void toStringDelegatesToParent() { + assertThat(this.wrapper.toString()).endsWith("temp.jar"); + } + + @Test // gh-22991 + void wrapperMustNotImplementClose() { + // If the wrapper overrides close then on Java 11 a FinalizableResource + // instance will be used to perform cleanup. This can result in a lot + // of additional memory being used since cleanup only occurs when the + // finalizer thread runs. See gh-22991 + assertThatExceptionOfType(NoSuchMethodException.class) + .isThrownBy(() -> JarFileWrapper.class.getDeclaredMethod("close")); + } + + @Test + void streamDelegatesToParent() { + this.wrapper.stream(); + this.parent.verify(Call.STREAM); + } + + /** + * {@link JarFile} that we can spy (even on Java 11+) + */ + static class SpyJarFile extends JarFile { + + private final Set calls = EnumSet.noneOf(Call.class); + + SpyJarFile(File file) throws IOException { + super(file); + } + + @Override + Permission getPermission() { + mark(Call.GET_PERMISSION); + return super.getPermission(); + } + + @Override + public Manifest getManifest() throws IOException { + mark(Call.GET_MANIFEST); + return super.getManifest(); + } + + @Override + public Enumeration entries() { + mark(Call.ENTRIES); + return super.entries(); + } + + @Override + public Stream stream() { + mark(Call.STREAM); + return super.stream(); + } + + @Override + public JarEntry getJarEntry(String name) { + mark(Call.GET_JAR_ENTRY); + return super.getJarEntry(name); + } + + @Override + public ZipEntry getEntry(String name) { + mark(Call.GET_ENTRY); + return super.getEntry(name); + } + + @Override + InputStream getInputStream() throws IOException { + mark(Call.GET_INPUT_STREAM); + return super.getInputStream(); + } + + @Override + InputStream getInputStream(String name) throws IOException { + mark(Call.GET_ENTRY_INPUT_STREAM); + return super.getInputStream(name); + } + + @Override + public String getComment() { + mark(Call.GET_COMMENT); + return super.getComment(); + } + + @Override + public int size() { + mark(Call.SIZE); + return super.size(); + } + + @Override + public URL getUrl() throws MalformedURLException { + mark(Call.GET_URL); + return super.getUrl(); + } + + @Override + JarFileType getType() { + mark(Call.GET_TYPE); + return super.getType(); + } + + private void mark(Call call) { + this.calls.add(call); + } + + void verify(Call call) { + assertThat(call).matches(this.calls::contains); + } + + enum Call { + + GET_URL, + + GET_TYPE, + + GET_PERMISSION, + + GET_MANIFEST, + + ENTRIES, + + GET_JAR_ENTRY, + + GET_ENTRY, + + GET_INPUT_STREAM, + + GET_ENTRY_INPUT_STREAM, + + GET_COMMENT, + + SIZE, + + STREAM + + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarURLConnectionTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarURLConnectionTests.java new file mode 100644 index 0000000000..d962a72fc5 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarURLConnectionTests.java @@ -0,0 +1,246 @@ +/* + * Copyright 2012-2023 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.loader.jar; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.net.URL; +import java.util.List; +import java.util.jar.JarEntry; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.TestJarCreator; +import org.springframework.boot.loader.jar.JarURLConnection.JarEntryName; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link JarURLConnection}. + * + * @author Andy Wilkinson + * @author Phillip Webb + * @author Rostyslav Dudka + */ +class JarURLConnectionTests { + + private File rootJarFile; + + private JarFile jarFile; + + @BeforeEach + void setup(@TempDir File tempDir) throws Exception { + this.rootJarFile = new File(tempDir, "root.jar"); + TestJarCreator.createTestJar(this.rootJarFile); + this.jarFile = new JarFile(this.rootJarFile); + } + + @AfterEach + void tearDown() throws Exception { + this.jarFile.close(); + } + + @Test + void connectionToRootUsingAbsoluteUrl() throws Exception { + URL url = new URL("jar:" + this.rootJarFile.toURI().toURL() + "!/"); + Object content = JarURLConnection.get(url, this.jarFile).getContent(); + assertThat(JarFileWrapper.unwrap((java.util.jar.JarFile) content)).isSameAs(this.jarFile); + } + + @Test + void connectionToRootUsingRelativeUrl() throws Exception { + URL url = new URL("jar:file:" + getRelativePath() + "!/"); + Object content = JarURLConnection.get(url, this.jarFile).getContent(); + assertThat(JarFileWrapper.unwrap((java.util.jar.JarFile) content)).isSameAs(this.jarFile); + } + + @Test + void connectionToEntryUsingAbsoluteUrl() throws Exception { + URL url = new URL("jar:" + this.rootJarFile.toURI().toURL() + "!/1.dat"); + try (InputStream input = JarURLConnection.get(url, this.jarFile).getInputStream()) { + assertThat(input).hasBinaryContent(new byte[] { 1 }); + } + } + + @Test + void connectionToEntryUsingRelativeUrl() throws Exception { + URL url = new URL("jar:file:" + getRelativePath() + "!/1.dat"); + try (InputStream input = JarURLConnection.get(url, this.jarFile).getInputStream()) { + assertThat(input).hasBinaryContent(new byte[] { 1 }); + } + } + + @Test + void connectionToEntryUsingAbsoluteUrlWithFileColonSlashSlashPrefix() throws Exception { + URL url = new URL("jar:" + this.rootJarFile.toURI().toURL() + "!/1.dat"); + try (InputStream input = JarURLConnection.get(url, this.jarFile).getInputStream()) { + assertThat(input).hasBinaryContent(new byte[] { 1 }); + } + } + + @Test + void connectionToEntryUsingAbsoluteUrlForNestedEntry() throws Exception { + URL url = new URL("jar:" + this.rootJarFile.toURI().toURL() + "!/nested.jar!/3.dat"); + JarURLConnection connection = JarURLConnection.get(url, this.jarFile); + try (InputStream input = connection.getInputStream()) { + assertThat(input).hasBinaryContent(new byte[] { 3 }); + } + connection.getJarFile().close(); + } + + @Test + void connectionToEntryUsingRelativeUrlForNestedEntry() throws Exception { + URL url = new URL("jar:file:" + getRelativePath() + "!/nested.jar!/3.dat"); + JarURLConnection connection = JarURLConnection.get(url, this.jarFile); + try (InputStream input = connection.getInputStream()) { + assertThat(input).hasBinaryContent(new byte[] { 3 }); + } + connection.getJarFile().close(); + } + + @Test + void connectionToEntryUsingAbsoluteUrlForEntryFromNestedJarFile() throws Exception { + URL url = new URL("jar:" + this.rootJarFile.toURI().toURL() + "!/nested.jar!/3.dat"); + try (JarFile nested = this.jarFile.getNestedJarFile(this.jarFile.getEntry("nested.jar"))) { + try (InputStream input = JarURLConnection.get(url, nested).getInputStream()) { + assertThat(input).hasBinaryContent(new byte[] { 3 }); + } + } + } + + @Test + void connectionToEntryUsingRelativeUrlForEntryFromNestedJarFile() throws Exception { + URL url = new URL("jar:file:" + getRelativePath() + "!/nested.jar!/3.dat"); + try (JarFile nested = this.jarFile.getNestedJarFile(this.jarFile.getEntry("nested.jar"))) { + try (InputStream input = JarURLConnection.get(url, nested).getInputStream()) { + assertThat(input).hasBinaryContent(new byte[] { 3 }); + } + } + } + + @Test + void connectionToEntryInNestedJarFromUrlThatUsesExistingUrlAsContext() throws Exception { + URL url = new URL(new URL("jar", null, -1, this.rootJarFile.toURI().toURL() + "!/nested.jar!/", new Handler()), + "/3.dat"); + try (JarFile nested = this.jarFile.getNestedJarFile(this.jarFile.getEntry("nested.jar"))) { + try (InputStream input = JarURLConnection.get(url, nested).getInputStream()) { + assertThat(input).hasBinaryContent(new byte[] { 3 }); + } + } + } + + @Test + void connectionToEntryWithSpaceNestedEntry() throws Exception { + URL url = new URL("jar:file:" + getRelativePath() + "!/space nested.jar!/3.dat"); + JarURLConnection connection = JarURLConnection.get(url, this.jarFile); + try (InputStream input = connection.getInputStream()) { + assertThat(input).hasBinaryContent(new byte[] { 3 }); + } + connection.getJarFile().close(); + } + + @Test + void connectionToEntryWithEncodedSpaceNestedEntry() throws Exception { + URL url = new URL("jar:file:" + getRelativePath() + "!/space%20nested.jar!/3.dat"); + JarURLConnection connection = JarURLConnection.get(url, this.jarFile); + try (InputStream input = connection.getInputStream()) { + assertThat(input).hasBinaryContent(new byte[] { 3 }); + } + connection.getJarFile().close(); + } + + @Test + void connectionToEntryUsingWrongAbsoluteUrlForEntryFromNestedJarFile() throws Exception { + URL url = new URL("jar:" + this.rootJarFile.toURI().toURL() + "!/w.jar!/3.dat"); + try (JarFile nested = this.jarFile.getNestedJarFile(this.jarFile.getEntry("nested.jar"))) { + assertThatExceptionOfType(FileNotFoundException.class) + .isThrownBy(JarURLConnection.get(url, nested)::getInputStream); + } + } + + @Test + void getContentLengthReturnsLengthOfUnderlyingEntry() throws Exception { + URL url = new URL("jar:" + this.rootJarFile.toURI().toURL() + "!/nested.jar!/3.dat"); + try (JarFile nested = this.jarFile.getNestedJarFile(this.jarFile.getEntry("nested.jar"))) { + JarURLConnection connection = JarURLConnection.get(url, nested); + assertThat(connection.getContentLength()).isOne(); + } + } + + @Test + void getContentLengthLongReturnsLengthOfUnderlyingEntry() throws Exception { + URL url = new URL("jar:" + this.rootJarFile.toURI().toURL() + "!/nested.jar!/3.dat"); + try (JarFile nested = this.jarFile.getNestedJarFile(this.jarFile.getEntry("nested.jar"))) { + JarURLConnection connection = JarURLConnection.get(url, nested); + assertThat(connection.getContentLengthLong()).isOne(); + } + } + + @Test + void getLastModifiedReturnsLastModifiedTimeOfJarEntry() throws Exception { + URL url = new URL("jar:" + this.rootJarFile.toURI().toURL() + "!/1.dat"); + JarURLConnection connection = JarURLConnection.get(url, this.jarFile); + assertThat(connection.getLastModified()).isEqualTo(connection.getJarEntry().getTime()); + } + + @Test + void entriesCanBeStreamedFromJarFileOfConnection() throws Exception { + URL url = new URL("jar:" + this.rootJarFile.toURI().toURL() + "!/"); + JarURLConnection connection = JarURLConnection.get(url, this.jarFile); + List entryNames = connection.getJarFile().stream().map(JarEntry::getName).toList(); + assertThat(entryNames).hasSize(12); + } + + @Test + void jarEntryBasicName() { + assertThat(new JarEntryName(new StringSequence("a/b/C.class"))).hasToString("a/b/C.class"); + } + + @Test + void jarEntryNameWithSingleByteEncodedCharacters() { + assertThat(new JarEntryName(new StringSequence("%61/%62/%43.class"))).hasToString("a/b/C.class"); + } + + @Test + void jarEntryNameWithDoubleByteEncodedCharacters() { + assertThat(new JarEntryName(new StringSequence("%c3%a1/b/C.class"))).hasToString("\u00e1/b/C.class"); + } + + @Test + void jarEntryNameWithMixtureOfEncodedAndUnencodedDoubleByteCharacters() { + assertThat(new JarEntryName(new StringSequence("%c3%a1/b/\u00c7.class"))).hasToString("\u00e1/b/\u00c7.class"); + } + + @Test + void openConnectionCanBeClosedWithoutClosingSourceJar() throws Exception { + URL url = new URL("jar:" + this.rootJarFile.toURI().toURL() + "!/"); + JarURLConnection connection = JarURLConnection.get(url, this.jarFile); + java.util.jar.JarFile connectionJarFile = connection.getJarFile(); + connectionJarFile.close(); + assertThat(this.jarFile.isClosed()).isFalse(); + } + + private String getRelativePath() { + return this.rootJarFile.getPath().replace('\\', '/'); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarUrlProtocolHandler.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarUrlProtocolHandler.java new file mode 100644 index 0000000000..d9e5eb2814 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarUrlProtocolHandler.java @@ -0,0 +1,57 @@ +/* + * Copyright 2012-2023 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.loader.jar; + +import java.io.File; +import java.lang.ref.SoftReference; +import java.util.Map; + +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.Extension; +import org.junit.jupiter.api.extension.ExtensionContext; + +import org.springframework.test.util.ReflectionTestUtils; + +/** + * JUnit 5 {@link Extension} for tests that interact with Spring Boot's {@link Handler} + * for {@code jar:} URLs. Ensures that the handler is registered prior to test execution + * and cleans up the handler's root file cache afterwards. + * + * @author Andy Wilkinson + */ +class JarUrlProtocolHandler implements BeforeEachCallback, AfterEachCallback { + + @Override + public void beforeEach(ExtensionContext context) throws Exception { + JarFile.registerUrlProtocolHandler(); + } + + @Override + @SuppressWarnings("unchecked") + public void afterEach(ExtensionContext context) throws Exception { + Map rootFileCache = ((SoftReference>) ReflectionTestUtils + .getField(Handler.class, "rootFileCache")).get(); + if (rootFileCache != null) { + for (JarFile rootJarFile : rootFileCache.values()) { + rootJarFile.close(); + } + rootFileCache.clear(); + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/StringSequenceTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/StringSequenceTests.java new file mode 100644 index 0000000000..ee7170f08c --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/StringSequenceTests.java @@ -0,0 +1,220 @@ +/* + * Copyright 2012-2023 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.loader.jar; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +/** + * Tests for {@link StringSequence}. + * + * @author Phillip Webb + */ +class StringSequenceTests { + + @Test + void createWhenSourceIsNullShouldThrowException() { + assertThatNullPointerException().isThrownBy(() -> new StringSequence(null)) + .withMessage("Source must not be null"); + } + + @Test + void createWithIndexWhenSourceIsNullShouldThrowException() { + assertThatNullPointerException().isThrownBy(() -> new StringSequence(null, 0, 0)) + .withMessage("Source must not be null"); + } + + @Test + void createWhenStartIsLessThanZeroShouldThrowException() { + assertThatExceptionOfType(StringIndexOutOfBoundsException.class) + .isThrownBy(() -> new StringSequence("x", -1, 0)); + } + + @Test + void createWhenEndIsGreaterThanLengthShouldThrowException() { + assertThatExceptionOfType(StringIndexOutOfBoundsException.class) + .isThrownBy(() -> new StringSequence("x", 0, 2)); + } + + @Test + void createFromString() { + assertThat(new StringSequence("test")).hasToString("test"); + } + + @Test + void subSequenceWithJustStartShouldReturnSubSequence() { + assertThat(new StringSequence("smiles").subSequence(1)).hasToString("miles"); + } + + @Test + void subSequenceShouldReturnSubSequence() { + assertThat(new StringSequence("hamburger").subSequence(4, 8)).hasToString("urge"); + assertThat(new StringSequence("smiles").subSequence(1, 5)).hasToString("mile"); + } + + @Test + void subSequenceWhenCalledMultipleTimesShouldReturnSubSequence() { + assertThat(new StringSequence("hamburger").subSequence(4, 8).subSequence(1, 3)).hasToString("rg"); + } + + @Test + void subSequenceWhenEndPastExistingEndShouldThrowException() { + StringSequence sequence = new StringSequence("abcde").subSequence(1, 4); + assertThat(sequence).hasToString("bcd"); + assertThat(sequence.subSequence(2, 3)).hasToString("d"); + assertThatExceptionOfType(IndexOutOfBoundsException.class).isThrownBy(() -> sequence.subSequence(3, 4)); + } + + @Test + void subSequenceWhenStartPastExistingEndShouldThrowException() { + StringSequence sequence = new StringSequence("abcde").subSequence(1, 4); + assertThat(sequence).hasToString("bcd"); + assertThat(sequence.subSequence(2, 3)).hasToString("d"); + assertThatExceptionOfType(IndexOutOfBoundsException.class).isThrownBy(() -> sequence.subSequence(4, 3)); + } + + @Test + void isEmptyWhenEmptyShouldReturnTrue() { + assertThat(new StringSequence("").isEmpty()).isTrue(); + } + + @Test + void isEmptyWhenNotEmptyShouldReturnFalse() { + assertThat(new StringSequence("x").isEmpty()).isFalse(); + } + + @Test + void lengthShouldReturnLength() { + StringSequence sequence = new StringSequence("hamburger"); + assertThat(sequence).hasSize(9); + assertThat(sequence.subSequence(4, 8)).hasSize(4); + } + + @Test + void charAtShouldReturnChar() { + StringSequence sequence = new StringSequence("hamburger"); + assertThat(sequence.charAt(0)).isEqualTo('h'); + assertThat(sequence.charAt(1)).isEqualTo('a'); + assertThat(sequence.subSequence(4, 8).charAt(0)).isEqualTo('u'); + assertThat(sequence.subSequence(4, 8).charAt(1)).isEqualTo('r'); + } + + @Test + void indexOfCharShouldReturnIndexOf() { + StringSequence sequence = new StringSequence("aabbaacc"); + assertThat(sequence.indexOf('a')).isZero(); + assertThat(sequence.indexOf('b')).isEqualTo(2); + assertThat(sequence.subSequence(2).indexOf('a')).isEqualTo(2); + } + + @Test + void indexOfStringShouldReturnIndexOf() { + StringSequence sequence = new StringSequence("aabbaacc"); + assertThat(sequence.indexOf('a')).isZero(); + assertThat(sequence.indexOf('b')).isEqualTo(2); + assertThat(sequence.subSequence(2).indexOf('a')).isEqualTo(2); + } + + @Test + void indexOfStringFromIndexShouldReturnIndexOf() { + StringSequence sequence = new StringSequence("aabbaacc"); + assertThat(sequence.indexOf("a", 2)).isEqualTo(4); + assertThat(sequence.indexOf("b", 3)).isEqualTo(3); + assertThat(sequence.subSequence(2).indexOf("a", 3)).isEqualTo(3); + } + + @Test + void hashCodeShouldBeSameAsString() { + assertThat(new StringSequence("hamburger")).hasSameHashCodeAs("hamburger"); + assertThat(new StringSequence("hamburger").subSequence(4, 8)).hasSameHashCodeAs("urge"); + } + + @Test + void equalsWhenSameContentShouldMatch() { + StringSequence a = new StringSequence("hamburger").subSequence(4, 8); + StringSequence b = new StringSequence("urge"); + StringSequence c = new StringSequence("urgh"); + assertThat(a).isEqualTo(b).isNotEqualTo(c); + } + + @Test + void notEqualsWhenSequencesOfDifferentLength() { + StringSequence a = new StringSequence("abcd"); + StringSequence b = new StringSequence("ef"); + assertThat(a).isNotEqualTo(b); + } + + @Test + void startsWithWhenExactMatch() { + assertThat(new StringSequence("abc").startsWith("abc")).isTrue(); + } + + @Test + void startsWithWhenLongerAndStartsWith() { + assertThat(new StringSequence("abcd").startsWith("abc")).isTrue(); + } + + @Test + void startsWithWhenLongerAndDoesNotStartWith() { + assertThat(new StringSequence("abcd").startsWith("abx")).isFalse(); + } + + @Test + void startsWithWhenShorterAndDoesNotStartWith() { + assertThat(new StringSequence("ab").startsWith("abc")).isFalse(); + assertThat(new StringSequence("ab").startsWith("c")).isFalse(); + } + + @Test + void startsWithOffsetWhenExactMatch() { + assertThat(new StringSequence("xabc").startsWith("abc", 1)).isTrue(); + } + + @Test + void startsWithOffsetWhenLongerAndStartsWith() { + assertThat(new StringSequence("xabcd").startsWith("abc", 1)).isTrue(); + } + + @Test + void startsWithOffsetWhenLongerAndDoesNotStartWith() { + assertThat(new StringSequence("xabcd").startsWith("abx", 1)).isFalse(); + } + + @Test + void startsWithOffsetWhenShorterAndDoesNotStartWith() { + assertThat(new StringSequence("xab").startsWith("abc", 1)).isFalse(); + assertThat(new StringSequence("xab").startsWith("c", 1)).isFalse(); + } + + @Test + void startsWithOnSubstringTailWhenMatch() { + StringSequence subSequence = new StringSequence("xabc").subSequence(1); + assertThat(subSequence.startsWith("abc")).isTrue(); + assertThat(subSequence.startsWith("abcd")).isFalse(); + } + + @Test + void startsWithOnSubstringMiddleWhenMatch() { + StringSequence subSequence = new StringSequence("xabc").subSequence(1, 3); + assertThat(subSequence.startsWith("ab")).isTrue(); + assertThat(subSequence.startsWith("abc")).isFalse(); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jarmode/LauncherJarModeTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jarmode/LauncherJarModeTests.java new file mode 100644 index 0000000000..dec587e18b --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jarmode/LauncherJarModeTests.java @@ -0,0 +1,86 @@ +/* + * Copyright 2012-2023 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.loader.jarmode; + +import java.util.Collections; +import java.util.Iterator; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.boot.loader.Launcher; +import org.springframework.boot.loader.archive.Archive; +import org.springframework.boot.testsupport.system.CapturedOutput; +import org.springframework.boot.testsupport.system.OutputCaptureExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link Launcher} with jar mode support. + * + * @author Phillip Webb + */ +@ExtendWith(OutputCaptureExtension.class) +class LauncherJarModeTests { + + @BeforeEach + void setup() { + System.setProperty(JarModeLauncher.DISABLE_SYSTEM_EXIT, "true"); + } + + @AfterEach + void cleanup() { + System.clearProperty("jarmode"); + System.clearProperty(JarModeLauncher.DISABLE_SYSTEM_EXIT); + } + + @Test + void launchWhenJarModePropertyIsSetLaunchesJarMode(CapturedOutput out) throws Exception { + System.setProperty("jarmode", "test"); + new TestLauncher().launch(new String[] { "boot" }); + assertThat(out).contains("running in test jar mode [boot]"); + } + + @Test + void launchWhenJarModePropertyIsNotAcceptedThrowsException(CapturedOutput out) throws Exception { + System.setProperty("jarmode", "idontexist"); + new TestLauncher().launch(new String[] { "boot" }); + assertThat(out).contains("Unsupported jarmode 'idontexist'"); + } + + private static class TestLauncher extends Launcher { + + @Override + protected String getMainClass() throws Exception { + throw new IllegalStateException("Should not be called"); + } + + @Override + protected Iterator getClassPathArchivesIterator() throws Exception { + return Collections.emptyIterator(); + } + + @Override + protected void launch(String[] args) throws Exception { + super.launch(args); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/util/SystemPropertyUtilsTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/util/SystemPropertyUtilsTests.java new file mode 100644 index 0000000000..802a762e79 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/util/SystemPropertyUtilsTests.java @@ -0,0 +1,62 @@ +/* + * Copyright 2012-2023 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.loader.util; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link SystemPropertyUtils}. + * + * @author Dave Syer + */ +class SystemPropertyUtilsTests { + + @BeforeEach + void init() { + System.setProperty("foo", "bar"); + } + + @AfterEach + void close() { + System.clearProperty("foo"); + } + + @Test + void testVanillaPlaceholder() { + assertThat(SystemPropertyUtils.resolvePlaceholders("${foo}")).isEqualTo("bar"); + } + + @Test + void testDefaultValue() { + assertThat(SystemPropertyUtils.resolvePlaceholders("${bar:foo}")).isEqualTo("foo"); + } + + @Test + void testNestedPlaceholder() { + assertThat(SystemPropertyUtils.resolvePlaceholders("${bar:${spam:foo}}")).isEqualTo("foo"); + } + + @Test + void testEnvVar() { + assertThat(SystemPropertyUtils.getProperty("lang")).isEqualTo(System.getenv("LANG")); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/BOOT-INF/classes/application.properties b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/BOOT-INF/classes/application.properties new file mode 100644 index 0000000000..85a390f4d4 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/BOOT-INF/classes/application.properties @@ -0,0 +1 @@ +loader.main: demo.Application diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/BOOT-INF/classes/bar.properties b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/BOOT-INF/classes/bar.properties new file mode 100644 index 0000000000..6b37480f8b --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/BOOT-INF/classes/bar.properties @@ -0,0 +1 @@ +loader.main: my.BootInfBarApplication diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/BOOT-INF/classes/foo.properties b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/BOOT-INF/classes/foo.properties new file mode 100644 index 0000000000..36bd211df4 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/BOOT-INF/classes/foo.properties @@ -0,0 +1,3 @@ +foo: Application +loader.main: my.${foo} +loader.path: etc diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/BOOT-INF/classes/loader.properties b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/BOOT-INF/classes/loader.properties new file mode 100644 index 0000000000..85a390f4d4 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/BOOT-INF/classes/loader.properties @@ -0,0 +1 @@ +loader.main: demo.Application diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/META-INF/spring.factories b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/META-INF/spring.factories new file mode 100644 index 0000000000..c45c87d76f --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/META-INF/spring.factories @@ -0,0 +1,3 @@ +# Jar Modes +org.springframework.boot.loader.jarmode.JarMode=\ +org.springframework.boot.loader.jarmode.TestJarMode \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/bar.properties b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/bar.properties new file mode 100644 index 0000000000..8301c2649f --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/bar.properties @@ -0,0 +1 @@ +loader.main: my.BarApplication diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/explodedsample/ExampleClass.txt b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/explodedsample/ExampleClass.txt new file mode 100644 index 0000000000..c53100f90f --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/explodedsample/ExampleClass.txt @@ -0,0 +1,26 @@ +/* + * Copyright 2012-2020 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 explodedsample; + +/** + * Example class used to test class loading. + * + * @author Phillip Webb + */ +public class ExampleClass { + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/home/loader.properties b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/home/loader.properties new file mode 100644 index 0000000000..7a134969b7 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/home/loader.properties @@ -0,0 +1 @@ +loader.main: demo.HomeApplication diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/jars/app.jar b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/jars/app.jar new file mode 100644 index 0000000000..fb02c02701 Binary files /dev/null and b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/jars/app.jar differ diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/more-jars/app.jar b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/more-jars/app.jar new file mode 100644 index 0000000000..3945fd020d Binary files /dev/null and b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/more-jars/app.jar differ diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/nested-jars/app.jar b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/nested-jars/app.jar new file mode 100644 index 0000000000..5600ed279e Binary files /dev/null and b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/nested-jars/app.jar differ diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/nested-jars/nested-jar-app.jar b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/nested-jars/nested-jar-app.jar new file mode 100644 index 0000000000..4c2254f635 Binary files /dev/null and b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/nested-jars/nested-jar-app.jar differ diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/org/springframework/boot/loader/classpath-index-file.idx b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/org/springframework/boot/loader/classpath-index-file.idx new file mode 100644 index 0000000000..b84b99a6b4 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/org/springframework/boot/loader/classpath-index-file.idx @@ -0,0 +1,5 @@ +- "BOOT-INF/layers/one/lib/a.jar" +- "BOOT-INF/layers/one/lib/b.jar" +- "BOOT-INF/layers/one/lib/c.jar" +- "BOOT-INF/layers/two/lib/d.jar" +- "BOOT-INF/layers/two/lib/e.jar" diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/placeholders/META-INF/MANIFEST.MF b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/placeholders/META-INF/MANIFEST.MF new file mode 100644 index 0000000000..d95a13c528 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/placeholders/META-INF/MANIFEST.MF @@ -0,0 +1,2 @@ +Manifest-Version: 1.0 +Start-Class: ${foo.main} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/placeholders/loader.properties b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/placeholders/loader.properties new file mode 100644 index 0000000000..32f7d00f2d --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/placeholders/loader.properties @@ -0,0 +1 @@ +foo.main: demo.FooApplication diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/root/META-INF/MANIFEST.MF b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/root/META-INF/MANIFEST.MF new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/root/META-INF/MANIFEST.MF @@ -0,0 +1 @@ + diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/root/META-INF/spring/application.xml b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/root/META-INF/spring/application.xml new file mode 100644 index 0000000000..cf04aa4fbe --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/root/META-INF/spring/application.xml @@ -0,0 +1,6 @@ + + + + diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/build.gradle b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/build.gradle new file mode 100644 index 0000000000..7c4095f73b --- /dev/null +++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/build.gradle @@ -0,0 +1,44 @@ +plugins { + id "java" + id "org.springframework.boot.conventions" + id "org.springframework.boot.integration-test" +} + +description = "Spring Boot Loader Integration Tests" + +configurations { + app +} + +dependencies { + app project(path: ":spring-boot-project:spring-boot-dependencies", configuration: "mavenRepository") + app project(path: ":spring-boot-project:spring-boot-tools:spring-boot-gradle-plugin", configuration: "mavenRepository") + app project(path: ":spring-boot-project:spring-boot-starters:spring-boot-starter-web", configuration: "mavenRepository") + + intTestImplementation(enforcedPlatform(project(":spring-boot-project:spring-boot-parent"))) + intTestImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support")) + intTestImplementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-test")) + intTestImplementation("org.testcontainers:junit-jupiter") + intTestImplementation("org.testcontainers:testcontainers") +} + +task syncMavenRepository(type: Sync) { + from configurations.app + into "${buildDir}/int-test-maven-repository" +} + +task syncAppSource(type: org.springframework.boot.build.SyncAppSource) { + sourceDirectory = file("spring-boot-loader-tests-app") + destinationDirectory = file("${buildDir}/spring-boot-loader-tests-app") +} + +task buildApp(type: GradleBuild) { + dependsOn syncAppSource, syncMavenRepository + dir = "${buildDir}/spring-boot-loader-tests-app" + startParameter.buildCacheEnabled = false + tasks = ["build"] +} + +intTest { + dependsOn buildApp +} \ No newline at end of file diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-app/build.gradle b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-app/build.gradle new file mode 100644 index 0000000000..37596c6206 --- /dev/null +++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-app/build.gradle @@ -0,0 +1,18 @@ +plugins { + id "java" + id "org.springframework.boot" +} + +apply plugin: "io.spring.dependency-management" + +repositories { + maven { url "file:${rootDir}/../int-test-maven-repository"} + mavenCentral() + maven { url "https://repo.spring.io/snapshot" } + maven { url "https://repo.spring.io/milestone" } +} + +dependencies { + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.webjars:jquery:3.5.0") +} diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-app/settings.gradle b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-app/settings.gradle new file mode 100644 index 0000000000..06d9554ad0 --- /dev/null +++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-app/settings.gradle @@ -0,0 +1,15 @@ +pluginManagement { + repositories { + maven { url "file:${rootDir}/../int-test-maven-repository"} + mavenCentral() + maven { url "https://repo.spring.io/snapshot" } + maven { url "https://repo.spring.io/milestone" } + } + resolutionStrategy { + eachPlugin { + if (requested.id.id == "org.springframework.boot") { + useModule "org.springframework.boot:spring-boot-gradle-plugin:${requested.version}" + } + } + } +} \ No newline at end of file diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-app/src/main/java/org/springframework/boot/loaderapp/LoaderTestApplication.java b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-app/src/main/java/org/springframework/boot/loaderapp/LoaderTestApplication.java new file mode 100644 index 0000000000..0c9d429350 --- /dev/null +++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/spring-boot-loader-tests-app/src/main/java/org/springframework/boot/loaderapp/LoaderTestApplication.java @@ -0,0 +1,59 @@ +/* + * Copyright 2012-2023 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.loaderapp; + +import java.io.File; +import java.net.JarURLConnection; +import java.net.URL; +import java.util.Arrays; + +import jakarta.servlet.ServletContext; + +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.util.FileCopyUtils; + +@SpringBootApplication +public class LoaderTestApplication { + + @Bean + public CommandLineRunner commandLineRunner(ServletContext servletContext) { + return (args) -> { + File temp = new File(System.getProperty("java.io.tmpdir")); + URL resourceUrl = servletContext.getResource("webjars/jquery/3.5.0/jquery.js"); + JarURLConnection connection = (JarURLConnection) resourceUrl.openConnection(); + String jarName = connection.getJarFile().getName(); + System.out.println(">>>>> jar file " + jarName); + if(jarName.contains(temp.getAbsolutePath())) { + System.out.println(">>>>> jar written to temp"); + } + byte[] resourceContent = FileCopyUtils.copyToByteArray(resourceUrl.openStream()); + URL directUrl = new URL(resourceUrl.toExternalForm()); + byte[] directContent = FileCopyUtils.copyToByteArray(directUrl.openStream()); + String message = (!Arrays.equals(resourceContent, directContent)) ? "NO MATCH" + : directContent.length + " BYTES"; + System.out.println(">>>>> " + message + " from " + resourceUrl); + }; + } + + public static void main(String[] args) { + SpringApplication.run(LoaderTestApplication.class, args).close(); + } + +} diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/src/intTest/java/org/springframework/boot/loader/LoaderIntegrationTests.java b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/src/intTest/java/org/springframework/boot/loader/LoaderIntegrationTests.java new file mode 100644 index 0000000000..a2d6db7c7f --- /dev/null +++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/src/intTest/java/org/springframework/boot/loader/LoaderIntegrationTests.java @@ -0,0 +1,139 @@ +/* + * Copyright 2012-2023 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.loader; + +import java.io.File; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; +import java.util.stream.Stream; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.output.ToStringConsumer; +import org.testcontainers.containers.startupcheck.OneShotStartupCheckStrategy; +import org.testcontainers.images.builder.ImageFromDockerfile; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.MountableFile; + +import org.springframework.boot.system.JavaVersion; +import org.springframework.boot.testsupport.testcontainers.DisabledIfDockerUnavailable; +import org.springframework.util.Assert; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests loader that supports fat jars. + * + * @author Phillip Webb + * @author Moritz Halbritter + */ +@DisabledIfDockerUnavailable +class LoaderIntegrationTests { + + private final ToStringConsumer output = new ToStringConsumer(); + + @ParameterizedTest + @MethodSource("javaRuntimes") + void readUrlsWithoutWarning(JavaRuntime javaRuntime) { + try (GenericContainer container = createContainer(javaRuntime)) { + container.start(); + System.out.println(this.output.toUtf8String()); + assertThat(this.output.toUtf8String()).contains(">>>>> 287649 BYTES from") + .doesNotContain("WARNING:") + .doesNotContain("illegal") + .doesNotContain("jar written to temp"); + } + } + + private GenericContainer createContainer(JavaRuntime javaRuntime) { + return javaRuntime.getContainer() + .withLogConsumer(this.output) + .withCopyFileToContainer(MountableFile.forHostPath(findApplication().toPath()), "/app.jar") + .withStartupCheckStrategy(new OneShotStartupCheckStrategy().withTimeout(Duration.ofMinutes(5))) + .withCommand("java", "-jar", "app.jar"); + } + + private File findApplication() { + String name = String.format("build/%1$s/build/libs/%1$s.jar", "spring-boot-loader-tests-app"); + File jar = new File(name); + Assert.state(jar.isFile(), () -> "Could not find " + name + ". Have you built it?"); + return jar; + } + + static Stream javaRuntimes() { + List javaRuntimes = new ArrayList<>(); + javaRuntimes.add(JavaRuntime.openJdk(JavaVersion.SEVENTEEN)); + javaRuntimes.add(JavaRuntime.openJdk(JavaVersion.TWENTY)); + javaRuntimes.add(JavaRuntime.oracleJdk17()); + javaRuntimes.add(JavaRuntime.openJdkEarlyAccess(JavaVersion.TWENTY_ONE)); + return javaRuntimes.stream().filter(JavaRuntime::isCompatible); + } + + static final class JavaRuntime { + + private final String name; + + private final JavaVersion version; + + private final Supplier> container; + + private JavaRuntime(String name, JavaVersion version, Supplier> container) { + this.name = name; + this.version = version; + this.container = container; + } + + private boolean isCompatible() { + return this.version.isEqualOrNewerThan(JavaVersion.getJavaVersion()); + } + + GenericContainer getContainer() { + return this.container.get(); + } + + @Override + public String toString() { + return this.name; + } + + static JavaRuntime openJdkEarlyAccess(JavaVersion version) { + String imageVersion = version.toString(); + DockerImageName image = DockerImageName.parse("openjdk:%s-ea-jdk".formatted(imageVersion)); + return new JavaRuntime("OpenJDK Early Access " + imageVersion, version, + () -> new GenericContainer<>(image)); + } + + static JavaRuntime openJdk(JavaVersion version) { + String imageVersion = version.toString(); + DockerImageName image = DockerImageName.parse("bellsoft/liberica-openjdk-debian:" + imageVersion); + return new JavaRuntime("OpenJDK " + imageVersion, version, () -> new GenericContainer<>(image)); + } + + static JavaRuntime oracleJdk17() { + String arch = System.getProperty("os.arch"); + String dockerFile = ("aarch64".equals(arch)) ? "Dockerfile-aarch64" : "Dockerfile"; + ImageFromDockerfile image = new ImageFromDockerfile("spring-boot-loader/oracle-jdk-17") + .withFileFromFile("Dockerfile", new File("src/intTest/resources/conf/oracle-jdk-17/" + dockerFile)); + return new JavaRuntime("Oracle JDK 17", JavaVersion.SEVENTEEN, () -> new GenericContainer<>(image)); + } + + } + +} diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/src/intTest/resources/conf/oracle-jdk-17/Dockerfile b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/src/intTest/resources/conf/oracle-jdk-17/Dockerfile new file mode 100644 index 0000000000..2a50709dc5 --- /dev/null +++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/src/intTest/resources/conf/oracle-jdk-17/Dockerfile @@ -0,0 +1,8 @@ +FROM ubuntu:jammy-20230624 +RUN apt-get update && \ + apt-get install -y software-properties-common curl && \ + mkdir -p /opt/oraclejdk && \ + cd /opt/oraclejdk && \ + curl -L https://download.oracle.com/java/17/latest/jdk-17_linux-x64_bin.tar.gz | tar zx --strip-components=1 +ENV JAVA_HOME /opt/oraclejdk +ENV PATH $JAVA_HOME/bin:$PATH diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/src/intTest/resources/conf/oracle-jdk-17/Dockerfile-aarch64 b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/src/intTest/resources/conf/oracle-jdk-17/Dockerfile-aarch64 new file mode 100644 index 0000000000..3f8614c7a2 --- /dev/null +++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/src/intTest/resources/conf/oracle-jdk-17/Dockerfile-aarch64 @@ -0,0 +1,8 @@ +FROM ubuntu:jammy-20230624 +RUN apt-get update && \ + apt-get install -y software-properties-common curl && \ + mkdir -p /opt/oraclejdk && \ + cd /opt/oraclejdk && \ + curl -L https://download.oracle.com/java/17/archive/jdk-17.0.8_linux-aarch64_bin.tar.gz | tar zx --strip-components=1 +ENV JAVA_HOME /opt/oraclejdk +ENV PATH $JAVA_HOME/bin:$PATH diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/src/intTest/resources/conf/oracle-jdk-17/README.adoc b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/src/intTest/resources/conf/oracle-jdk-17/README.adoc new file mode 100644 index 0000000000..28704af225 --- /dev/null +++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/src/intTest/resources/conf/oracle-jdk-17/README.adoc @@ -0,0 +1,5 @@ +This folder contains a Dockerfile that will create an Oracle JDK instance for use in integration tests. +The resulting Docker image should not be published. + +Oracle JDK is subject to the https://www.oracle.com/downloads/licenses/no-fee-license.html["Oracle No-Fee Terms and Conditions" License (NFTC)] license. +We are specifically using the unmodified JDK for the purposes of developing and testing. diff --git a/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/src/intTest/resources/logback.xml b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/src/intTest/resources/logback.xml new file mode 100644 index 0000000000..b8a41480d7 --- /dev/null +++ b/spring-boot-tests/spring-boot-integration-tests/spring-boot-loader-tests/src/intTest/resources/logback.xml @@ -0,0 +1,4 @@ + + + +