diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/jarfile-class.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/jarfile-class.adoc index da7c616fb3..b1db0c8726 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/jarfile-class.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/jarfile-class.adoc @@ -1,7 +1,7 @@ [[appendix.executable-jar.jarfile-class]] -== Spring Boot's "`JarFile`" Class -The core class used to support loading nested jars is `org.springframework.boot.loader.jar.JarFile`. -It lets you load jar content from a standard jar file or from nested child jar data. +== Spring Boot's "`NestedJarFile`" Class +The core class used to support loading nested jars is `org.springframework.boot.loader.jar.NestedJarFile`. +It lets you load jar content from nested child jar data. When first loaded, the location of each `JarEntry` is mapped to a physical file offset of the outer jar, as shown in the following example: [indent=0] @@ -28,5 +28,7 @@ We do not need to unpack the archive, and we do not need to read all entry data [[appendix.executable-jar.jarfile-class.compatibility]] === Compatibility With the Standard Java "`JarFile`" Spring Boot Loader strives to remain compatible with existing code and libraries. -`org.springframework.boot.loader.jar.JarFile` extends from `java.util.jar.JarFile` and should work as a drop-in replacement. -The `getURL()` method returns a `URL` that opens a connection compatible with `java.net.JarURLConnection` and can be used with Java's `URLClassLoader`. +`org.springframework.boot.loader.jar.NestedJarFile` extends from `java.util.jar.JarFile` and should work as a drop-in replacement. + +Nested JAR URLs of the form `jar:nested:/path/myjar.jar/!BOOT-INF/lib/mylib.jar!/B.class` are supported and open a connection compatible with `java.net.JarURLConnection`. +These can be used with Java's `URLClassLoader`. diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/launching.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/launching.adoc index 481145b60a..690b85c438 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/launching.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/executable-jar/launching.adoc @@ -1,13 +1,14 @@ [[appendix.executable-jar.launching]] == Launching Executable Jars -The `org.springframework.boot.loader.Launcher` class is a special bootstrap class that is used as an executable jar's main entry point. -It is the actual `Main-Class` in your jar file, and it is used to setup an appropriate `URLClassLoader` and ultimately call your `main()` method. +The `org.springframework.boot.loader.launch.Launcher` class is a special bootstrap class that is used as an executable jar's main entry point. +It is the actual `Main-Class` in your jar file, and it is used to setup an appropriate `ClassLoader` and ultimately call your `main()` method. There are three launcher subclasses (`JarLauncher`, `WarLauncher`, and `PropertiesLauncher`). Their purpose is to load resources (`.class` files and so on) from nested jar files or war files in directories (as opposed to those explicitly on the classpath). In the case of `JarLauncher` and `WarLauncher`, the nested paths are fixed. `JarLauncher` looks in `BOOT-INF/lib/`, and `WarLauncher` looks in `WEB-INF/lib/` and `WEB-INF/lib-provided/`. You can add extra jars in those locations if you want more. + The `PropertiesLauncher` looks in `BOOT-INF/lib/` in your application archive by default. You can add additional locations by setting an environment variable called `LOADER_PATH` or `loader.path` in `loader.properties` (which is a comma-separated list of directories, archives, or directories within archives). @@ -30,7 +31,7 @@ For a war file, it would be as follows: [indent=0] ---- - Main-Class: org.springframework.boot.loader.WarLauncher + Main-Class: org.springframework.boot.loader.launch.WarLauncher Start-Class: com.mycompany.project.MyApplication ---- diff --git a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/AbstractBootArchiveTests.java b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/AbstractBootArchiveTests.java index 3dffc00752..c5c78eb5a2 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/AbstractBootArchiveTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/AbstractBootArchiveTests.java @@ -256,7 +256,8 @@ abstract class AbstractBootArchiveTests { this.task.getMainClass().set("com.example.Main"); executeTask(); try (JarFile jarFile = new JarFile(this.task.getArchiveFile().get().getAsFile())) { - assertThat(jarFile.getEntry("org/springframework/boot/loader/LaunchedURLClassLoader.class")).isNotNull(); + assertThat(jarFile.getEntry("org/springframework/boot/loader/launch/LaunchedClassLoader.class")) + .isNotNull(); assertThat(jarFile.getEntry("org/springframework/boot/loader/")).isNotNull(); } // gh-16698 @@ -275,7 +276,8 @@ abstract class AbstractBootArchiveTests { .getAttributes() .put("Main-Class", "org.springframework.boot.loader.launch.PropertiesLauncher"); try (JarFile jarFile = new JarFile(this.task.getArchiveFile().get().getAsFile())) { - assertThat(jarFile.getEntry("org/springframework/boot/loader/LaunchedURLClassLoader.class")).isNotNull(); + assertThat(jarFile.getEntry("org/springframework/boot/loader/launch/LaunchedClassLoader.class")) + .isNotNull(); assertThat(jarFile.getEntry("org/springframework/boot/loader/")).isNotNull(); } } 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 deleted file mode 100644 index d2ceaf61c5..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ExecutableArchiveLauncher.java +++ /dev/null @@ -1,207 +0,0 @@ -/* - * 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 deleted file mode 100644 index 5061573e24..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/JarLauncher.java +++ /dev/null @@ -1,68 +0,0 @@ -/* - * 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 deleted file mode 100644 index 7e3e2fa223..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/LaunchedURLClassLoader.java +++ /dev/null @@ -1,366 +0,0 @@ -/* - * 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 deleted file mode 100644 index 2f4cac9444..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/Launcher.java +++ /dev/null @@ -1,159 +0,0 @@ -/* - * 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 deleted file mode 100644 index 12355a2bef..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/MainMethodRunner.java +++ /dev/null @@ -1,52 +0,0 @@ -/* - * 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 deleted file mode 100755 index 3703ac1367..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/PropertiesLauncher.java +++ /dev/null @@ -1,726 +0,0 @@ -/* - * 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 deleted file mode 100644 index 482832c1f7..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/WarLauncher.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * 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 deleted file mode 100644 index c1f2bbb2f7..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/Archive.java +++ /dev/null @@ -1,115 +0,0 @@ -/* - * 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 deleted file mode 100644 index f8cd52dc16..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/ExplodedArchive.java +++ /dev/null @@ -1,342 +0,0 @@ -/* - * 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 deleted file mode 100755 index 91e7bc53a4..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/JarFileArchive.java +++ /dev/null @@ -1,310 +0,0 @@ -/* - * 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/data/RandomAccessData.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/data/RandomAccessData.java deleted file mode 100644 index e96d5ea81a..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/data/RandomAccessData.java +++ /dev/null @@ -1,74 +0,0 @@ -/* - * 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 deleted file mode 100644 index 4bd5d20541..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/data/RandomAccessDataFile.java +++ /dev/null @@ -1,262 +0,0 @@ -/* - * 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/jar/AbstractJarFile.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/AbstractJarFile.java deleted file mode 100644 index 6a98ef6821..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/AbstractJarFile.java +++ /dev/null @@ -1,78 +0,0 @@ -/* - * 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 deleted file mode 100644 index cfe121b689..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/AsciiBytes.java +++ /dev/null @@ -1,255 +0,0 @@ -/* - * 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/CentralDirectoryEndRecord.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryEndRecord.java deleted file mode 100644 index 61db0b73f4..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryEndRecord.java +++ /dev/null @@ -1,258 +0,0 @@ -/* - * 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 deleted file mode 100644 index 19c88dda52..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryFileHeader.java +++ /dev/null @@ -1,222 +0,0 @@ -/* - * 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 deleted file mode 100644 index eff96a56e2..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryParser.java +++ /dev/null @@ -1,101 +0,0 @@ -/* - * 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/FileHeader.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/FileHeader.java deleted file mode 100644 index 7e4134fe56..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/FileHeader.java +++ /dev/null @@ -1,64 +0,0 @@ -/* - * 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 deleted file mode 100644 index 932dea6548..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/Handler.java +++ /dev/null @@ -1,466 +0,0 @@ -/* - * 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 deleted file mode 100644 index 8f54dc3070..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarEntry.java +++ /dev/null @@ -1,120 +0,0 @@ -/* - * 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 deleted file mode 100644 index ffd629e094..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarEntryCertification.java +++ /dev/null @@ -1,58 +0,0 @@ -/* - * 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/JarFile.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarFile.java deleted file mode 100644 index 6e548048db..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarFile.java +++ /dev/null @@ -1,475 +0,0 @@ -/* - * 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 deleted file mode 100644 index d151c8d80a..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarFileEntries.java +++ /dev/null @@ -1,491 +0,0 @@ -/* - * 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 deleted file mode 100644 index b65358947a..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarFileWrapper.java +++ /dev/null @@ -1,126 +0,0 @@ -/* - * 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 deleted file mode 100644 index 859ae88ab0..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarURLConnection.java +++ /dev/null @@ -1,393 +0,0 @@ -/* - * 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/ManifestInfo.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/ManifestInfo.java new file mode 100644 index 0000000000..1a6b592f32 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/ManifestInfo.java @@ -0,0 +1,79 @@ +/* + * 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.jar.Attributes; +import java.util.jar.Attributes.Name; +import java.util.jar.Manifest; + +import org.springframework.boot.loader.zip.ZipContent; + +/** + * Info obtained from a {@link ZipContent} instance relating to the {@link Manifest}. + * + * @author Phillip Webb + */ +class ManifestInfo { + + private static final Name MULTI_RELEASE = new Name("Multi-Release"); + + static final ManifestInfo NONE = new ManifestInfo(null, false); + + private final Manifest manifest; + + private volatile Boolean multiRelease; + + /** + * Create a new {@link ManifestInfo} instance. + * @param manifest the jar manifest + */ + ManifestInfo(Manifest manifest) { + this(manifest, null); + } + + private ManifestInfo(Manifest manifest, Boolean multiRelease) { + this.manifest = manifest; + this.multiRelease = multiRelease; + } + + /** + * Return the manifest, if any. + * @return the manifest or {@code null} + */ + Manifest getManifest() { + return this.manifest; + } + + /** + * Return if this is a multi-release jar. + * @return if the jar is multi-release + */ + boolean isMultiRelease() { + if (this.manifest == null) { + return false; + } + Boolean multiRelease = this.multiRelease; + if (multiRelease != null) { + return multiRelease; + } + Attributes attributes = this.manifest.getMainAttributes(); + multiRelease = attributes.containsKey(MULTI_RELEASE); + this.multiRelease = multiRelease; + return multiRelease; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/MetaInfVersionsInfo.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/MetaInfVersionsInfo.java new file mode 100644 index 0000000000..caf76a2b96 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/MetaInfVersionsInfo.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.util.Collections; +import java.util.Set; +import java.util.TreeSet; +import java.util.function.IntFunction; + +import org.springframework.boot.loader.zip.ZipContent; + +/** + * Info obtained from a {@link ZipContent} instance relating to the directories listed + * under {@code META-INF/versions/}. + * + * @author Phillip Webb + */ +final class MetaInfVersionsInfo { + + static final MetaInfVersionsInfo NONE = new MetaInfVersionsInfo(Collections.emptySet()); + + private static final String META_INF_VERSIONS = NestedJarFile.META_INF_VERSIONS; + + private final int[] versions; + + private final String[] directories; + + private MetaInfVersionsInfo(Set versions) { + this.versions = versions.stream().mapToInt(Integer::intValue).toArray(); + this.directories = versions.stream().map((version) -> META_INF_VERSIONS + version + "/").toArray(String[]::new); + } + + /** + * Return the versions listed under {@code META-INF/versions/} in ascending order. + * @return the versions + */ + int[] versions() { + return this.versions; + } + + /** + * Return the version directories in the same order as {@link #versions()}. + * @return the version directories + */ + String[] directories() { + return this.directories; + } + + /** + * Get {@link MetaInfVersionsInfo} for the given {@link ZipContent}. + * @param zipContent the zip content + * @return the {@link MetaInfVersionsInfo}. + */ + static MetaInfVersionsInfo get(ZipContent zipContent) { + return get(zipContent.size(), zipContent::getEntry); + } + + /** + * Get {@link MetaInfVersionsInfo} for the given details. + * @param size the number of entries + * @param entries a function to get an entry from an index + * @return the {@link MetaInfVersionsInfo}. + */ + static MetaInfVersionsInfo get(int size, IntFunction entries) { + Set versions = new TreeSet<>(); + for (int i = 0; i < size; i++) { + ZipContent.Entry contentEntry = entries.apply(i); + if (contentEntry.hasNameStartingWith(META_INF_VERSIONS) && !contentEntry.isDirectory()) { + String name = contentEntry.getName(); + int slash = name.indexOf('/', META_INF_VERSIONS.length()); + String version = name.substring(META_INF_VERSIONS.length(), slash); + try { + int versionNumber = Integer.parseInt(version); + if (versionNumber >= NestedJarFile.BASE_VERSION) { + versions.add(versionNumber); + } + } + catch (NumberFormatException ex) { + // Ignore + } + } + } + return (!versions.isEmpty()) ? new MetaInfVersionsInfo(versions) : NONE; + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/NestedJarFile.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/NestedJarFile.java new file mode 100644 index 0000000000..b3de537026 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/NestedJarFile.java @@ -0,0 +1,801 @@ +/* + * 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.io.UncheckedIOException; +import java.lang.ref.Cleaner.Cleanable; +import java.nio.ByteBuffer; +import java.nio.file.attribute.FileTime; +import java.security.CodeSigner; +import java.security.cert.Certificate; +import java.time.LocalDateTime; +import java.util.Enumeration; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.Spliterator; +import java.util.Spliterators.AbstractSpliterator; +import java.util.function.Consumer; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.Manifest; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; +import java.util.zip.Inflater; +import java.util.zip.ZipEntry; +import java.util.zip.ZipException; + +import org.springframework.boot.loader.log.DebugLogger; +import org.springframework.boot.loader.ref.Cleaner; +import org.springframework.boot.loader.zip.CloseableDataBlock; +import org.springframework.boot.loader.zip.ZipContent; +import org.springframework.boot.loader.zip.ZipContent.Entry; + +/** + * Extended variant of {@link JarFile} that behaves in the same way but can open nested + * jars. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @since 3.2.0 + */ +public class NestedJarFile extends JarFile { + + private static final int DECIMAL = 10; + + private static final String META_INF = "META-INF/"; + + static final String META_INF_VERSIONS = META_INF + "versions/"; + + static final int BASE_VERSION = baseVersion().feature(); + + private static final DebugLogger debug = DebugLogger.get(NestedJarFile.class); + + private final Cleaner cleaner; + + private final NestedJarFileResources resources; + + private final Cleanable cleanup; + + private final String name; + + private final int version; + + private volatile NestedJarEntry lastEntry; + + private volatile boolean closed; + + private volatile ManifestInfo manifestInfo; + + private volatile MetaInfVersionsInfo metaInfVersionsInfo; + + /** + * Creates a new {@link NestedJarFile} instance to read from the specific + * {@code File}. + * @param file the jar file to be opened for reading + * @throws IOException on I/O error + */ + NestedJarFile(File file) throws IOException { + this(file, null, null, false, Cleaner.instance); + } + + /** + * Creates a new {@link NestedJarFile} instance to read from the specific + * {@code File}. + * @param file the jar file to be opened for reading + * @param nestedEntryName the nested entry name to open or {@code null} + * @throws IOException on I/O error + */ + public NestedJarFile(File file, String nestedEntryName) throws IOException { + this(file, nestedEntryName, null, true, Cleaner.instance); + } + + /** + * Creates a new {@link NestedJarFile} instance to read from the specific + * {@code File}. + * @param file the jar file to be opened for reading + * @param nestedEntryName the nested entry name to open or {@code null} + * @param version the release version to use when opening a multi-release jar + * @throws IOException on I/O error + */ + public NestedJarFile(File file, String nestedEntryName, Runtime.Version version) throws IOException { + this(file, nestedEntryName, version, true, Cleaner.instance); + } + + /** + * Creates a new {@link NestedJarFile} instance to read from the specific + * {@code File}. + * @param file the jar file to be opened for reading + * @param nestedEntryName the nested entry name to open or {@code null} + * @param version the release version to use when opening a multi-release jar + * @param onlyNestedJars if only nested jars should be opened + * @param cleaner the cleaner used to release resources + * @throws IOException on I/O error + */ + NestedJarFile(File file, String nestedEntryName, Runtime.Version version, boolean onlyNestedJars, Cleaner cleaner) + throws IOException { + super(file); + if (onlyNestedJars && (nestedEntryName == null || nestedEntryName.isEmpty())) { + throw new IllegalArgumentException("nestedEntryName must not be empty"); + } + debug.log("Created nested jar file (%s, %s, %s)", file, nestedEntryName, version); + this.cleaner = cleaner; + this.resources = new NestedJarFileResources(file, nestedEntryName); + this.cleanup = cleaner.register(this, this.resources); + this.name = file.getPath() + ((nestedEntryName != null) ? "!/" + nestedEntryName : ""); + this.version = (version != null) ? version.feature() : baseVersion().feature(); + } + + @Override + public Manifest getManifest() throws IOException { + try { + return this.resources.zipContent().getInfo(ManifestInfo.class, this::getManifestInfo).getManifest(); + } + catch (UncheckedIOException ex) { + throw ex.getCause(); + } + } + + @Override + public Enumeration entries() { + synchronized (this) { + ensureOpen(); + return new JarEntriesEnumeration(this.resources.zipContent()); + } + } + + @Override + public Stream stream() { + synchronized (this) { + ensureOpen(); + return streamContentEntries().map(NestedJarEntry::new); + } + } + + @Override + public Stream versionedStream() { + synchronized (this) { + ensureOpen(); + return streamContentEntries().map(this::getBaseName) + .filter(Objects::nonNull) + .distinct() + .map(this::getJarEntry) + .filter(Objects::nonNull); + } + } + + private Stream streamContentEntries() { + ZipContentEntriesSpliterator spliterator = new ZipContentEntriesSpliterator(this.resources.zipContent()); + return StreamSupport.stream(spliterator, false); + } + + private String getBaseName(ZipContent.Entry contentEntry) { + String name = contentEntry.getName(); + if (!name.startsWith(META_INF_VERSIONS)) { + return name; + } + int versionNumberStartIndex = META_INF_VERSIONS.length(); + int versionNumberEndIndex = (versionNumberStartIndex != -1) ? name.indexOf('/', versionNumberStartIndex) : -1; + if (versionNumberEndIndex == -1 || versionNumberEndIndex == (name.length() - 1)) { + return null; + } + try { + int versionNumber = Integer.parseInt(name, versionNumberStartIndex, versionNumberEndIndex, DECIMAL); + if (versionNumber > this.version) { + return null; + } + } + catch (NumberFormatException ex) { + return null; + } + return name.substring(versionNumberEndIndex + 1); + } + + @Override + public JarEntry getJarEntry(String name) { + return getNestedJarEntry(name); + } + + @Override + public JarEntry getEntry(String name) { + return getNestedJarEntry(name); + } + + /** + * Return if an entry with the given name exists. + * @param name the name to check + * @return if the entry exists + */ + public boolean hasEntry(String name) { + NestedJarEntry lastEntry = this.lastEntry; + if (lastEntry != null && name.equals(lastEntry.getName())) { + return true; + } + ZipContent.Entry entry = getVersionedContentEntry(name); + if (entry != null) { + return false; + } + synchronized (this) { + ensureOpen(); + return this.resources.zipContent().hasEntry(null, name); + } + } + + private NestedJarEntry getNestedJarEntry(String name) { + Objects.requireNonNull(name, "name"); + NestedJarEntry lastEntry = this.lastEntry; + if (lastEntry != null && name.equals(lastEntry.getName())) { + return lastEntry; + } + ZipContent.Entry entry = getVersionedContentEntry(name); + entry = (entry != null) ? entry : getContentEntry(null, name); + if (entry == null) { + return null; + } + NestedJarEntry nestedJarEntry = new NestedJarEntry(entry, name); + this.lastEntry = nestedJarEntry; + return nestedJarEntry; + } + + private ZipContent.Entry getVersionedContentEntry(String name) { + // NOTE: we can't call isMultiRelease() directly because it's a final method and + // it inspects the container jar. We use ManifestInfo instead. + if (BASE_VERSION >= this.version || name.startsWith(META_INF) || !getManifestInfo().isMultiRelease()) { + return null; + } + MetaInfVersionsInfo metaInfVersionsInfo = getMetaInfVersionsInfo(); + int[] versions = metaInfVersionsInfo.versions(); + String[] directories = metaInfVersionsInfo.directories(); + for (int i = versions.length - 1; i >= 0; i--) { + if (versions[i] <= this.version) { + ZipContent.Entry entry = getContentEntry(directories[i], name); + if (entry != null) { + return entry; + } + } + } + return null; + } + + private ZipContent.Entry getContentEntry(String namePrefix, String name) { + synchronized (this) { + ensureOpen(); + return this.resources.zipContent().getEntry(namePrefix, name); + } + } + + private ManifestInfo getManifestInfo() { + ManifestInfo manifestInfo = this.manifestInfo; + if (manifestInfo != null) { + return manifestInfo; + } + synchronized (this) { + ensureOpen(); + manifestInfo = this.resources.zipContent().getInfo(ManifestInfo.class, this::getManifestInfo); + } + this.manifestInfo = manifestInfo; + return manifestInfo; + } + + private ManifestInfo getManifestInfo(ZipContent zipContent) { + ZipContent.Entry contentEntry = zipContent.getEntry(MANIFEST_NAME); + if (contentEntry == null) { + return ManifestInfo.NONE; + } + try { + try (InputStream inputStream = getInputStream(contentEntry)) { + Manifest manifest = new Manifest(inputStream); + return new ManifestInfo(manifest); + } + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + private MetaInfVersionsInfo getMetaInfVersionsInfo() { + MetaInfVersionsInfo metaInfVersionsInfo = this.metaInfVersionsInfo; + if (metaInfVersionsInfo != null) { + return metaInfVersionsInfo; + } + synchronized (this) { + ensureOpen(); + metaInfVersionsInfo = this.resources.zipContent() + .getInfo(MetaInfVersionsInfo.class, MetaInfVersionsInfo::get); + } + this.metaInfVersionsInfo = metaInfVersionsInfo; + return metaInfVersionsInfo; + } + + @Override + public InputStream getInputStream(ZipEntry entry) throws IOException { + Objects.requireNonNull(entry, "entry"); + if (entry instanceof NestedJarEntry nestedJarEntry && nestedJarEntry.isOwnedBy(this)) { + return getInputStream(nestedJarEntry.contentEntry()); + } + return getInputStream(getNestedJarEntry(entry.getName()).contentEntry()); + } + + private InputStream getInputStream(ZipContent.Entry contentEntry) throws IOException { + int compression = contentEntry.getCompressionMethod(); + if (compression != ZipEntry.STORED && compression != ZipEntry.DEFLATED) { + throw new ZipException("invalid compression method"); + } + synchronized (this) { + ensureOpen(); + InputStream inputStream = new JarEntryInputStream(contentEntry); + try { + if (compression == ZipEntry.DEFLATED) { + inputStream = new JarEntryInflaterInputStream((JarEntryInputStream) inputStream, this.resources); + } + this.resources.addInputStream(inputStream); + return inputStream; + } + catch (RuntimeException ex) { + inputStream.close(); + throw ex; + } + } + } + + @Override + public String getComment() { + synchronized (this) { + ensureOpen(); + return this.resources.zipContent().getComment(); + } + } + + @Override + public int size() { + synchronized (this) { + ensureOpen(); + return this.resources.zipContent().size(); + } + } + + @Override + public void close() throws IOException { + if (this.closed) { + return; + } + this.closed = true; + synchronized (this) { + try { + this.cleanup.clean(); + } + catch (UncheckedIOException ex) { + throw ex.getCause(); + } + } + } + + @Override + public String getName() { + return this.name; + } + + private void ensureOpen() { + if (this.closed) { + throw new IllegalStateException("Zip file closed"); + } + if (this.resources.zipContent() == null) { + throw new IllegalStateException("The object is not initialized."); + } + } + + /** + * Clear any internal caches. + */ + public void clearCache() { + synchronized (this) { + this.lastEntry = null; + } + } + + /** + * An individual entry from a {@link NestedJarFile}. + */ + private class NestedJarEntry extends java.util.jar.JarEntry { + + private static final IllegalStateException CANNOT_BE_MODIFIED_EXCEPTION = new IllegalStateException( + "Neste jar entries cannot be modified"); + + private final ZipContent.Entry contentEntry; + + private final String name; + + private volatile boolean populated; + + NestedJarEntry(Entry contentEntry) { + this(contentEntry, contentEntry.getName()); + } + + NestedJarEntry(ZipContent.Entry contentEntry, String name) { + super(contentEntry.getName()); + this.contentEntry = contentEntry; + this.name = name; + } + + @Override + public long getTime() { + populate(); + return super.getTime(); + } + + @Override + public LocalDateTime getTimeLocal() { + populate(); + return super.getTimeLocal(); + } + + @Override + public void setTime(long time) { + throw CANNOT_BE_MODIFIED_EXCEPTION; + } + + @Override + public void setTimeLocal(LocalDateTime time) { + throw CANNOT_BE_MODIFIED_EXCEPTION; + } + + @Override + public FileTime getLastModifiedTime() { + populate(); + return super.getLastModifiedTime(); + } + + @Override + public ZipEntry setLastModifiedTime(FileTime time) { + throw CANNOT_BE_MODIFIED_EXCEPTION; + } + + @Override + public FileTime getLastAccessTime() { + populate(); + return super.getLastAccessTime(); + } + + @Override + public ZipEntry setLastAccessTime(FileTime time) { + throw CANNOT_BE_MODIFIED_EXCEPTION; + } + + @Override + public FileTime getCreationTime() { + populate(); + return super.getCreationTime(); + } + + @Override + public ZipEntry setCreationTime(FileTime time) { + throw CANNOT_BE_MODIFIED_EXCEPTION; + } + + @Override + public long getSize() { + return this.contentEntry.getUncompressedSize() & 0xFFFFFFFFL; + } + + @Override + public void setSize(long size) { + throw CANNOT_BE_MODIFIED_EXCEPTION; + } + + @Override + public long getCompressedSize() { + populate(); + return super.getCompressedSize(); + } + + @Override + public void setCompressedSize(long csize) { + throw CANNOT_BE_MODIFIED_EXCEPTION; + } + + @Override + public long getCrc() { + populate(); + return super.getCrc(); + } + + @Override + public void setCrc(long crc) { + throw CANNOT_BE_MODIFIED_EXCEPTION; + } + + @Override + public int getMethod() { + populate(); + return super.getMethod(); + } + + @Override + public void setMethod(int method) { + throw CANNOT_BE_MODIFIED_EXCEPTION; + } + + @Override + public byte[] getExtra() { + populate(); + return super.getExtra(); + } + + @Override + public void setExtra(byte[] extra) { + throw CANNOT_BE_MODIFIED_EXCEPTION; + } + + @Override + public String getComment() { + populate(); + return super.getComment(); + } + + @Override + public void setComment(String comment) { + throw CANNOT_BE_MODIFIED_EXCEPTION; + } + + boolean isOwnedBy(NestedJarFile nestedJarFile) { + return NestedJarFile.this == nestedJarFile; + } + + @Override + public String getRealName() { + return super.getName(); + } + + @Override + public String getName() { + return this.name; + } + + @Override + public Attributes getAttributes() throws IOException { + Manifest manifest = getManifest(); + return (manifest != null) ? manifest.getAttributes(getName()) : null; + } + + @Override + public Certificate[] getCertificates() { + return getSecurityInfo().getCertificates(contentEntry()); + } + + @Override + public CodeSigner[] getCodeSigners() { + return getSecurityInfo().getCodeSigners(contentEntry()); + } + + private SecurityInfo getSecurityInfo() { + return NestedJarFile.this.resources.zipContent().getInfo(SecurityInfo.class, SecurityInfo::get); + } + + ZipContent.Entry contentEntry() { + return this.contentEntry; + } + + private void populate() { + boolean populated = this.populated; + if (!populated) { + ZipEntry entry = this.contentEntry.as(ZipEntry::new); + super.setMethod(entry.getMethod()); + super.setTime(entry.getTime()); + super.setCrc(entry.getCrc()); + super.setCompressedSize(entry.getCompressedSize()); + super.setSize(entry.getSize()); + super.setExtra(entry.getExtra()); + super.setComment(entry.getComment()); + this.populated = true; + } + } + + } + + /** + * {@link Enumeration} of {@link NestedJarEntry} instances. + */ + private class JarEntriesEnumeration implements Enumeration { + + private final ZipContent zipContent; + + private int cursor; + + JarEntriesEnumeration(ZipContent zipContent) { + this.zipContent = zipContent; + } + + @Override + public boolean hasMoreElements() { + return this.cursor < this.zipContent.size(); + } + + @Override + public NestedJarEntry nextElement() { + if (!hasMoreElements()) { + throw new NoSuchElementException(); + } + synchronized (NestedJarFile.this) { + ensureOpen(); + return new NestedJarEntry(this.zipContent.getEntry(this.cursor++)); + } + } + + } + + /** + * {@link Spliterator} for {@link ZipContent.Entry} instances. + */ + private class ZipContentEntriesSpliterator extends AbstractSpliterator { + + private static final int ADDITIONAL_CHARACTERISTICS = Spliterator.ORDERED | Spliterator.DISTINCT + | Spliterator.IMMUTABLE | Spliterator.NONNULL; + + private final ZipContent zipContent; + + private int cursor; + + ZipContentEntriesSpliterator(ZipContent zipContent) { + super(zipContent.size(), ADDITIONAL_CHARACTERISTICS); + this.zipContent = zipContent; + } + + @Override + public boolean tryAdvance(Consumer action) { + if (this.cursor < this.zipContent.size()) { + synchronized (NestedJarFile.this) { + ensureOpen(); + action.accept(this.zipContent.getEntry(this.cursor++)); + } + return true; + } + return false; + } + + } + + /** + * {@link InputStream} to read jar entry content. + */ + private class JarEntryInputStream extends InputStream { + + private final int uncompressedSize; + + private final CloseableDataBlock content; + + private long pos; + + private long remaining; + + private volatile boolean closed; + + JarEntryInputStream(ZipContent.Entry entry) throws IOException { + this.uncompressedSize = entry.getUncompressedSize(); + this.content = entry.openContent(); + } + + @Override + public int read() throws IOException { + byte[] b = new byte[1]; + return (read(b, 0, 1) == 1) ? b[0] & 0xFF : -1; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + int result; + synchronized (NestedJarFile.this) { + ensureOpen(); + ByteBuffer dst = ByteBuffer.wrap(b, off, len); + int count = this.content.read(dst, this.pos); + if (count > 0) { + this.pos += count; + this.remaining -= count; + } + result = count; + } + if (this.remaining == 0) { + close(); + } + return result; + } + + @Override + public long skip(long n) throws IOException { + long result; + synchronized (NestedJarFile.this) { + result = (n > 0) ? maxForwardSkip(n) : maxBackwardSkip(n); + this.pos += result; + this.remaining -= result; + } + if (this.remaining == 0) { + close(); + } + return result; + } + + private long maxForwardSkip(long n) { + boolean willCauseOverflow = (this.pos + n) < 0; + return (willCauseOverflow || n > this.remaining) ? this.remaining : n; + } + + private long maxBackwardSkip(long n) { + return Math.max(-this.pos, n); + } + + @Override + public int available() { + return (this.remaining < Integer.MAX_VALUE) ? (int) this.remaining : Integer.MAX_VALUE; + } + + private void ensureOpen() throws ZipException { + if (NestedJarFile.this.closed || this.closed) { + throw new ZipException("ZipFile closed"); + } + } + + @Override + public void close() throws IOException { + if (this.closed) { + return; + } + this.closed = true; + this.content.close(); + NestedJarFile.this.resources.removeInputStream(this); + } + + int getUncompressedSize() { + return this.uncompressedSize; + } + + } + + /** + * {@link ZipInflaterInputStream} to read and inflate jar entry content. + */ + private class JarEntryInflaterInputStream extends ZipInflaterInputStream { + + private final Cleanable cleanup; + + private volatile boolean closed; + + JarEntryInflaterInputStream(JarEntryInputStream inputStream, NestedJarFileResources resources) { + this(inputStream, resources, resources.getOrCreateInflater()); + } + + private JarEntryInflaterInputStream(JarEntryInputStream inputStream, NestedJarFileResources resources, + Inflater inflater) { + super(inputStream, inflater, inputStream.getUncompressedSize()); + this.cleanup = NestedJarFile.this.cleaner.register(this, resources.createInflatorCleanupAction(inflater)); + } + + @Override + public void close() throws IOException { + if (this.closed) { + return; + } + this.closed = true; + super.close(); + NestedJarFile.this.resources.removeInputStream(this); + this.cleanup.clean(); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/NestedJarFileResources.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/NestedJarFileResources.java new file mode 100644 index 0000000000..4f57e03497 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/NestedJarFileResources.java @@ -0,0 +1,206 @@ +/* + * 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.io.UncheckedIOException; +import java.util.ArrayDeque; +import java.util.Collections; +import java.util.Deque; +import java.util.List; +import java.util.Set; +import java.util.WeakHashMap; +import java.util.zip.Inflater; + +import org.springframework.boot.loader.ref.Cleaner; +import org.springframework.boot.loader.zip.ZipContent; + +/** + * Resources created managed and cleaned by a {@link NestedJarFile} instance and suitable + * for registration with a {@link Cleaner}. + * + * @author Phillip Webb + */ +class NestedJarFileResources implements Runnable { + + private static final int INFLATER_CACHE_LIMIT = 20; + + private ZipContent zipContent; + + private final Set inputStreams = Collections.newSetFromMap(new WeakHashMap<>()); + + private Deque inflaterCache = new ArrayDeque<>(); + + /** + * Create a new {@link NestedJarFileResources} instance. + * @param file the source zip file + * @param nestedEntryName the nested entry or {@code null} + * @throws IOException on I/O error + */ + NestedJarFileResources(File file, String nestedEntryName) throws IOException { + this.zipContent = ZipContent.open(file.toPath(), nestedEntryName); + } + + /** + * Return the underling {@link ZipContent}. + * @return the zip content + */ + ZipContent zipContent() { + return this.zipContent; + } + + /** + * Add a managed input stream resource. + * @param inputStream the input stream + */ + void addInputStream(InputStream inputStream) { + synchronized (this.inputStreams) { + this.inputStreams.add(inputStream); + } + } + + /** + * Remove a managed input stream resource. + * @param inputStream the input stream + */ + void removeInputStream(InputStream inputStream) { + synchronized (this.inputStreams) { + this.inputStreams.remove(inputStream); + } + } + + /** + * Create a {@link Runnable} action to cleanup the given inflater. + * @param inflater the inflater to cleanup + * @return the cleanup action + */ + Runnable createInflatorCleanupAction(Inflater inflater) { + return () -> endOrCacheInflater(inflater); + } + + /** + * Get previously used {@link Inflater} from the cache, or create a new one. + * @return a usable {@link Inflater} + */ + Inflater getOrCreateInflater() { + Deque inflaterCache = this.inflaterCache; + if (inflaterCache != null) { + synchronized (inflaterCache) { + Inflater inflater = this.inflaterCache.poll(); + if (inflater != null) { + return inflater; + } + } + } + return new Inflater(true); + } + + /** + * Either release the given {@link Inflater} by calling {@link Inflater#end()} or add + * it to the cache for later reuse. + * @param inflater the inflater to end or cache + */ + private void endOrCacheInflater(Inflater inflater) { + Deque inflaterCache = this.inflaterCache; + if (inflaterCache != null) { + synchronized (inflaterCache) { + if (this.inflaterCache == inflaterCache && inflaterCache.size() < INFLATER_CACHE_LIMIT) { + inflater.reset(); + this.inflaterCache.add(inflater); + return; + } + } + } + inflater.end(); + } + + /** + * Called by the {@link Cleaner} to free resources. + * @see java.lang.Runnable#run() + */ + @Override + public void run() { + releaseAll(); + } + + private void releaseAll() { + IOException exceptionChain = null; + exceptionChain = releaseInflators(exceptionChain); + exceptionChain = releaseInputStreams(exceptionChain); + exceptionChain = releaseZipContent(exceptionChain); + if (exceptionChain != null) { + throw new UncheckedIOException(exceptionChain); + } + } + + private IOException releaseInflators(IOException exceptionChain) { + Deque inflaterCache = this.inflaterCache; + if (inflaterCache != null) { + try { + synchronized (inflaterCache) { + inflaterCache.forEach(Inflater::end); + } + } + finally { + this.inflaterCache = null; + } + } + return exceptionChain; + } + + private IOException releaseInputStreams(IOException exceptionChain) { + synchronized (this.inputStreams) { + for (InputStream inputStream : List.copyOf(this.inputStreams)) { + try { + inputStream.close(); + } + catch (IOException ex) { + exceptionChain = addToExceptionChain(exceptionChain, ex); + } + } + this.inputStreams.clear(); + } + return exceptionChain; + } + + private IOException releaseZipContent(IOException exceptionChain) { + ZipContent zipContent = this.zipContent; + if (zipContent != null) { + try { + zipContent.close(); + } + catch (IOException ex) { + exceptionChain = addToExceptionChain(exceptionChain, ex); + } + finally { + this.zipContent = null; + } + } + return exceptionChain; + } + + private IOException addToExceptionChain(IOException exceptionChain, IOException ex) { + if (exceptionChain != null) { + exceptionChain.addSuppressed(ex); + return exceptionChain; + } + return ex; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/SecurityInfo.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/SecurityInfo.java new file mode 100644 index 0000000000..3b20bebdbe --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/SecurityInfo.java @@ -0,0 +1,110 @@ +/* + * 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.UncheckedIOException; +import java.security.CodeSigner; +import java.security.cert.Certificate; +import java.util.jar.JarEntry; +import java.util.jar.JarInputStream; + +import org.springframework.boot.loader.zip.ZipContent; + +/** + * Security information ({@link Certificate} and {@link CodeSigner} details) for entries + * in the jar. + * + * @author Phillip Webb + */ +final class SecurityInfo { + + static final SecurityInfo NONE = new SecurityInfo(null, null); + + private final Certificate[][] certificateLookups; + + private final CodeSigner[][] codeSignerLookups; + + private SecurityInfo(Certificate[][] entryCertificates, CodeSigner[][] entryCodeSigners) { + this.certificateLookups = entryCertificates; + this.codeSignerLookups = entryCodeSigners; + } + + Certificate[] getCertificates(ZipContent.Entry contentEntry) { + return (this.certificateLookups != null) ? clone(this.certificateLookups[contentEntry.getLookupIndex()]) : null; + } + + CodeSigner[] getCodeSigners(ZipContent.Entry contentEntry) { + return (this.codeSignerLookups != null) ? clone(this.codeSignerLookups[contentEntry.getLookupIndex()]) : null; + } + + private T[] clone(T[] array) { + return (array != null) ? array.clone() : null; + } + + /** + * Get the {@link SecurityInfo} for the given {@link ZipContent}. + * @param content the zip content + * @return the security info + */ + static SecurityInfo get(ZipContent content) { + if (!content.hasJarSignatureFile()) { + return NONE; + } + try { + return load(content); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + /** + * Load security info from the jar file. We need to use {@link JarInputStream} to + * obtain the security info since we don't have an actual real file to read. This + * isn't that fast, but hopefully doesn't happen too often and the result is cached. + * @param content the zip content + * @return the security info + * @throws IOException on I/O error + */ + private static SecurityInfo load(ZipContent content) throws IOException { + int size = content.size(); + boolean hasSecurityInfo = false; + Certificate[][] entryCertificates = new Certificate[size][]; + CodeSigner[][] entryCodeSigners = new CodeSigner[size][]; + try (JarInputStream in = new JarInputStream(content.openRawZipData().asInputStream())) { + JarEntry jarEntry = in.getNextJarEntry(); + while (jarEntry != null) { + in.closeEntry(); // Close to trigger a read and set certs/signers + Certificate[] certificates = jarEntry.getCertificates(); + CodeSigner[] codeSigners = jarEntry.getCodeSigners(); + if (certificates != null || codeSigners != null) { + ZipContent.Entry contentEntry = content.getEntry(jarEntry.getName()); + if (contentEntry != null) { + hasSecurityInfo = true; + entryCertificates[contentEntry.getLookupIndex()] = certificates; + entryCodeSigners[contentEntry.getLookupIndex()] = codeSigners; + } + } + jarEntry = in.getNextJarEntry(); + } + return (!hasSecurityInfo) ? NONE : new SecurityInfo(entryCertificates, entryCodeSigners); + } + + } + +} 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 deleted file mode 100644 index 12850a4ebe..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/StringSequence.java +++ /dev/null @@ -1,157 +0,0 @@ -/* - * 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 index 67624460cc..1528f0b9c5 100644 --- 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 @@ -24,27 +24,32 @@ 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. + * is required when using an {@link Inflater} with {@code nowrap}) and returns accurate + * available() results. * * @author Phillip Webb */ -class ZipInflaterInputStream extends InflaterInputStream { +abstract class ZipInflaterInputStream extends InflaterInputStream { private int available; private boolean extraBytesWritten; - ZipInflaterInputStream(InputStream inputStream, int size) { - super(inputStream, new Inflater(true), getInflaterBufferSize(size)); + ZipInflaterInputStream(InputStream inputStream, Inflater inflater, int size) { + super(inputStream, inflater, getInflaterBufferSize(size)); this.available = size; } + 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; + } + @Override public int available() throws IOException { - if (this.available < 0) { - return super.available(); - } - return this.available; + return (this.available >= 0) ? this.available : super.available(); } @Override @@ -56,12 +61,6 @@ class ZipInflaterInputStream extends InflaterInputStream { return result; } - @Override - public void close() throws IOException { - super.close(); - this.inf.end(); - } - @Override protected void fill() throws IOException { try { @@ -78,11 +77,4 @@ class ZipInflaterInputStream extends InflaterInputStream { } } - 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 index 638afe45f4..ae1ba30639 100644 --- 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 @@ -15,6 +15,7 @@ */ /** - * Support for loading and manipulating JAR/WAR files. + * Alternative {@link java.util.jar.JarFile} implementation with support for nested jars. + * @see org.springframework.boot.loader.jar.NestedJarFile */ 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/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/package-info.java index 2f3b5a74e8..d68ef83474 100644 --- 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 @@ -16,7 +16,5 @@ /** * 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/Archive.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/Archive.java new file mode 100644 index 0000000000..933a630ffb --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/Archive.java @@ -0,0 +1,150 @@ +/* + * 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; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.net.URL; +import java.security.CodeSource; +import java.security.ProtectionDomain; +import java.util.Set; +import java.util.function.Predicate; +import java.util.jar.Manifest; + +/** + * An archive that can be launched by the {@link Launcher}. + * + * @author Phillip Webb + * @since 3.2.0 + */ +public interface Archive extends AutoCloseable { + + /** + * Predicate that accepts all entries. + */ + Predicate ALL_ENTRIES = (entry) -> true; + + /** + * Returns the manifest of the archive. + * @return the manifest or {@code null} + * @throws IOException if the manifest cannot be read + */ + Manifest getManifest() throws IOException; + + /** + * Returns classpath URLs for the archive that match the specified filter. + * @param includeFilter filter used to determine which entries should be included. + * @return the classpath URLs + * @throws IOException on IO error + */ + default Set getClassPathUrls(Predicate includeFilter) throws IOException { + return getClassPathUrls(includeFilter, ALL_ENTRIES); + + } + + /** + * Returns classpath URLs for the archive that match the specified filters. + * @param includeFilter filter used to determine which entries should be included + * @param directorySearchFilter filter used to optimize tree walking for exploded + * archives by determining if a directory needs to be searched or not + * @return the classpath URLs + * @throws IOException on IO error + */ + Set getClassPathUrls(Predicate includeFilter, Predicate directorySearchFilter) + throws IOException; + + /** + * Returns if this archive is backed by an exploded archive directory. + * @return if the archive is exploded + */ + default boolean isExploded() { + return getRootDirectory() != null; + } + + /** + * Returns the root directory of this archive or {@code null} if the archive is not + * backed by a directory. + * @return the root directory + */ + default File getRootDirectory() { + return null; + } + + /** + * Closes the {@code Archive}, releasing any open resources. + * @throws Exception if an error occurs during close processing + */ + @Override + default void close() throws Exception { + } + + /** + * Factory method to create an appropriate {@link Archive} from the given + * {@link Class} target. + * @param target a target class that will be used to find the archive code source + * @return an new {@link Archive} instance + * @throws Exception if the archive cannot be created + */ + static Archive create(Class target) throws Exception { + return create(target.getProtectionDomain()); + } + + static Archive create(ProtectionDomain protectionDomain) throws Exception { + 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"); + } + return create(new File(path)); + } + + /** + * Factory method to create an {@link Archive} from the given {@link File} target. + * @param target a target {@link File} used to create the archive. May be a directory + * or a jar file. + * @return a new {@link Archive} instance. + * @throws Exception if the archive cannot be created + */ + static Archive create(File target) throws Exception { + if (!target.exists()) { + throw new IllegalStateException("Unable to determine code source archive from " + target); + } + return (target.isDirectory() ? new ExplodedArchive(target) : new JarFileArchive(target)); + } + + /** + * Represents a single entry in the archive. + */ + interface Entry { + + /** + * Returns the name of the entry. + * @return the name of the entry + */ + String name(); + + /** + * Returns {@code true} if the entry represents a directory. + * @return if the entry is a directory + */ + boolean isDirectory(); + + } + +} 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/launch/ClassPathIndexFile.java similarity index 54% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ClassPathIndexFile.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/ClassPathIndexFile.java index 5ad01e5071..dcc4384099 100644 --- 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/launch/ClassPathIndexFile.java @@ -14,24 +14,20 @@ * limitations under the License. */ -package org.springframework.boot.loader; +package org.springframework.boot.loader.launch; -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.nio.file.Files; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; /** - * A class path index file that provides ordering information for JARs. + * A class path index file that provides an ordered classpath for exploded JARs. * * @author Madhura Bhave * @author Phillip Webb @@ -40,11 +36,11 @@ final class ClassPathIndexFile { private final File root; - private final List lines; + private final Set lines; private ClassPathIndexFile(File root, List lines) { this.root = root; - this.lines = lines.stream().map(this::extractName).toList(); + this.lines = lines.stream().map(this::extractName).collect(Collectors.toCollection(LinkedHashSet::new)); } private String extractName(String line) { @@ -78,46 +74,23 @@ final class ClassPathIndexFile { } } - static ClassPathIndexFile loadIfPossible(URL root, String location) throws IOException { - return loadIfPossible(asFile(root), location); - } - - private static ClassPathIndexFile loadIfPossible(File root, String location) throws IOException { + 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)); - } + List lines = Files.readAllLines(indexFile.toPath()) + .stream() + .filter(ClassPathIndexFile::lineHasText) + .toList(); + return new ClassPathIndexFile(root, lines); } 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()); - } + private static boolean lineHasText(String line) { + return !line.trim().isEmpty(); } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/ExecutableArchiveLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/ExecutableArchiveLauncher.java new file mode 100644 index 0000000000..fd6bd8cf52 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/ExecutableArchiveLauncher.java @@ -0,0 +1,136 @@ +/* + * 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; + +import java.io.IOException; +import java.net.URL; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Set; +import java.util.jar.Attributes; +import java.util.jar.Manifest; + +import org.springframework.boot.loader.launch.Archive.Entry; + +/** + * Base class for a {@link Launcher} backed by an executable archive. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Madhura Bhave + * @author Scott Frederick + * @since 3.2.0 + * @see JarLauncher + * @see WarLauncher + */ +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() throws Exception { + this(Archive.create(Launcher.class)); + } + + protected ExecutableArchiveLauncher(Archive archive) throws Exception { + this.archive = archive; + this.classPathIndex = getClassPathIndex(this.archive); + } + + ClassPathIndexFile getClassPathIndex(Archive archive) throws IOException { + if (!archive.isExploded()) { + return null; // Regular archives already have a defined order + } + String location = getClassPathIndexFileLocation(archive); + return ClassPathIndexFile.loadIfPossible(archive.getRootDirectory(), location); + } + + 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 : getEntryPathPrefix() + DEFAULT_CLASSPATH_INDEX_FILE_NAME; + } + + @Override + protected ClassLoader createClassLoader(Collection urls) throws Exception { + if (this.classPathIndex != null) { + urls = new ArrayList<>(urls); + urls.addAll(this.classPathIndex.getUrls()); + } + return super.createClassLoader(urls); + } + + @Override + protected final Archive getArchive() { + return this.archive; + } + + @Override + protected String getMainClass() throws Exception { + Manifest manifest = this.archive.getManifest(); + String mainClass = (manifest != null) ? manifest.getMainAttributes().getValue(START_CLASS_ATTRIBUTE) : null; + if (mainClass == null) { + throw new IllegalStateException("No 'Start-Class' manifest entry specified in " + this); + } + return mainClass; + } + + @Override + protected Set getClassPathUrls() throws Exception { + return this.archive.getClassPathUrls(this::isIncludedOnClassPathAndNotIndexed, this::isSearchedDirectory); + } + + private boolean isIncludedOnClassPathAndNotIndexed(Entry entry) { + if (!isIncludedOnClassPath(entry)) { + return false; + } + return (this.classPathIndex == null) || !this.classPathIndex.containsEntry(entry.name()); + } + + /** + * Determine if the specified directory 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 + */ + protected boolean isSearchedDirectory(Archive.Entry entry) { + return ((getEntryPathPrefix() == null) || entry.name().startsWith(getEntryPathPrefix())) + && !isIncludedOnClassPath(entry); + } + + /** + * 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 isIncludedOnClassPath(Archive.Entry entry); + + /** + * Return the path prefix for relevant entries in the archive. + * @return the entry path prefix + */ + protected abstract String getEntryPathPrefix(); + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/ExplodedArchive.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/ExplodedArchive.java new file mode 100644 index 0000000000..79cb729f60 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/ExplodedArchive.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.launch; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.net.URL; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; +import java.util.function.Predicate; +import java.util.jar.Manifest; + +/** + * {@link Archive} implementation backed by an exploded archive directory. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Madhura Bhave + */ +class ExplodedArchive implements Archive { + + private static final Object NO_MANIFEST = new Object(); + + private static final Set SKIPPED_NAMES = Set.of(".", ".."); + + private static final Comparator entryComparator = Comparator.comparing(File::getAbsolutePath); + + private final File rootDirectory; + + private final String rootUriPath; + + private volatile Object manifest; + + /** + * Create a new {@link ExplodedArchive} instance. + * @param rootDirectory the root directory + */ + ExplodedArchive(File rootDirectory) { + if (!rootDirectory.exists() || !rootDirectory.isDirectory()) { + throw new IllegalArgumentException("Invalid source directory " + rootDirectory); + } + this.rootDirectory = rootDirectory; + this.rootUriPath = ExplodedArchive.this.rootDirectory.toURI().getPath(); + } + + @Override + public Manifest getManifest() throws IOException { + Object manifest = this.manifest; + if (manifest == null) { + manifest = loadManifest(); + this.manifest = manifest; + } + return (manifest != NO_MANIFEST) ? (Manifest) manifest : null; + } + + private Object loadManifest() throws IOException { + File file = new File(this.rootDirectory, "META-INF/MANIFEST.MF"); + if (!file.exists()) { + return NO_MANIFEST; + } + try (FileInputStream inputStream = new FileInputStream(file)) { + return new Manifest(inputStream); + } + } + + @Override + public Set getClassPathUrls(Predicate includeFilter, Predicate directorySearchFilter) + throws IOException { + Set urls = new LinkedHashSet<>(); + LinkedList files = new LinkedList<>(listFiles(this.rootDirectory)); + while (!files.isEmpty()) { + File file = files.poll(); + if (SKIPPED_NAMES.contains(file.getName())) { + continue; + } + String entryName = file.toURI().getPath().substring(this.rootUriPath.length()); + Entry entry = new FileArchiveEntry(entryName, file); + if (entry.isDirectory() && directorySearchFilter.test(entry)) { + files.addAll(0, listFiles(file)); + } + if (includeFilter.test(entry)) { + urls.add(file.toURI().toURL()); + } + } + return urls; + } + + private List listFiles(File file) { + File[] files = file.listFiles(); + if (files == null) { + return Collections.emptyList(); + } + Arrays.sort(files, entryComparator); + return Arrays.asList(files); + } + + @Override + public File getRootDirectory() { + return this.rootDirectory; + } + + @Override + public String toString() { + return this.rootDirectory.toString(); + } + + /** + * {@link Entry} backed by a File. + */ + private record FileArchiveEntry(String name, File file) implements Entry { + + @Override + public boolean isDirectory() { + return this.file.isDirectory(); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/JarFileArchive.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/JarFileArchive.java new file mode 100755 index 0000000000..3ccb32009f --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/JarFileArchive.java @@ -0,0 +1,205 @@ +/* + * 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; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +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.StandardCopyOption; +import java.nio.file.attribute.FileAttribute; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.UUID; +import java.util.function.Predicate; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.Manifest; +import java.util.stream.Collectors; + +import org.springframework.boot.loader.net.protocol.jar.JarUrl; + +/** + * {@link Archive} implementation backed by a {@link JarFile}. + * + * @author Phillip Webb + * @author Andy Wilkinson + */ +class JarFileArchive implements Archive { + + private static final String UNPACK_MARKER = "UNPACK:"; + + private static final FileAttribute[] NO_FILE_ATTRIBUTES = {}; + + private static final FileAttribute[] DIRECTORY_PERMISSION_ATTRIBUTES = asFileAttributes( + PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE); + + private static final FileAttribute[] FILE_PERMISSION_ATTRIBUTES = asFileAttributes( + PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE); + + private static final Path TEMP = Paths.get(System.getProperty("java.io.tmpdir")); + + private final File file; + + private final JarFile jarFile; + + private volatile Path tempUnpackDirectory; + + JarFileArchive(File file) throws IOException { + this(file, new JarFile(file)); + } + + private JarFileArchive(File file, JarFile jarFile) { + this.file = file; + this.jarFile = jarFile; + } + + @Override + public Manifest getManifest() throws IOException { + return this.jarFile.getManifest(); + } + + @Override + public Set getClassPathUrls(Predicate includeFilter, Predicate directorySearchFilter) + throws IOException { + return this.jarFile.stream() + .map(JarArchiveEntry::new) + .filter(includeFilter) + .map(this::getNestedJarUrl) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + private URL getNestedJarUrl(JarArchiveEntry archiveEntry) { + try { + JarEntry jarEntry = archiveEntry.jarEntry(); + String comment = jarEntry.getComment(); + if (comment != null && comment.startsWith(UNPACK_MARKER)) { + return getUnpackedNestedJarUrl(jarEntry); + } + return JarUrl.create(this.file, jarEntry); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + private URL getUnpackedNestedJarUrl(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 JarUrl.create(path.toFile()); + } + + private Path getTempUnpackDirectory() { + Path tempUnpackDirectory = this.tempUnpackDirectory; + if (tempUnpackDirectory != null) { + return tempUnpackDirectory; + } + synchronized (TEMP) { + tempUnpackDirectory = this.tempUnpackDirectory; + if (tempUnpackDirectory == null) { + tempUnpackDirectory = createUnpackDirectory(TEMP); + this.tempUnpackDirectory = tempUnpackDirectory; + } + } + return tempUnpackDirectory; + } + + private Path createUnpackDirectory(Path parent) { + int attempts = 0; + String fileName = Paths.get(this.jarFile.getName()).getFileName().toString(); + while (attempts++ < 100) { + Path unpackDirectory = parent.resolve(fileName + "-spring-boot-libs-" + UUID.randomUUID()); + try { + createDirectory(unpackDirectory); + return unpackDirectory; + } + catch (IOException ex) { + // Ignore + } + } + throw new IllegalStateException("Failed to create unpack directory in directory '" + parent + "'"); + } + + private void createDirectory(Path path) throws IOException { + Files.createDirectory(path, getFileAttributes(path, DIRECTORY_PERMISSION_ATTRIBUTES)); + } + + private void unpack(JarEntry entry, Path path) throws IOException { + createFile(path); + path.toFile().deleteOnExit(); + try (InputStream in = this.jarFile.getInputStream(entry)) { + Files.copy(in, path, StandardCopyOption.REPLACE_EXISTING); + } + } + + private void createFile(Path path) throws IOException { + Files.createFile(path, getFileAttributes(path, FILE_PERMISSION_ATTRIBUTES)); + } + + private FileAttribute[] getFileAttributes(Path path, FileAttribute[] permissionAttributes) { + return (!supportsPosix(path.getFileSystem())) ? NO_FILE_ATTRIBUTES : permissionAttributes; + } + + private boolean supportsPosix(FileSystem fileSystem) { + return fileSystem.supportedFileAttributeViews().contains("posix"); + } + + @Override + public void close() throws IOException { + this.jarFile.close(); + } + + @Override + public String toString() { + return this.file.toString(); + } + + private static FileAttribute[] asFileAttributes(PosixFilePermission... permissions) { + return new FileAttribute[] { PosixFilePermissions.asFileAttribute(Set.of(permissions)) }; + } + + /** + * {@link Entry} implementation backed by a {@link JarEntry}. + */ + private record JarArchiveEntry(JarEntry jarEntry) implements Entry { + + @Override + public String name() { + return this.jarEntry.getName(); + } + + @Override + public boolean isDirectory() { + return this.jarEntry.isDirectory(); + } + + } + +} 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 index 5beb8d1096..ecabbc1fdf 100644 --- 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 @@ -17,18 +17,41 @@ package org.springframework.boot.loader.launch; /** - * Repackaged {@link org.springframework.boot.loader.JarLauncher}. + * {@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 3.2.0 */ -public final class JarLauncher { +public class JarLauncher extends ExecutableArchiveLauncher { - private JarLauncher() { + public JarLauncher() throws Exception { + } + + protected JarLauncher(Archive archive) throws Exception { + super(archive); + } + + @Override + protected boolean isIncludedOnClassPath(Archive.Entry entry) { + String name = entry.name(); + if (entry.isDirectory()) { + return name.equals("BOOT-INF/classes/"); + } + return name.startsWith("BOOT-INF/lib/"); + } + + @Override + protected String getEntryPathPrefix() { + return "BOOT-INF/"; } public static void main(String[] args) throws Exception { - org.springframework.boot.loader.JarLauncher.main(args); + new JarLauncher().launch(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/launch/JarModeRunner.java similarity index 77% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/JarModeLauncher.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/JarModeRunner.java index 44fcb7902e..4805a633d4 100644 --- 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/launch/JarModeRunner.java @@ -14,27 +14,27 @@ * limitations under the License. */ -package org.springframework.boot.loader.jarmode; +package org.springframework.boot.loader.launch; import java.util.List; +import org.springframework.boot.loader.jarmode.JarMode; import org.springframework.core.io.support.SpringFactoriesLoader; import org.springframework.util.ClassUtils; /** - * Delegate class used to launch the fat jar in a specific mode. + * Delegate class used to run the nested jar in a specific mode. * * @author Phillip Webb - * @since 2.3.0 */ -public final class JarModeLauncher { +final class JarModeRunner { - static final String DISABLE_SYSTEM_EXIT = JarModeLauncher.class.getName() + ".DISABLE_SYSTEM_EXIT"; + static final String DISABLE_SYSTEM_EXIT = JarModeRunner.class.getName() + ".DISABLE_SYSTEM_EXIT"; - private JarModeLauncher() { + private JarModeRunner() { } - public static void main(String[] args) { + static void main(String[] args) { String mode = System.getProperty("jarmode"); List candidates = SpringFactoriesLoader.loadFactories(JarMode.class, ClassUtils.getDefaultClassLoader()); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/LaunchedClassLoader.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/LaunchedClassLoader.java new file mode 100644 index 0000000000..c604df0487 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/LaunchedClassLoader.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.launch; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.util.function.Supplier; +import java.util.jar.Manifest; + +import org.springframework.boot.loader.net.protocol.jar.JarUrlClassLoader; + +/** + * {@link ClassLoader} used by the {@link Launcher}. + * + * @author Phillip Webb + * @author Dave Syer + * @author Andy Wilkinson + * @since 3.2.0 + */ +public class LaunchedClassLoader extends JarUrlClassLoader { + + private static final String JAR_MODE_PACKAGE_PREFIX = "org.springframework.boot.loader.jarmode."; + + private static final String JAR_MODE_RUNNER_CLASS_NAME = JarModeRunner.class.getName(); + + static { + ClassLoader.registerAsParallelCapable(); + } + + private final boolean exploded; + + private final Archive rootArchive; + + private final Object definePackageLock = new Object(); + + private volatile DefinePackageCallType definePackageCallType; + + /** + * Create a new {@link LaunchedClassLoader} 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 LaunchedClassLoader(boolean exploded, URL[] urls, ClassLoader parent) { + this(exploded, null, urls, parent); + } + + /** + * Create a new {@link LaunchedClassLoader} 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 + */ + public LaunchedClassLoader(boolean exploded, Archive rootArchive, URL[] urls, ClassLoader parent) { + super(urls, parent); + this.exploded = exploded; + this.rootArchive = rootArchive; + } + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (name.startsWith(JAR_MODE_PACKAGE_PREFIX) || name.equals(JAR_MODE_RUNNER_CLASS_NAME)) { + try { + Class result = loadClassInLaunchedClassLoader(name); + if (resolve) { + resolveClass(result); + } + return result; + } + catch (ClassNotFoundException ex) { + // Ignore + } + } + return super.loadClass(name, resolve); + } + + private Class loadClassInLaunchedClassLoader(String name) throws ClassNotFoundException { + try { + String internalName = name.replace('.', '/') + ".class"; + try (InputStream inputStream = getParent().getResourceAsStream(internalName); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + if (inputStream == null) { + throw new ClassNotFoundException(name); + } + inputStream.transferTo(outputStream); + byte[] bytes = outputStream.toByteArray(); + Class definedClass = defineClass(name, bytes, 0, bytes.length); + definePackageIfNecessary(name); + return definedClass; + } + } + catch (IOException ex) { + throw new ClassNotFoundException("Cannot load resource for class [" + name + "]", ex); + } + } + + @Override + protected Package definePackage(String name, Manifest man, URL url) throws IllegalArgumentException { + return (!this.exploded) ? super.definePackage(name, man, url) : definePackageForExploded(name, man, url); + } + + private Package definePackageForExploded(String name, Manifest man, URL url) { + synchronized (this.definePackageLock) { + return definePackage(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); + } + return definePackageForExploded(name, sealBase, () -> super.definePackage(name, specTitle, specVersion, + specVendor, implTitle, implVersion, implVendor, sealBase)); + } + + private Package definePackageForExploded(String name, URL sealBase, Supplier call) { + synchronized (this.definePackageLock) { + 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 definePackage(DefinePackageCallType.ATTRIBUTES, call); + } + } + + private T definePackage(DefinePackageCallType type, Supplier call) { + DefinePackageCallType existingType = this.definePackageCallType; + try { + this.definePackageCallType = type; + return call.get(); + } + finally { + this.definePackageCallType = existingType; + } + } + + private Manifest getManifest(Archive archive) { + try { + return (archive != null) ? archive.getManifest() : null; + } + catch (IOException ex) { + return null; + } + } + + /** + * 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/launch/Launcher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/Launcher.java new file mode 100644 index 0000000000..2cae9b06b9 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/Launcher.java @@ -0,0 +1,125 @@ +/* + * 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; + +import java.io.UncheckedIOException; +import java.lang.reflect.Method; +import java.net.URL; +import java.util.Collection; +import java.util.Set; + +import org.springframework.boot.loader.net.protocol.Handlers; + +/** + * Base class for launchers that can start an application with a fully configured + * classpath. + * + * @author Phillip Webb + * @author Dave Syer + * @since 3.2.0 + */ +public abstract class Launcher { + + private static final String JAR_MODE_RUNNER_CLASS_NAME = JarModeRunner.class.getName(); + + /** + * 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()) { + Handlers.register(); + } + try { + ClassLoader classLoader = createClassLoader(getClassPathUrls()); + String jarMode = System.getProperty("jarmode"); + String mainClassName = hasLength(jarMode) ? JAR_MODE_RUNNER_CLASS_NAME : getMainClass(); + launch(classLoader, mainClassName, args); + } + catch (UncheckedIOException ex) { + throw ex.getCause(); + } + } + + private boolean hasLength(String jarMode) { + return (jarMode != null) && !jarMode.isEmpty(); + } + + /** + * Create a classloader for the specified archives. + * @param urls the classpath URLs + * @return the classloader + * @throws Exception if the classloader cannot be created + */ + protected ClassLoader createClassLoader(Collection urls) throws Exception { + return createClassLoader(urls.toArray(new URL[0])); + } + + private ClassLoader createClassLoader(URL[] urls) { + ClassLoader parent = getClass().getClassLoader(); + return new LaunchedClassLoader(isExploded(), getArchive(), urls, parent); + } + + /** + * Launch the application given the archive file and a fully configured classloader. + * @param classLoader the classloader + * @param mainClassName the main class to run + * @param args the incoming arguments + * @throws Exception if the launch fails + */ + protected void launch(ClassLoader classLoader, String mainClassName, String[] args) throws Exception { + Thread.currentThread().setContextClassLoader(classLoader); + Class mainClass = Class.forName(mainClassName, false, classLoader); + Method mainMethod = mainClass.getDeclaredMethod("main", String[].class); + mainMethod.setAccessible(true); + mainMethod.invoke(null, new Object[] { args }); + } + + /** + * 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. + */ + protected boolean isExploded() { + Archive archive = getArchive(); + return (archive != null) && archive.isExploded(); + } + + /** + * Return the archive being launched or {@code null} if there is no archive. + * @return the launched archive + */ + protected abstract Archive getArchive(); + + /** + * 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 + */ + protected abstract Set getClassPathUrls() throws Exception; + +} 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 index d80fb0bb71..8b88484df4 100644 --- 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 @@ -16,19 +16,585 @@ package org.springframework.boot.loader.launch; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Constructor; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Properties; +import java.util.Set; +import java.util.function.Predicate; +import java.util.jar.Manifest; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.boot.loader.launch.Archive.Entry; +import org.springframework.boot.loader.log.DebugLogger; +import org.springframework.boot.loader.net.protocol.jar.JarUrl; + /** - * Repackaged {@link org.springframework.boot.loader.PropertiesLauncher}. + * {@link Launcher} for archives with user-configured classpath and main class through a + * properties file. + *

+ * 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=my} will look for + * {@code my.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 * @author Phillip Webb * @since 3.2.0 */ -public final class PropertiesLauncher { +public class PropertiesLauncher extends Launcher { - private PropertiesLauncher() { + /** + * 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 URL[] NO_URLS = new URL[0]; + + private static final Pattern WORD_SEPARATOR = Pattern.compile("\\W+"); + + private static final String NESTED_ARCHIVE_SEPARATOR = "!" + File.separator; + + private static final String JAR_FILE_PREFIX = "jar:file:"; + + private static final DebugLogger debug = DebugLogger.get(PropertiesLauncher.class); + + private final Archive archive; + + private final File homeDirectory; + + private final List paths; + + private final Properties properties = new Properties(); + + public PropertiesLauncher() throws Exception { + this.archive = Archive.create(Launcher.class); + this.homeDirectory = getHomeDirectory(); + initializeProperties(); + this.paths = getPaths(); + } + + protected File getHomeDirectory() throws Exception { + return new File(getPropertyWithDefault(HOME, "${user.dir}")); + } + + 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) { + String propertiesFile = name + ".properties"; + configs.add("file:" + this.homeDirectory + "/" + propertiesFile); + configs.add("classpath:" + propertiesFile); + configs.add("classpath:BOOT-INF/classes/" + propertiesFile); + } + } + for (String config : configs) { + try (InputStream resource = getResource(config)) { + if (resource == null) { + debug.log("Not found: %s", config); + continue; + } + debug.log("Found: %s", config); + loadResource(resource); + return; // Load the first one we find + } + } + } + + 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 InputStream getClasspathResource(String config) { + config = stripLeadingSlashes(config); + config = "/" + config; + debug.log("Trying classpath: %s", config); + return getClass().getResourceAsStream(config); + } + + private String handleUrl(String path) { + if (path.startsWith("jar:file:") || path.startsWith("file:")) { + path = URLDecoder.decode(path, StandardCharsets.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 getURLResource(String config) throws Exception { + URL url = new URL(config); + if (exists(url)) { + URLConnection connection = url.openConnection(); + try { + return connection.getInputStream(); + } + catch (IOException ex) { + disconnect(connection); + throw ex; + } + } + return null; + } + + private boolean exists(URL url) throws IOException { + 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; + } + if (responseCode == HttpURLConnection.HTTP_NOT_FOUND) { + return false; + } + } + return (connection.getContentLength() >= 0); + } + finally { + disconnect(connection); + } + } + + private void disconnect(URLConnection connection) { + if (connection instanceof HttpURLConnection httpConnection) { + httpConnection.disconnect(); + } + } + + private InputStream getFileResource(String config) throws Exception { + File file = new File(config); + debug.log("Trying file: %s", config); + return (!file.canRead()) ? null : new FileInputStream(file); + } + + private void loadResource(InputStream resource) throws Exception { + this.properties.load(resource); + resolvePropertyPlaceholders(); + if ("true".equalsIgnoreCase(getProperty(SET_SYSTEM_PROPERTIES))) { + addToSystemProperties(); + } + } + + private void resolvePropertyPlaceholders() { + for (String name : this.properties.stringPropertyNames()) { + String value = this.properties.getProperty(name); + String resolved = SystemPropertyUtils.resolvePlaceholders(this.properties, value); + if (resolved != null) { + this.properties.put(name, resolved); + } + } + } + + private void addToSystemProperties() { + debug.log("Adding resolved properties to System properties"); + for (String name : this.properties.stringPropertyNames()) { + String value = this.properties.getProperty(name); + System.setProperty(name, value); + } + } + + private List getPaths() throws Exception { + String path = getProperty(PATH); + List paths = (path != null) ? parsePathsProperty(path) : Collections.emptyList(); + debug.log("Nested archive paths: %s", this.paths); + return 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.isEmpty()) ? "/" : path; + paths.add(path); + } + if (paths.isEmpty()) { + paths.add("lib"); + } + return paths; + } + + private String cleanupPath(String path) { + path = path.trim(); + // No need for current dir path + if (path.startsWith("./")) { + path = path.substring(2); + } + if (isArchive(path)) { + return path; + } + if (path.endsWith("/*")) { + return path.substring(0, path.length() - 1); + } + // It's a directory + return (!path.endsWith("/") && !path.equals(".")) ? path + "/" : path; + } + + @Override + protected ClassLoader createClassLoader(Collection urls) throws Exception { + String loaderClassName = getProperty("loader.classLoader"); + if (loaderClassName == null) { + return super.createClassLoader(urls); + } + ClassLoader parent = getClass().getClassLoader(); + ClassLoader classLoader = new LaunchedClassLoader(false, urls.toArray(new URL[0]), parent); + debug.log("Classpath for custom loader: %s", urls); + classLoader = wrapWithCustomClassLoader(classLoader, loaderClassName); + debug.log("Using custom class loader: %s", loaderClassName); + return classLoader; + } + + private ClassLoader wrapWithCustomClassLoader(ClassLoader parent, String loaderClassName) throws Exception { + Instantiator instantiator = new Instantiator<>(parent, loaderClassName); + ClassLoader loader = instantiator.declaredConstructor(ClassLoader.class).newInstance(parent); + loader = (loader != null) ? loader + : instantiator.declaredConstructor(URL[].class, ClassLoader.class).newInstance(NO_URLS, parent); + loader = (loader != null) ? loader : instantiator.constructWithoutParameters(); + if (loader != null) { + return loader; + } + throw new IllegalStateException("Unable to create class loader for " + loaderClassName); + } + + @Override + protected Archive getArchive() { + return null; // We don't have a single archive and are not exploded. + } + + @Override + protected String getMainClass() throws Exception { + String mainClass = getProperty(MAIN, "Start-Class"); + if (mainClass == null) { + throw new IllegalStateException("No '%s' or 'Start-Class' specified".formatted(MAIN)); + } + return mainClass; + } + + protected String[] getArgs(String... args) throws Exception { + String loaderArgs = getProperty(ARGS); + return (loaderArgs != null) ? merge(loaderArgs.split("\\s+"), args) : args; + } + + private String[] merge(String[] a1, String[] a2) { + String[] result = new String[a1.length + a2.length]; + System.arraycopy(a1, 0, result, 0, a1.length); + System.arraycopy(a2, 0, result, a1.length, a2.length); + return result; + } + + private String getProperty(String name) throws Exception { + return getProperty(name, null, null); + } + + private String getProperty(String name, String manifestKey) throws Exception { + return getProperty(name, manifestKey, null); + } + + private String getPropertyWithDefault(String name, String defaultValue) throws Exception { + return getProperty(name, null, defaultValue); + } + + private String getProperty(String name, String manifestKey, String defaultValue) throws Exception { + manifestKey = (manifestKey != null) ? manifestKey : toCamelCase(name.replace('.', '-')); + String value = SystemPropertyUtils.getProperty(name); + if (value != null) { + return getResolvedProperty(name, manifestKey, value, "environment"); + } + if (this.properties.containsKey(name)) { + value = this.properties.getProperty(name); + return getResolvedProperty(name, manifestKey, value, "properties"); + } + // Prefer home dir for MANIFEST if there is one + if (this.homeDirectory != null) { + try { + try (ExplodedArchive explodedArchive = new ExplodedArchive(this.homeDirectory)) { + value = getManifestValue(explodedArchive, manifestKey); + if (value != null) { + return getResolvedProperty(name, manifestKey, value, "home directory manifest"); + } + } + } + catch (IllegalStateException ex) { + // Ignore + } + } + // Otherwise try the root archive + value = getManifestValue(this.archive, manifestKey); + if (value != null) { + return getResolvedProperty(name, manifestKey, value, "manifest"); + } + return SystemPropertyUtils.resolvePlaceholders(this.properties, defaultValue); + } + + String getManifestValue(Archive archive, String manifestKey) throws Exception { + Manifest manifest = archive.getManifest(); + return (manifest != null) ? manifest.getMainAttributes().getValue(manifestKey) : null; + } + + private String getResolvedProperty(String name, String manifestKey, String value, String from) { + value = SystemPropertyUtils.resolvePlaceholders(this.properties, value); + String altName = (manifestKey != null && !manifestKey.equals(name)) ? "[%s] ".formatted(manifestKey) : ""; + debug.log("Property '%s'%s from %s: %s", name, altName, from, value); + return value; + + } + + void close() throws Exception { + if (this.archive != null) { + this.archive.close(); + } + } + + public static String toCamelCase(CharSequence string) { + if (string == null) { + return null; + } + StringBuilder result = new StringBuilder(); + Matcher matcher = WORD_SEPARATOR.matcher(string); + int pos = 0; + while (matcher.find()) { + result.append(capitalize(string.subSequence(pos, matcher.end()).toString())); + pos = matcher.end(); + } + result.append(capitalize(string.subSequence(pos, string.length()).toString())); + return result.toString(); + } + + private static String capitalize(String str) { + return Character.toUpperCase(str.charAt(0)) + str.substring(1); + } + + @Override + protected Set getClassPathUrls() throws Exception { + Set urls = new LinkedHashSet<>(); + for (String path : getPaths()) { + path = cleanupPath(handleUrl(path)); + urls.addAll(getClassPathUrlsForPath(path)); + } + return urls; + } + + private Set getClassPathUrlsForPath(String path) throws Exception { + File file = (!isAbsolutePath(path)) ? new File(this.homeDirectory, path) : new File(path); + Set urls = new LinkedHashSet<>(); + if (!"/".equals(path)) { + if (file.isDirectory()) { + try (ExplodedArchive explodedArchive = new ExplodedArchive(file)) { + debug.log("Adding classpath entries from directory %s", file); + urls.add(file.toURI().toURL()); + urls.addAll(explodedArchive.getClassPathUrls(this::isArchive)); + } + } + } + if (!file.getPath().contains(NESTED_ARCHIVE_SEPARATOR) && isArchive(file.getName())) { + debug.log("Adding classpath entries from jar/zip archive %s", path); + urls.add(file.toURI().toURL()); + } + Set nested = getClassPathUrlsForNested(path); + if (!nested.isEmpty()) { + debug.log("Adding classpath entries from nested %s", path); + urls.addAll(nested); + } + return urls; + } + + private Set getClassPathUrlsForNested(String path) throws Exception { + boolean isJustArchive = isArchive(path); + if (!path.equals("/") && path.startsWith("/") + || (this.archive.isExploded() && this.archive.getRootDirectory().equals(this.homeDirectory))) { + return Collections.emptySet(); + } + File file = null; + if (isJustArchive) { + File candidate = new File(this.homeDirectory, path); + if (candidate.exists()) { + file = candidate; + path = ""; + } + } + int separatorIndex = path.indexOf('!'); + if (separatorIndex != -1) { + file = (!path.startsWith(JAR_FILE_PREFIX)) ? new File(this.homeDirectory, path.substring(0, separatorIndex)) + : new File(path.substring(JAR_FILE_PREFIX.length(), separatorIndex)); + path = path.substring(separatorIndex + 1); + path = stripLeadingSlashes(path); + } + if (path.equals("/") || path.equals("./") || path.equals(".")) { + // The prefix for nested jars is actually empty if it's at the root + path = ""; + } + Archive archive = (file != null) ? new JarFileArchive(file) : this.archive; + try { + Set urls = new LinkedHashSet<>(archive.getClassPathUrls(includeByPrefix(path))); + if (!isJustArchive && file != null && path.isEmpty()) { + urls.add(JarUrl.create(file)); + } + return urls; + } + finally { + if (archive != this.archive) { + archive.close(); + } + } + } + + private Predicate includeByPrefix(String prefix) { + return (entry) -> (entry.isDirectory() && entry.name().equals(prefix)) + || (isArchive(entry) && entry.name().startsWith(prefix)); + } + + private boolean isArchive(Entry entry) { + return isArchive(entry.name()); + } + + private boolean isArchive(String name) { + name = name.toLowerCase(Locale.ENGLISH); + return name.endsWith(".jar") || name.endsWith(".zip"); + } + + private boolean isAbsolutePath(String root) { + // Windows contains ":" others start with "/" + return root.contains(":") || root.startsWith("/"); + } + + private String stripLeadingSlashes(String string) { + while (string.startsWith("/")) { + string = string.substring(1); + } + return string; } public static void main(String[] args) throws Exception { - org.springframework.boot.loader.PropertiesLauncher.main(args); + PropertiesLauncher launcher = new PropertiesLauncher(); + args = launcher.getArgs(args); + launcher.launch(args); + } + + /** + * Utility to help instantiate objects. + */ + private record Instantiator(ClassLoader parent, Class type) { + + Instantiator(ClassLoader parent, String className) throws ClassNotFoundException { + this(parent, Class.forName(className, true, parent)); + } + + T constructWithoutParameters() throws Exception { + return declaredConstructor().newInstance(); + } + + Using declaredConstructor(Class... parameterTypes) { + return new Using<>(this, parameterTypes); + } + + private record Using(Instantiator instantiator, Class... parameterTypes) { + + @SuppressWarnings("unchecked") + T newInstance(Object... initargs) throws Exception { + try { + Constructor constructor = this.instantiator.type().getDeclaredConstructor(this.parameterTypes); + constructor.setAccessible(true); + return (T) constructor.newInstance(initargs); + } + catch (NoSuchMethodException ex) { + return null; + } + } + + } } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/SystemPropertyUtils.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/SystemPropertyUtils.java new file mode 100644 index 0000000000..5efb96f354 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/SystemPropertyUtils.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.launch; + +import java.util.HashSet; +import java.util.Locale; +import java.util.Properties; +import java.util.Set; + +/** + * Internal helper class adapted from Spring Framework for resolving placeholders in + * texts. + * + * @author Juergen Hoeller + * @author Rob Harrop + * @author Dave Syer + * @author Phillip Webb + */ +final class SystemPropertyUtils { + + private static final String PLACEHOLDER_PREFIX = "${"; + + private static final String PLACEHOLDER_SUFFIX = "}"; + + private static final String VALUE_SEPARATOR = ":"; + + private static final String SIMPLE_PREFIX = PLACEHOLDER_PREFIX.substring(1); + + private SystemPropertyUtils() { + } + + static String resolvePlaceholders(Properties properties, String text) { + return (text != null) ? parseStringValue(properties, text, text, new HashSet<>()) : null; + } + + private static String parseStringValue(Properties properties, String value, String current, + Set visitedPlaceholders) { + StringBuilder result = new StringBuilder(current); + int startIndex = current.indexOf(PLACEHOLDER_PREFIX); + while (startIndex != -1) { + int endIndex = findPlaceholderEndIndex(result, startIndex); + if (endIndex == -1) { + startIndex = -1; + continue; + } + String placeholder = result.substring(startIndex + PLACEHOLDER_PREFIX.length(), endIndex); + String originalPlaceholder = placeholder; + if (!visitedPlaceholders.add(originalPlaceholder)) { + throw new IllegalArgumentException( + "Circular placeholder reference '" + originalPlaceholder + "' in property definitions"); + } + placeholder = parseStringValue(properties, value, placeholder, visitedPlaceholders); + String propertyValue = resolvePlaceholder(properties, value, placeholder); + if (propertyValue == null) { + int separatorIndex = placeholder.indexOf(VALUE_SEPARATOR); + if (separatorIndex != -1) { + String actualPlaceholder = placeholder.substring(0, separatorIndex); + String defaultValue = placeholder.substring(separatorIndex + VALUE_SEPARATOR.length()); + propertyValue = resolvePlaceholder(properties, value, actualPlaceholder); + propertyValue = (propertyValue != null) ? propertyValue : defaultValue; + } + } + if (propertyValue != null) { + propertyValue = parseStringValue(properties, value, propertyValue, visitedPlaceholders); + result.replace(startIndex, endIndex + PLACEHOLDER_SUFFIX.length(), propertyValue); + startIndex = result.indexOf(PLACEHOLDER_PREFIX, startIndex + propertyValue.length()); + } + else { + startIndex = result.indexOf(PLACEHOLDER_PREFIX, endIndex + PLACEHOLDER_SUFFIX.length()); + } + visitedPlaceholders.remove(originalPlaceholder); + } + return result.toString(); + } + + private static String resolvePlaceholder(Properties properties, String text, String placeholderName) { + String propertyValue = getProperty(placeholderName, null, text); + if (propertyValue != null) { + return propertyValue; + } + return (properties != null) ? properties.getProperty(placeholderName) : null; + } + + static String getProperty(String key) { + return getProperty(key, null, ""); + } + + private static String getProperty(String key, String defaultValue, String text) { + try { + String value = System.getProperty(key); + value = (value != null) ? value : System.getenv(key); + value = (value != null) ? value : System.getenv(key.replace('.', '_')); + value = (value != null) ? value : System.getenv(key.toUpperCase(Locale.ENGLISH).replace('.', '_')); + return (value != null) ? value : defaultValue; + } + 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/launch/WarLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/launch/WarLauncher.java index 9392d3bf2b..a74e63c4ab 100644 --- 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 @@ -17,18 +17,40 @@ package org.springframework.boot.loader.launch; /** - * Repackaged {@link org.springframework.boot.loader.WarLauncher}. + * {@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 3.2.0 */ -public final class WarLauncher { +public class WarLauncher extends ExecutableArchiveLauncher { - private WarLauncher() { + public WarLauncher() throws Exception { + } + + protected WarLauncher(Archive archive) throws Exception { + super(archive); + } + + @Override + public boolean isIncludedOnClassPath(Archive.Entry entry) { + String name = entry.name(); + if (entry.isDirectory()) { + return name.equals("WEB-INF/classes/"); + } + return name.startsWith("WEB-INF/lib/") || name.startsWith("WEB-INF/lib-provided/"); + } + + @Override + protected String getEntryPathPrefix() { + return "WEB-INF/"; } public static void main(String[] args) throws Exception { - org.springframework.boot.loader.WarLauncher.main(args); + new WarLauncher().launch(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 index 7968d509a2..5c5115bf0e 100644 --- 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 @@ -15,7 +15,10 @@ */ /** - * Repackaged launcher classes. + * 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.launch.JarLauncher * @see org.springframework.boot.loader.launch.WarLauncher diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/log/DebugLogger.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/log/DebugLogger.java new file mode 100644 index 0000000000..417a9c5a4b --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/log/DebugLogger.java @@ -0,0 +1,152 @@ +/* + * 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.log; + +/** + * Simple logger class used for {@link System#err} debugging. + * + * @author Phillip Webb + * @since 3.2.0 + */ +public abstract sealed class DebugLogger { + + private static final String ENABLED_PROPERTY = "loader.debug"; + + private static final DebugLogger disabled; + static { + disabled = Boolean.getBoolean(ENABLED_PROPERTY) ? null : new DisabledDebugLogger(); + } + + /** + * Log a message. + * @param message the message to log + */ + public abstract void log(String message); + + /** + * Log a formatted message. + * @param message the message to log + * @param arg1 the first format argument + */ + public abstract void log(String message, Object arg1); + + /** + * Log a formatted message. + * @param message the message to log + * @param arg1 the first format argument + * @param arg2 the second format argument + */ + public abstract void log(String message, Object arg1, Object arg2); + + /** + * Log a formatted message. + * @param message the message to log + * @param arg1 the first format argument + * @param arg2 the second format argument + * @param arg3 the third format argument + */ + public abstract void log(String message, Object arg1, Object arg2, Object arg3); + + /** + * Log a formatted message. + * @param message the message to log + * @param arg1 the first format argument + * @param arg2 the second format argument + * @param arg3 the third format argument + * @param arg4 the fourth format argument + */ + public abstract void log(String message, Object arg1, Object arg2, Object arg3, Object arg4); + + /** + * Get a {@link DebugLogger} to log messages for the given source class. + * @param sourceClass the source class + * @return a {@link DebugLogger} instance + */ + public static DebugLogger get(Class sourceClass) { + return (disabled != null) ? disabled : new SystemErrDebugLogger(sourceClass); + } + + /** + * {@link DebugLogger} used for disabled logging that does nothing. + */ + private static final class DisabledDebugLogger extends DebugLogger { + + @Override + public void log(String message) { + } + + @Override + public void log(String message, Object arg1) { + } + + @Override + public void log(String message, Object arg1, Object arg2) { + } + + @Override + public void log(String message, Object arg1, Object arg2, Object arg3) { + } + + @Override + public void log(String message, Object arg1, Object arg2, Object arg3, Object arg4) { + } + + } + + /** + * {@link DebugLogger} that prints messages to {@link System#err}. + */ + private static final class SystemErrDebugLogger extends DebugLogger { + + private final String prefix; + + SystemErrDebugLogger(Class sourceClass) { + this.prefix = "LOADER: " + sourceClass + " : "; + } + + @Override + public void log(String message) { + print(message); + } + + @Override + public void log(String message, Object arg1) { + print(message.formatted(arg1)); + } + + @Override + public void log(String message, Object arg1, Object arg2) { + print(message.formatted(arg1, arg2)); + } + + @Override + public void log(String message, Object arg1, Object arg2, Object arg3) { + print(message.formatted(arg1, arg2, arg3)); + } + + @Override + public void log(String message, Object arg1, Object arg2, Object arg3, Object arg4) { + print(message.formatted(arg1, arg2, arg3, arg4)); + } + + private void print(String message) { + System.err.println(this.prefix + message); + } + + } + +} 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/log/package-info.java similarity index 77% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/data/package-info.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/log/package-info.java index 34bf2ead43..c94baf14b3 100644 --- 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/log/package-info.java @@ -15,8 +15,6 @@ */ /** - * Classes and interfaces to allow random access to a block of data. - * - * @see org.springframework.boot.loader.data.RandomAccessData + * Debug {@link java.lang.System#err} logging support. */ -package org.springframework.boot.loader.data; +package org.springframework.boot.loader.log; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/Handlers.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/Handlers.java new file mode 100644 index 0000000000..781daeaf0f --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/Handlers.java @@ -0,0 +1,63 @@ +/* + * 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.net.protocol; + +import java.net.URL; +import java.net.URLStreamHandler; +import java.net.URLStreamHandlerFactory; + +/** + * Utility used to register loader {@link URLStreamHandler URL handlers}. + * + * @author Phillip Webb + * @since 3.2.0 + */ +public final class Handlers { + + private static final String PROTOCOL_HANDLER_PACKAGES = "java.protocol.handler.pkgs"; + + private static final String PACKAGE = Handlers.class.getPackageName(); + + private Handlers() { + } + + /** + * Register a {@literal 'java.protocol.handler.pkgs'} property so that a + * {@link URLStreamHandler} will be located to deal with jar URLs. + */ + public static void register() { + String packages = System.getProperty(PROTOCOL_HANDLER_PACKAGES, ""); + packages = (!packages.isEmpty() && !packages.contains(PACKAGE)) ? packages + "|" + PACKAGE : PACKAGE; + System.setProperty(PROTOCOL_HANDLER_PACKAGES, packages); + 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 + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/Canonicalizer.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/Canonicalizer.java new file mode 100644 index 0000000000..f06227498f --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/Canonicalizer.java @@ -0,0 +1,84 @@ +/* + * 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.net.protocol.jar; + +/** + * Internal utility used by the {@link Handler} to canonicalize paths. This implementation + * should behave the same as the canonicalization functions in + * {@code sun.net.www.protocol.jar.Handler}. + * + * @author Phillip Webb + */ +final class Canonicalizer { + + private Canonicalizer() { + } + + static String canonicalizeAfter(String path, int pos) { + int pathLength = path.length(); + boolean noDotSlash = path.indexOf("./", pos) == -1; + if (pos >= pathLength || (noDotSlash && path.charAt(pathLength - 1) != '.')) { + return path; + } + String before = path.substring(0, pos); + String after = path.substring(pos); + return before + canonicalize(after); + } + + static String canonicalize(String path) { + path = removeEmbeddedSlashDotDotSlash(path); + path = removedEmbdeddedSlashDotSlash(path); + path = removeTrailingSlashDotDot(path); + path = removeTrailingSlashDot(path); + return path; + } + + private static String removeEmbeddedSlashDotDotSlash(String path) { + int index; + while ((index = path.indexOf("/../")) >= 0) { + int priorSlash = path.lastIndexOf('/', index - 1); + String after = path.substring(index + 3); + path = (priorSlash >= 0) ? path.substring(0, priorSlash) + after : after; + } + return path; + } + + private static String removedEmbdeddedSlashDotSlash(String path) { + int index; + while ((index = path.indexOf("/./")) >= 0) { + String before = path.substring(0, index); + String after = path.substring(index + 2); + path = before + after; + } + return path; + } + + private static String removeTrailingSlashDot(String path) { + return (!path.endsWith("/.")) ? path : path.substring(0, path.length() - 1); + } + + private static String removeTrailingSlashDotDot(String path) { + int index; + while (path.endsWith("/..")) { + index = path.indexOf("/.."); + int priorSlash = path.lastIndexOf('/', index - 1); + path = (priorSlash >= 0) ? path.substring(0, priorSlash + 1) : path.substring(0, index); + } + return path; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/Handler.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/Handler.java new file mode 100644 index 0000000000..2778beecca --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/Handler.java @@ -0,0 +1,190 @@ +/* + * 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.net.protocol.jar; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLStreamHandler; + +/** + * {@link URLStreamHandler} alternative to {@code sun.net.www.protocol.jar.Handler} with + * optimized support for nested jars. + * + * @author Phillip Webb + * @since 3.2.0 + * @see org.springframework.boot.loader.net.protocol.Handlers + */ +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 PROTOCOL = "jar"; + + private static final String SEPARATOR = "!/"; + + static final Handler INSTANCE = new Handler(); + + @Override + protected URLConnection openConnection(URL url) throws IOException { + return JarUrlConnection.open(url); + } + + @Override + protected void parseURL(URL url, String spec, int start, int limit) { + if (spec.regionMatches(true, start, "jar:", 0, 4)) { + throw new IllegalStateException("Nested JAR URLs are not supported"); + } + int anchorIndex = spec.indexOf('#', limit); + String path = extractPath(url, spec, start, limit, anchorIndex); + String ref = (anchorIndex != -1) ? spec.substring(anchorIndex + 1) : null; + setURL(url, PROTOCOL, "", -1, null, null, path, null, ref); + } + + private String extractPath(URL url, String spec, int start, int limit, int anchorIndex) { + if (anchorIndex == start) { + return extractAnchorOnlyPath(url); + } + if (spec.length() >= 4 && spec.regionMatches(true, 0, "jar:", 0, 4)) { + return extractAbsolutePath(spec, start, limit); + } + return extractRelativePath(url, spec, start, limit); + } + + private String extractAnchorOnlyPath(URL url) { + return url.getPath(); + } + + private String extractAbsolutePath(String spec, int start, int limit) { + int indexOfSeparator = indexOfSeparator(spec, start, limit); + if (indexOfSeparator == -1) { + throw new IllegalStateException("no !/ in spec"); + } + String innerUrl = spec.substring(start, indexOfSeparator); + assertInnerUrlIsNotMalformed(spec, innerUrl); + return spec.substring(start, limit); + } + + private String extractRelativePath(URL url, String spec, int start, int limit) { + String contextPath = extractContextPath(url, spec, start); + String path = contextPath + spec.substring(start, limit); + return Canonicalizer.canonicalizeAfter(path, indexOfSeparator(path) + 1); + } + + private String extractContextPath(URL url, String spec, int start) { + String contextPath = url.getPath(); + if (spec.charAt(start) == '/') { + int indexOfContextPathSeparator = indexOfSeparator(contextPath); + if (indexOfContextPathSeparator == -1) { + throw new IllegalStateException("malformed context url:%s: no !/".formatted(url)); + } + return contextPath.substring(0, indexOfContextPathSeparator + 1); + } + int lastSlash = contextPath.lastIndexOf('/'); + if (lastSlash == -1) { + throw new IllegalStateException("malformed context url:%s".formatted(url)); + } + return contextPath.substring(0, lastSlash + 1); + } + + private void assertInnerUrlIsNotMalformed(String spec, String innerUrl) { + if (innerUrl.startsWith("nested:")) { + org.springframework.boot.loader.net.protocol.nested.Handler.assertUrlIsNotMalformed(innerUrl); + return; + } + try { + new URL(innerUrl); + } + catch (MalformedURLException ex) { + throw new IllegalStateException("invalid url: %s (%s)".formatted(spec, ex)); + } + } + + @Override + protected int hashCode(URL url) { + String protocol = url.getProtocol(); + int hash = (protocol != null) ? protocol.hashCode() : 0; + String file = url.getFile(); + int indexOfSeparator = file.indexOf(SEPARATOR); + if (indexOfSeparator == -1) { + return hash + file.hashCode(); + } + String fileWithoutEntry = file.substring(0, indexOfSeparator); + try { + hash += new URL(fileWithoutEntry).hashCode(); + } + catch (MalformedURLException ex) { + hash += fileWithoutEntry.hashCode(); + } + String entry = file.substring(indexOfSeparator + 2); + return hash + entry.hashCode(); + } + + @Override + protected boolean sameFile(URL url1, URL url2) { + if (!url1.getProtocol().equals(PROTOCOL) || !url2.getProtocol().equals(PROTOCOL)) { + return false; + } + String file1 = url1.getFile(); + String file2 = url2.getFile(); + int indexOfSeparator1 = file1.indexOf(SEPARATOR); + int indexOfSeparator2 = file2.indexOf(SEPARATOR); + if (indexOfSeparator1 == -1 || indexOfSeparator2 == -1) { + return super.sameFile(url1, url2); + } + String entry1 = file1.substring(indexOfSeparator1 + 2); + String entry2 = file2.substring(indexOfSeparator2 + 2); + if (!entry1.equals(entry2)) { + return false; + } + try { + URL innerUrl1 = new URL(file1.substring(0, indexOfSeparator1)); + URL innerUrl2 = new URL(file2.substring(0, indexOfSeparator2)); + if (!super.sameFile(innerUrl1, innerUrl2)) { + return false; + } + } + catch (MalformedURLException unused) { + return super.sameFile(url1, url2); + } + return true; + } + + static int indexOfSeparator(String spec) { + return indexOfSeparator(spec, 0, spec.length()); + } + + static int indexOfSeparator(String spec, int start, int limit) { + for (int i = limit - 1; i >= start; i--) { + if (spec.charAt(i) == '!' && (i + 1) < limit && spec.charAt(i + 1) == '/') { + return i; + } + } + return -1; + } + + /** + * Clear any internal caches. + */ + public static void clearCache() { + JarFileUrlKey.clearCache(); + JarUrlConnection.clearCache(); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarFileUrlKey.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarFileUrlKey.java new file mode 100644 index 0000000000..e8ce0f503d --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarFileUrlKey.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.net.protocol.jar; + +import java.lang.ref.SoftReference; +import java.net.URL; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Utility to generate a string key from a jar file {@link URL} that can be used as a + * cache key. + * + * @author Phillip Webb + */ +final class JarFileUrlKey { + + private static volatile SoftReference> cache; + + private JarFileUrlKey() { + } + + /** + * Get the {@link JarFileUrlKey} for the given URL. + * @param url the source URL + * @return a {@link JarFileUrlKey} instance + */ + static String get(URL url) { + Map cache = (JarFileUrlKey.cache != null) ? JarFileUrlKey.cache.get() : null; + if (cache == null) { + cache = new ConcurrentHashMap<>(); + JarFileUrlKey.cache = new SoftReference<>(cache); + } + return cache.computeIfAbsent(url, JarFileUrlKey::create); + } + + private static String create(URL url) { + StringBuilder value = new StringBuilder(); + String protocol = url.getProtocol(); + String host = url.getHost(); + int port = (url.getPort() != -1) ? url.getPort() : url.getDefaultPort(); + String file = url.getFile(); + value.append(protocol.toLowerCase()); + value.append(":"); + if (host != null && !host.isEmpty()) { + value.append(host.toLowerCase()); + value.append((port != -1) ? ":" + port : ""); + } + value.append((file != null) ? file : ""); + if ("runtime".equals(url.getRef())) { + value.append("#runtime"); + } + return value.toString(); + } + + static void clearCache() { + cache = null; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrl.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrl.java new file mode 100644 index 0000000000..1e40ced32f --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrl.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.net.protocol.jar; + +import java.io.File; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.jar.JarEntry; + +/** + * Utility class with factory methods that can be used to create JAR URLs. + * + * @author Phillip Webb + * @since 3.2.0 + */ +public final class JarUrl { + + private JarUrl() { + } + + /** + * Create a new jar URL. + * @param file the jar file + * @return a jar file URL + */ + public static URL create(File file) { + return create(file, (String) null); + } + + /** + * Create a new jar URL. + * @param file the jar file + * @param nestedEntry the nested entry or {@code null} + * @return a jar file URL + */ + public static URL create(File file, JarEntry nestedEntry) { + return create(file, (nestedEntry != null) ? nestedEntry.getName() : null); + } + + /** + * Create a new jar URL. + * @param file the jar file + * @param nestedEntryName the nested entry name or {@code null} + * @return a jar file URL + */ + public static URL create(File file, String nestedEntryName) { + return create(file, nestedEntryName, null); + } + + /** + * Create a new jar URL. + * @param file the jar file + * @param nestedEntryName the nested entry name or {@code null} + * @param path the path within the jar or nested jar + * @return a jar file URL + */ + public static URL create(File file, String nestedEntryName, String path) { + try { + path = (path != null) ? path : ""; + return new URL(null, "jar:" + getJarReference(file, nestedEntryName) + "!/" + path, Handler.INSTANCE); + } + catch (MalformedURLException ex) { + throw new IllegalStateException("Unable to create JarFileArchive URL", ex); + } + } + + private static String getJarReference(File file, String nestedEntryName) { + String jarFilePath = file.toURI().getPath(); + return (nestedEntryName != null) ? "nested:" + jarFilePath + "/!" + nestedEntryName : "file:" + jarFilePath; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrlClassLoader.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrlClassLoader.java new file mode 100644 index 0000000000..bf2aadc218 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrlClassLoader.java @@ -0,0 +1,290 @@ +/* + * 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.net.protocol.jar; + +import java.io.IOException; +import java.net.JarURLConnection; +import java.net.URL; +import java.net.URLClassLoader; +import java.net.URLConnection; +import java.util.Arrays; +import java.util.Collections; +import java.util.Enumeration; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.jar.JarFile; + +import org.springframework.boot.loader.jar.NestedJarFile; +import org.springframework.boot.loader.launch.LaunchedClassLoader; + +/** + * {@link URLClassLoader} with optimized support for Jar URLs. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @since 3.2.0 + */ +public abstract class JarUrlClassLoader extends URLClassLoader { + + private final URL[] urls; + + private final boolean hasJarUrls; + + private final Map jarFiles = new ConcurrentHashMap<>(); + + private final Set undefinablePackages = Collections.newSetFromMap(new ConcurrentHashMap<>()); + + /** + * Create a new {@link LaunchedClassLoader} instance. + * @param urls the URLs from which to load classes and resources + * @param parent the parent class loader for delegation + */ + public JarUrlClassLoader(URL[] urls, ClassLoader parent) { + super(urls, parent); + this.urls = urls; + this.hasJarUrls = Arrays.stream(urls).anyMatch(this::isJarUrl); + } + + @Override + public URL findResource(String name) { + if (!this.hasJarUrls) { + return super.findResource(name); + } + Optimizations.enable(false); + try { + return super.findResource(name); + } + finally { + Optimizations.disable(); + } + } + + @Override + public Enumeration findResources(String name) throws IOException { + if (!this.hasJarUrls) { + return super.findResources(name); + } + Optimizations.enable(false); + try { + return new OptimizedEnumeration(super.findResources(name)); + } + finally { + Optimizations.disable(); + } + } + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (!this.hasJarUrls) { + return super.loadClass(name, resolve); + } + Optimizations.enable(true); + try { + try { + definePackageIfNecessary(name); + } + catch (IllegalArgumentException ex) { + tolerateRaceConditionDueToBeingParallelCapable(ex, name); + } + return super.loadClass(name, resolve); + } + finally { + Optimizations.disable(); + } + } + + /** + * 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 + */ + protected final void definePackageIfNecessary(String className) { + if (className.startsWith("java.")) { + return; + } + int lastDot = className.lastIndexOf('.'); + if (lastDot >= 0) { + String packageName = className.substring(0, lastDot); + if (getDefinedPackage(packageName) == null) { + try { + definePackage(className, packageName); + } + catch (IllegalArgumentException ex) { + tolerateRaceConditionDueToBeingParallelCapable(ex, packageName); + } + } + } + } + + private void definePackage(String className, String packageName) { + if (this.undefinablePackages.contains(packageName)) { + return; + } + String packageEntryName = packageName.replace('.', '/') + "/"; + String classEntryName = className.replace('.', '/') + ".class"; + for (URL url : this.urls) { + try { + JarFile jarFile = getJarFile(url); + if (jarFile != null) { + if (hasEntry(jarFile, classEntryName) && hasEntry(jarFile, packageEntryName) + && jarFile.getManifest() != null) { + definePackage(packageName, jarFile.getManifest(), url); + return; + } + } + } + catch (IOException ex) { + // Ignore + } + } + this.undefinablePackages.add(packageName); + } + + private void tolerateRaceConditionDueToBeingParallelCapable(IllegalArgumentException ex, String packageName) + throws AssertionError { + 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 %s has already been defined but it could not be found".formatted(packageName), ex); + } + } + + private boolean hasEntry(JarFile jarFile, String name) { + return (jarFile instanceof NestedJarFile nestedJarFile) ? nestedJarFile.hasEntry(name) + : jarFile.getEntry(name) != null; + } + + private JarFile getJarFile(URL url) throws IOException { + JarFile jarFile = this.jarFiles.get(url); + if (jarFile != null) { + return jarFile; + } + URLConnection connection = url.openConnection(); + if (!(connection instanceof JarURLConnection)) { + return null; + } + connection.setUseCaches(false); + jarFile = ((JarURLConnection) connection).getJarFile(); + synchronized (this.jarFiles) { + JarFile previous = this.jarFiles.putIfAbsent(url, jarFile); + if (previous != null) { + jarFile.close(); + jarFile = previous; + } + } + return jarFile; + } + + /** + * Clear any caches. This method is called reflectively by + * {@code ClearCachesApplicationListener}. + */ + public void clearCache() { + Handler.clearCache(); + org.springframework.boot.loader.net.protocol.nested.Handler.clearCache(); + try { + clearJarFiles(); + } + catch (IOException ex) { + // Ignore + } + for (URL url : this.urls) { + if (isJarUrl(url)) { + clearCache(url); + } + } + } + + private void clearCache(URL url) { + try { + URLConnection connection = url.openConnection(); + if (connection instanceof JarURLConnection jarUrlConnection) { + clearCache(jarUrlConnection); + } + } + catch (IOException ex) { + // Ignore + } + } + + private void clearCache(JarURLConnection connection) throws IOException { + JarFile jarFile = connection.getJarFile(); + if (jarFile instanceof NestedJarFile nestedJarFile) { + nestedJarFile.clearCache(); + } + } + + private boolean isJarUrl(URL url) { + return "jar".equals(url.getProtocol()); + } + + @Override + public void close() throws IOException { + super.close(); + clearJarFiles(); + } + + private void clearJarFiles() throws IOException { + synchronized (this.jarFiles) { + for (JarFile jarFile : this.jarFiles.values()) { + jarFile.close(); + } + this.jarFiles.clear(); + } + } + + /** + * {@link Enumeration} that uses fast connections. + */ + private static class OptimizedEnumeration implements Enumeration { + + private final Enumeration delegate; + + OptimizedEnumeration(Enumeration delegate) { + this.delegate = delegate; + } + + @Override + public boolean hasMoreElements() { + Optimizations.enable(false); + try { + return this.delegate.hasMoreElements(); + } + finally { + Optimizations.disable(); + } + + } + + @Override + public URL nextElement() { + Optimizations.enable(false); + try { + return this.delegate.nextElement(); + } + finally { + Optimizations.disable(); + } + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrlConnection.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrlConnection.java new file mode 100644 index 0000000000..dc51bfe4eb --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/JarUrlConnection.java @@ -0,0 +1,399 @@ +/* + * 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.net.protocol.jar; + +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.FileNotFoundException; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLClassLoader; +import java.net.URLConnection; +import java.net.URLStreamHandler; +import java.security.Permission; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +import org.springframework.boot.loader.jar.NestedJarFile; +import org.springframework.boot.loader.net.util.UrlDecoder; + +/** + * {@link java.net.JarURLConnection} alternative to + * {@code sun.net.www.protocol.jar.JarURLConnection} with optimized support for nested + * jars. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Rostyslav Dudka + */ +final class JarUrlConnection extends java.net.JarURLConnection { + + static final UrlJarFiles jarFiles = new UrlJarFiles(); + + static final InputStream emptyInputStream = new ByteArrayInputStream(new byte[0]); + + static final FileNotFoundException FILE_NOT_FOUND_EXCEPTION = new FileNotFoundException( + "Jar file or entry not found"); + + private static final URL NOT_FOUND_URL; + + static final JarUrlConnection NOT_FOUND_CONNECTION; + static { + try { + NOT_FOUND_URL = new URL("jar:", null, 0, "nested:!/", new EmptyUrlStreamHandler()); + NOT_FOUND_CONNECTION = new JarUrlConnection(() -> FILE_NOT_FOUND_EXCEPTION); + } + catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + + private final String entryName; + + private final Supplier notFound; + + private JarFile jarFile; + + private URLConnection jarFileConnection; + + private JarEntry jarEntry; + + private String contentType; + + private JarUrlConnection(URL url) throws IOException { + super(url); + this.entryName = getEntryName(); + this.notFound = null; + this.jarFileConnection = getJarFileURL().openConnection(); + this.jarFileConnection.setUseCaches(this.useCaches); + } + + private JarUrlConnection(Supplier notFound) throws IOException { + super(NOT_FOUND_URL); + this.entryName = null; + this.notFound = notFound; + } + + @Override + public JarFile getJarFile() throws IOException { + connect(); + return this.jarFile; + } + + @Override + public JarEntry getJarEntry() throws IOException { + connect(); + return this.jarEntry; + } + + @Override + public int getContentLength() { + long contentLength = getContentLengthLong(); + return (contentLength <= Integer.MAX_VALUE) ? (int) contentLength : -1; + } + + @Override + public long getContentLengthLong() { + try { + connect(); + return (this.jarEntry != null) ? this.jarEntry.getSize() : this.jarFileConnection.getContentLengthLong(); + } + catch (IOException ex) { + return -1; + } + } + + @Override + public String getContentType() { + if (this.contentType == null) { + this.contentType = deduceContentType(); + } + return this.contentType; + } + + private String deduceContentType() { + String type = (this.entryName != null) ? null : "x-java/jar"; + type = (type != null) ? type : deduceContentTypeFromStream(); + type = (type != null) ? type : deduceContentTypeFromEntryName(); + return (type != null) ? type : "content/unknown"; + } + + private String deduceContentTypeFromStream() { + try { + connect(); + try (InputStream in = this.jarFile.getInputStream(this.jarEntry)) { + return guessContentTypeFromStream(new BufferedInputStream(in)); + } + } + catch (IOException ex) { + return null; + } + } + + private String deduceContentTypeFromEntryName() { + return guessContentTypeFromName(this.entryName); + } + + @Override + public String getHeaderField(String name) { + return (this.jarFileConnection != null) ? this.jarFileConnection.getHeaderField(name) : null; + } + + @Override + public Object getContent() throws IOException { + connect(); + return (this.entryName != null) ? super.getContent() : this.jarFile; + } + + @Override + public Permission getPermission() throws IOException { + return this.jarFileConnection.getPermission(); + } + + @Override + public InputStream getInputStream() throws IOException { + if (this.notFound != null) { + throwFileNotFound(); + } + if (this.entryName == null) { + throw new IOException("no entry name specified"); + } + if (!getUseCaches() && Optimizations.isEnabled(false)) { + JarFile cached = jarFiles.getCached(getJarFileURL()); + if (cached != null) { + if (cached.getEntry(this.entryName) != null) { + return emptyInputStream; + } + } + } + connect(); + if (this.jarEntry == null) { + throwFileNotFound(); + } + return new ConnectionInputStream(); + } + + @Override + public boolean getAllowUserInteraction() { + return (this.jarFileConnection != null) ? this.jarFileConnection.getAllowUserInteraction() : false; + } + + @Override + public void setAllowUserInteraction(boolean allowuserinteraction) { + if (this.jarFileConnection != null) { + this.jarFileConnection.setAllowUserInteraction(allowuserinteraction); + } + } + + @Override + public boolean getUseCaches() { + return (this.jarFileConnection != null) ? this.jarFileConnection.getUseCaches() : true; + } + + @Override + public void setUseCaches(boolean usecaches) { + if (this.jarFileConnection != null) { + this.jarFileConnection.setUseCaches(usecaches); + } + } + + @Override + public boolean getDefaultUseCaches() { + return (this.jarFileConnection != null) ? this.jarFileConnection.getDefaultUseCaches() : true; + } + + @Override + public void setDefaultUseCaches(boolean defaultusecaches) { + if (this.jarFileConnection != null) { + this.jarFileConnection.setDefaultUseCaches(defaultusecaches); + } + } + + @Override + public void setIfModifiedSince(long ifModifiedSince) { + if (this.jarFileConnection != null) { + this.jarFileConnection.setIfModifiedSince(ifModifiedSince); + } + } + + @Override + public String getRequestProperty(String key) { + return (this.jarFileConnection != null) ? this.jarFileConnection.getRequestProperty(key) : null; + } + + @Override + public void setRequestProperty(String key, String value) { + if (this.jarFileConnection != null) { + this.jarFileConnection.setRequestProperty(key, value); + } + } + + @Override + public void addRequestProperty(String key, String value) { + if (this.jarFileConnection != null) { + this.jarFileConnection.addRequestProperty(key, value); + } + } + + @Override + public Map> getRequestProperties() { + return (this.jarFileConnection != null) ? this.jarFileConnection.getRequestProperties() + : Collections.emptyMap(); + } + + @Override + public void connect() throws IOException { + if (this.connected) { + return; + } + if (this.notFound != null) { + throwFileNotFound(); + } + boolean useCaches = getUseCaches(); + URL jarFileURL = getJarFileURL(); + if (this.entryName != null && Optimizations.isEnabled()) { + assertCachedJarFileHasEntry(jarFileURL, this.entryName); + } + this.jarFile = jarFiles.getOrCreate(useCaches, jarFileURL); + this.jarEntry = getJarEntry(jarFileURL); + boolean addedToCache = jarFiles.cacheIfAbsent(useCaches, jarFileURL, this.jarFile); + if (addedToCache) { + this.jarFileConnection = jarFiles.reconnect(this.jarFile, this.jarFileConnection); + } + this.connected = true; + } + + /** + * The {@link URLClassLoader} connects often to check if a resource exists, we can + * save some object allocations by using the cached copy if we have one. + * @param jarFileURL the jar file to check + * @param entryName the entry name to check + * @throws FileNotFoundException on a missing entry + */ + private void assertCachedJarFileHasEntry(URL jarFileURL, String entryName) throws FileNotFoundException { + JarFile cachedJarFile = jarFiles.getCached(jarFileURL); + if (cachedJarFile != null && cachedJarFile.getJarEntry(entryName) == null) { + throw FILE_NOT_FOUND_EXCEPTION; + } + } + + private JarEntry getJarEntry(URL jarFileUrl) throws IOException { + if (this.entryName == null) { + return null; + } + JarEntry jarEntry = this.jarFile.getJarEntry(this.entryName); + if (jarEntry == null) { + jarFiles.closeIfNotCached(jarFileUrl, this.jarFile); + throwFileNotFound(); + } + return jarEntry; + } + + private void throwFileNotFound() throws FileNotFoundException { + if (Optimizations.isEnabled()) { + throw FILE_NOT_FOUND_EXCEPTION; + } + if (this.notFound != null) { + throw this.notFound.get(); + } + throw new FileNotFoundException("JAR entry " + this.entryName + " not found in " + this.jarFile.getName()); + } + + static JarUrlConnection open(URL url) throws IOException { + String spec = url.getFile(); + if (spec.startsWith("nested:")) { + int separator = spec.indexOf("!/"); + boolean specHasEntry = (separator != -1) && (separator + 2 != spec.length()); + if (specHasEntry) { + URL jarFileUrl = new URL(spec.substring(0, separator)); + if ("runtime".equals(url.getRef())) { + jarFileUrl = new URL(jarFileUrl, "#runtime"); + } + String entryName = UrlDecoder.decode(spec.substring(separator + 2)); + JarFile jarFile = jarFiles.getOrCreate(true, jarFileUrl); + jarFiles.cacheIfAbsent(true, jarFileUrl, jarFile); + if (!hasEntry(jarFile, entryName)) { + return notFoundConnection(jarFile.getName(), entryName); + } + } + } + return new JarUrlConnection(url); + } + + private static boolean hasEntry(JarFile jarFile, String name) { + return (jarFile instanceof NestedJarFile nestedJarFile) ? nestedJarFile.hasEntry(name) + : jarFile.getEntry(name) != null; + } + + private static JarUrlConnection notFoundConnection(String jarFileName, String entryName) throws IOException { + if (Optimizations.isEnabled()) { + return NOT_FOUND_CONNECTION; + } + return new JarUrlConnection( + () -> new FileNotFoundException("JAR entry " + entryName + " not found in " + jarFileName)); + } + + static void clearCache() { + jarFiles.clearCache(); + } + + /** + * Connection {@link InputStream}. This is not a {@link FilterInputStream} since + * {@link URLClassLoader} often creates streams that it doesn't call and we want to be + * lazy about getting the underlying {@link InputStream}. + */ + class ConnectionInputStream extends LazyDelegatingInputStream { + + @Override + public void close() throws IOException { + try { + super.close(); + } + finally { + if (!getUseCaches()) { + JarUrlConnection.this.jarFile.close(); + } + } + } + + @Override + protected InputStream getDelegateInputStream() throws IOException { + return JarUrlConnection.this.jarFile.getInputStream(JarUrlConnection.this.jarEntry); + } + + } + + /** + * Empty {@link URLStreamHandler} used to prevent the wrong JAR Handler from being + * Instantiated and cached. + */ + private static class EmptyUrlStreamHandler extends URLStreamHandler { + + @Override + protected URLConnection openConnection(URL url) { + return null; + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/LazyDelegatingInputStream.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/LazyDelegatingInputStream.java new file mode 100644 index 0000000000..95e5cc3c14 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/LazyDelegatingInputStream.java @@ -0,0 +1,110 @@ +/* + * 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.net.protocol.jar; + +import java.io.IOException; +import java.io.InputStream; + +/** + * {@link InputStream} that delegates lazily to another {@link InputStream}. + * + * @author Phillip Webb + */ +abstract class LazyDelegatingInputStream extends InputStream { + + private volatile InputStream in; + + @Override + public int read() throws IOException { + return in().read(); + } + + @Override + public int read(byte[] b) throws IOException { + return in().read(b); + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + return in().read(b, off, len); + } + + @Override + public long skip(long n) throws IOException { + return in().skip(n); + } + + @Override + public int available() throws IOException { + return in().available(); + } + + @Override + public boolean markSupported() { + try { + return in().markSupported(); + } + catch (IOException ex) { + return false; + } + } + + @Override + public synchronized void mark(int readlimit) { + try { + in().mark(readlimit); + } + catch (IOException ex) { + // Ignore + } + } + + @Override + public synchronized void reset() throws IOException { + in().reset(); + } + + private InputStream in() throws IOException { + InputStream in = this.in; + if (in == null) { + synchronized (this) { + in = this.in; + if (in == null) { + in = getDelegateInputStream(); + this.in = in; + } + } + } + return in; + } + + @Override + public void close() throws IOException { + InputStream in = this.in; + if (in != null) { + synchronized (this) { + in = this.in; + if (in != null) { + in.close(); + } + } + } + } + + protected abstract InputStream getDelegateInputStream() throws IOException; + +} 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/net/protocol/jar/Optimizations.java similarity index 54% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/JarEntryFilter.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/Optimizations.java index 6804f0ba37..138e8e45e0 100644 --- 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/net/protocol/jar/Optimizations.java @@ -14,22 +14,34 @@ * limitations under the License. */ -package org.springframework.boot.loader.jar; +package org.springframework.boot.loader.net.protocol.jar; /** - * Interface that can be used to filter and optionally rename jar entries. + * {@link ThreadLocal} state for {@link Handler} optimizations. * * @author Phillip Webb */ -interface JarEntryFilter { +final class Optimizations { - /** - * 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); + private static final ThreadLocal status = new ThreadLocal<>(); + + private Optimizations() { + } + + static void enable(boolean readContents) { + status.set(readContents); + } + + static void disable() { + status.remove(); + } + + static boolean isEnabled() { + return status.get() != null; + } + + static boolean isEnabled(boolean readContents) { + return Boolean.valueOf(readContents).equals(status.get()); + } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarEntry.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarEntry.java new file mode 100644 index 0000000000..5c2b100cf2 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarEntry.java @@ -0,0 +1,47 @@ +/* + * 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.net.protocol.jar; + +import java.io.IOException; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.zip.ZipEntry; + +/** + * A {@link JarEntry} returned from a {@link UrlJarFile} or {@link UrlNestedJarFile}. + * + * @author Phillip Webb + */ +final class UrlJarEntry extends JarEntry { + + private final UrlJarManifest manifest; + + private UrlJarEntry(JarEntry entry, UrlJarManifest manifest) { + super(entry); + this.manifest = manifest; + } + + @Override + public Attributes getAttributes() throws IOException { + return this.manifest.getEntryAttributes(this); + } + + static UrlJarEntry of(ZipEntry entry, UrlJarManifest manifest) { + return (entry != null) ? new UrlJarEntry((JarEntry) entry, manifest) : null; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFile.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFile.java new file mode 100644 index 0000000000..513d79799c --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFile.java @@ -0,0 +1,60 @@ +/* + * 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.net.protocol.jar; + +import java.io.File; +import java.io.IOException; +import java.util.function.Consumer; +import java.util.jar.JarFile; +import java.util.jar.Manifest; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +/** + * A {@link JarFile} subclass returned from a {@link JarUrlConnection}. + * + * @author Phillip Webb + */ +class UrlJarFile extends JarFile { + + private final UrlJarManifest manifest; + + private final Consumer closeAction; + + UrlJarFile(File file, Runtime.Version version, Consumer closeAction) throws IOException { + super(file, true, ZipFile.OPEN_READ, version); + this.manifest = new UrlJarManifest(super::getManifest); + this.closeAction = closeAction; + } + + @Override + public ZipEntry getEntry(String name) { + return UrlJarEntry.of(super.getEntry(name), this.manifest); + } + + @Override + public Manifest getManifest() throws IOException { + return this.manifest.get(); + } + + @Override + public void close() throws IOException { + this.closeAction.accept(this); + super.close(); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileFactory.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileFactory.java new file mode 100644 index 0000000000..1fb8173b87 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileFactory.java @@ -0,0 +1,118 @@ +/* + * 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.net.protocol.jar; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.lang.Runtime.Version; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.function.Consumer; +import java.util.jar.JarFile; + +import org.springframework.boot.loader.net.protocol.nested.NestedLocation; +import org.springframework.boot.loader.net.util.UrlDecoder; + +/** + * Factory used by {@link UrlJarFiles} to create {@link JarFile} instances. + * + * @author Phillip Webb + * @see UrlJarFile + * @see UrlNestedJarFile + */ +class UrlJarFileFactory { + + /** + * Create a new {@link UrlJarFile} or {@link UrlNestedJarFile} instance. + * @param jarFileUrl the jar file URL + * @param closeAction the action to call when the file is closed + * @return a new {@link JarFile} instance + * @throws IOException on I/O error + */ + JarFile createJarFile(URL jarFileUrl, Consumer closeAction) throws IOException { + Runtime.Version version = getVersion(jarFileUrl); + if (isLocalFileUrl(jarFileUrl)) { + return createJarFileForLocalFile(jarFileUrl, version, closeAction); + } + if (isNestedUrl(jarFileUrl)) { + return createJarFileForNested(jarFileUrl, version, closeAction); + } + return createJarFileForStream(jarFileUrl, version, closeAction); + } + + private Runtime.Version getVersion(URL url) { + return "runtime".equals(url.getRef()) ? JarFile.runtimeVersion() : JarFile.baseVersion(); + } + + private boolean isLocalFileUrl(URL url) { + return url.getProtocol().equalsIgnoreCase("file") && isLocal(url.getHost()); + } + + private boolean isLocal(String host) { + return host == null || host.isEmpty() || host.equals("~") || host.equalsIgnoreCase("localhost"); + } + + private JarFile createJarFileForLocalFile(URL url, Runtime.Version version, Consumer closeAction) + throws IOException { + String path = UrlDecoder.decode(url.getPath()); + return new UrlJarFile(new File(path), version, closeAction); + } + + private boolean isNestedUrl(URL url) { + return url.getProtocol().equalsIgnoreCase("nested"); + } + + private JarFile createJarFileForNested(URL url, Runtime.Version version, Consumer closeAction) + throws IOException { + NestedLocation location = NestedLocation.fromUrl(url); + return new UrlNestedJarFile(location.file(), location.nestedEntryName(), version, closeAction); + } + + private JarFile createJarFileForStream(URL url, Version version, Consumer closeAction) throws IOException { + try (InputStream in = url.openStream()) { + return createJarFileForStream(in, version, closeAction); + } + } + + private JarFile createJarFileForStream(InputStream in, Version version, Consumer closeAction) + throws IOException { + Path local = Files.createTempFile("jar_cache", null); + try { + Files.copy(in, local, StandardCopyOption.REPLACE_EXISTING); + JarFile jarFile = new UrlJarFile(local.toFile(), version, closeAction); + local.toFile().deleteOnExit(); + return jarFile; + } + catch (Throwable ex) { + deleteIfPossible(local, ex); + throw ex; + } + } + + private void deleteIfPossible(Path local, Throwable cause) { + try { + Files.delete(local); + } + catch (IOException ex) { + cause.addSuppressed(ex); + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFiles.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFiles.java new file mode 100644 index 0000000000..145a514960 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFiles.java @@ -0,0 +1,217 @@ +/* + * 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.net.protocol.jar; + +import java.io.IOException; +import java.net.URL; +import java.net.URLConnection; +import java.util.HashMap; +import java.util.Map; +import java.util.jar.JarFile; + +/** + * Provides access to {@link UrlJarFile} and {@link UrlNestedJarFile} instances taking + * care of caching concerns when necessary. + *

+ * This class is thread-safe and designed to be shared by all {@link JarUrlConnection} + * instances. + * + * @author Phillip Webb + */ +class UrlJarFiles { + + private final UrlJarFileFactory factory; + + private final Cache cache = new Cache(); + + /** + * Create a new {@link UrlJarFiles} instance. + */ + UrlJarFiles() { + this(new UrlJarFileFactory()); + } + + /** + * Create a new {@link UrlJarFiles} instance. + * @param factory the {@link UrlJarFileFactory} to use. + */ + UrlJarFiles(UrlJarFileFactory factory) { + this.factory = factory; + } + + /** + * Get an existing {@link JarFile} instance from the cache, or create a new + * {@link JarFile} instance that can be {@link #cacheIfAbsent(boolean, URL, JarFile) + * cached later}. + * @param useCaches if caches can be used + * @param jarFileUrl the jar file URL + * @return a new or existing {@link JarFile} instance + * @throws IOException on I/O error + */ + JarFile getOrCreate(boolean useCaches, URL jarFileUrl) throws IOException { + if (useCaches) { + JarFile cached = getCached(jarFileUrl); + if (cached != null) { + return cached; + } + } + return this.factory.createJarFile(jarFileUrl, this::onClose); + } + + /** + * Return the cached {@link JarFile} if available. + * @param jarFileUrl the jar file URL + * @return the cached jar or {@code null} + */ + JarFile getCached(URL jarFileUrl) { + return this.cache.get(jarFileUrl); + } + + /** + * Cache the given {@link JarFile} if caching can be used and there is no existing + * entry. + * @param useCaches if caches can be used + * @param jarFileUrl the jar file URL + * @param jarFile the jar file + * @return {@code true} if that file was added to the cache + */ + boolean cacheIfAbsent(boolean useCaches, URL jarFileUrl, JarFile jarFile) { + if (!useCaches) { + return false; + } + return this.cache.putIfAbsent(jarFileUrl, jarFile); + } + + /** + * Close the given {@link JarFile} only if it is not contained in the cache. + * @param jarFileUrl the jar file URL + * @param jarFile the jar file + * @throws IOException on I/O error + */ + void closeIfNotCached(URL jarFileUrl, JarFile jarFile) throws IOException { + JarFile cached = getCached(jarFileUrl); + if (cached != jarFile) { + jarFile.close(); + } + } + + /** + * Reconnect to the {@link JarFile}, returning a replacement {@link URLConnection}. + * @param jarFile the jar file + * @param existingConnection the existing connection + * @return a newly opened connection inhering the same {@code useCaches} value as the + * existing connection + * @throws IOException on I/O error + */ + URLConnection reconnect(JarFile jarFile, URLConnection existingConnection) throws IOException { + Boolean useCaches = (existingConnection != null) ? existingConnection.getUseCaches() : null; + URLConnection connection = openConnection(jarFile); + if (useCaches != null && connection != null) { + connection.setUseCaches(useCaches); + } + return connection; + } + + private URLConnection openConnection(JarFile jarFile) throws IOException { + URL url = this.cache.get(jarFile); + return (url != null) ? url.openConnection() : null; + } + + private void onClose(JarFile jarFile) { + this.cache.remove(jarFile); + } + + void clearCache() { + this.cache.clear(); + } + + /** + * Internal cache. + */ + private static class Cache { + + private final Map jarFileUrlToJarFile = new HashMap<>(); + + private final Map jarFileToJarFileUrl = new HashMap<>(); + + /** + * Get a {@link JarFile} from the cache given a jar file URL. + * @param jarFileUrl the jar file URL + * @return the cached {@link JarFile} or {@code null} + */ + JarFile get(URL jarFileUrl) { + String urlKey = JarFileUrlKey.get(jarFileUrl); + synchronized (this) { + return this.jarFileUrlToJarFile.get(urlKey); + } + } + + /** + * Get a jar file URL from the cache given a jar file. + * @param jarFile the jar file + * @return the cached {@link URL} or {@code null} + */ + URL get(JarFile jarFile) { + synchronized (this) { + return this.jarFileToJarFileUrl.get(jarFile); + } + } + + /** + * Put the given jar file URL and jar file into the cache if they aren't already + * there. + * @param jarFileUrl the jar file URL + * @param jarFile the jar file + * @return {@code true} if the items were added to the cache or {@code false} if + * they were already there + */ + boolean putIfAbsent(URL jarFileUrl, JarFile jarFile) { + String urlKey = JarFileUrlKey.get(jarFileUrl); + synchronized (this) { + JarFile cached = this.jarFileUrlToJarFile.get(urlKey); + if (cached == null) { + this.jarFileUrlToJarFile.put(urlKey, jarFile); + this.jarFileToJarFileUrl.put(jarFile, jarFileUrl); + return true; + } + return false; + } + } + + /** + * Remove the given jar and any related URL file from the cache. + * @param jarFile the jar file to remove + */ + void remove(JarFile jarFile) { + synchronized (this) { + URL removedUrl = this.jarFileToJarFileUrl.remove(jarFile); + if (removedUrl != null) { + this.jarFileUrlToJarFile.remove(JarFileUrlKey.get(removedUrl)); + } + } + } + + void clear() { + synchronized (this) { + this.jarFileToJarFileUrl.clear(); + this.jarFileUrlToJarFile.clear(); + } + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarManifest.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarManifest.java new file mode 100644 index 0000000000..70c372855d --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlJarManifest.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.net.protocol.jar; + +import java.io.IOException; +import java.util.Map; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.jar.Manifest; + +/** + * Provides access {@link Manifest} content that can be safely returned from + * {@link UrlJarFile} or {@link UrlNestedJarFile}. + * + * @author Phillip Webb + */ +class UrlJarManifest { + + private static final Object NONE = new Object(); + + private final ManifestSupplier supplier; + + private volatile Object supplied; + + UrlJarManifest(ManifestSupplier supplier) { + this.supplier = supplier; + } + + Manifest get() throws IOException { + Manifest manifest = supply(); + if (manifest == null) { + return null; + } + Manifest copy = new Manifest(); + copy.getMainAttributes().putAll((Map) manifest.getMainAttributes().clone()); + manifest.getEntries().forEach((key, value) -> copy.getEntries().put(key, cloneAttributes(value))); + return copy; + } + + Attributes getEntryAttributes(JarEntry entry) throws IOException { + Manifest manifest = supply(); + if (manifest == null) { + return null; + } + Attributes attributes = manifest.getEntries().get(entry.getName()); + return cloneAttributes(attributes); + } + + private Attributes cloneAttributes(Attributes attributes) { + return (attributes != null) ? (Attributes) attributes.clone() : null; + } + + private Manifest supply() throws IOException { + Object supplied = this.supplied; + if (supplied == null) { + supplied = this.supplier.getManifest(); + this.supplied = (supplied != null) ? supplied : NONE; + } + return (supplied != NONE) ? (Manifest) supplied : null; + } + + /** + * Interface used to supply the actual manifest. + */ + @FunctionalInterface + interface ManifestSupplier { + + Manifest getManifest() throws IOException; + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlNestedJarFile.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlNestedJarFile.java new file mode 100644 index 0000000000..33bbed2e83 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/UrlNestedJarFile.java @@ -0,0 +1,63 @@ +/* + * 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.net.protocol.jar; + +import java.io.File; +import java.io.IOException; +import java.lang.Runtime.Version; +import java.util.function.Consumer; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.Manifest; + +import org.springframework.boot.loader.jar.NestedJarFile; + +/** + * {@link NestedJarFile} subclass returned from a {@link JarUrlConnection}. + * + * @author Phillip Webb + */ +class UrlNestedJarFile extends NestedJarFile { + + private final UrlJarManifest manifest; + + private final Consumer closeAction; + + UrlNestedJarFile(File file, String nestedEntryName, Version version, Consumer closeAction) + throws IOException { + super(file, nestedEntryName, version); + this.manifest = new UrlJarManifest(super::getManifest); + this.closeAction = closeAction; + } + + @Override + public Manifest getManifest() throws IOException { + return this.manifest.get(); + } + + @Override + public JarEntry getEntry(String name) { + return UrlJarEntry.of(super.getEntry(name), this.manifest); + } + + @Override + public void close() throws IOException { + this.closeAction.accept(this); + super.close(); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/package-info.java new file mode 100644 index 0000000000..980f423022 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/jar/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. + */ + +/** + * JAR URL support, including support for nested jars. + * + * @see org.springframework.boot.loader.net.protocol.jar.JarUrl + * @see org.springframework.boot.loader.net.protocol.jar.Handler + */ +package org.springframework.boot.loader.net.protocol.jar; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/Handler.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/Handler.java new file mode 100644 index 0000000000..0a05596e28 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/Handler.java @@ -0,0 +1,61 @@ +/* + * 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.net.protocol.nested; + +import java.io.IOException; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLStreamHandler; + +/** + * {@link URLStreamHandler} to support {@code nested:} URLs. See {@link NestedLocation} + * for details of the URL format. + * + * @author Phillip Webb + * @since 3.2.0 + */ +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 '.nested' + + private static final String PREFIX = "nested:"; + + @Override + protected URLConnection openConnection(URL url) throws IOException { + return new NestedUrlConnection(url); + } + + /** + * Assert that the specified URL is a valid "nested" URL. + * @param url the URL to check + */ + public static void assertUrlIsNotMalformed(String url) { + if (url == null || !url.startsWith(PREFIX)) { + throw new IllegalArgumentException("'url' must not be null and must use 'nested' protocol"); + } + NestedLocation.parse(url.substring(PREFIX.length())); + } + + /** + * Clear any internal caches. + */ + public static void clearCache() { + NestedLocation.clearCache(); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedLocation.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedLocation.java new file mode 100644 index 0000000000..3f0a016a6a --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedLocation.java @@ -0,0 +1,98 @@ +/* + * 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.net.protocol.nested; + +import java.io.File; +import java.net.URL; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.boot.loader.net.util.UrlDecoder; + +/** + * A location obtained from a {@code nested:} {@link URL} consisting of a jar file and a + * nested entry. + *

+ * The syntax of a nested JAR URL is:

+ * nestedjar:<path>/!{entry}
+ * 
+ *

+ * for example: + *

+ * {@code nested:/home/example/my.jar/!BOOT-INF/lib/my-nested.jar} + *

+ * or: + *

+ * {@code nested:/home/example/my.jar/!BOOT-INF/classes/} + *

+ * The path must refer to a jar file on the file system. The entry refers to either an + * uncompressed entry that contains the nested jar, or a directory entry. The entry must + * not start with a {@code '/'}. + * + * @param file the zip file that contains the nested entry + * @param nestedEntryName the nested entry name + * @author Phillip Webb + * @since 3.2.0 + */ +public record NestedLocation(File file, String nestedEntryName) { + + private static final Map cache = new ConcurrentHashMap<>(); + + public NestedLocation { + if (file == null) { + throw new IllegalArgumentException("'file' must not be null"); + } + if (nestedEntryName == null || nestedEntryName.trim().isEmpty()) { + throw new IllegalArgumentException("'nestedEntryName' must not be empty"); + } + } + + /** + * Create a new {@link NestedLocation} from the given URL. + * @param url the nested URL + * @return a new {@link NestedLocation} instance + * @throws IllegalArgumentException if the URL is not valid + */ + public static NestedLocation fromUrl(URL url) { + if (url == null || !"nested".equalsIgnoreCase(url.getProtocol())) { + throw new IllegalArgumentException("'url' must not be null and must use 'nested' protocol"); + } + return parse(UrlDecoder.decode(url.getPath())); + } + + static NestedLocation parse(String path) { + if (path == null || path.isEmpty()) { + throw new IllegalArgumentException("'path' must not be empty"); + } + int index = path.lastIndexOf("/!"); + if (index == -1) { + throw new IllegalArgumentException("'path' must contain '/!'"); + } + return cache.computeIfAbsent(path, (l) -> create(index, l)); + } + + private static NestedLocation create(int index, String location) { + String file = location.substring(0, index); + String nestedEntryName = location.substring(index + 2); + return new NestedLocation((!file.isEmpty()) ? new File(file) : null, nestedEntryName); + } + + static void clearCache() { + cache.clear(); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedUrlConnection.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedUrlConnection.java new file mode 100644 index 0000000000..308b32116d --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedUrlConnection.java @@ -0,0 +1,155 @@ +/* + * 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.net.protocol.nested; + +import java.io.FilePermission; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.lang.ref.Cleaner.Cleanable; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLConnection; +import java.security.Permission; + +import org.springframework.boot.loader.ref.Cleaner; + +/** + * {@link URLConnection} to support {@code nested:} URLs. See {@link NestedLocation} for + * details of the URL format. + * + * @author Phillip Webb + */ +class NestedUrlConnection extends URLConnection { + + private static final String CONTENT_TYPE = "x-java/jar"; + + private final NestedUrlConnectionResources resources; + + private final Cleanable cleanup; + + private long lastModified; + + private FilePermission permission; + + NestedUrlConnection(URL url) throws MalformedURLException { + this(url, Cleaner.instance); + } + + NestedUrlConnection(URL url, Cleaner cleaner) throws MalformedURLException { + super(url); + NestedLocation location = parseNestedLocation(url); + this.resources = new NestedUrlConnectionResources(location); + this.cleanup = cleaner.register(this, this.resources); + } + + private NestedLocation parseNestedLocation(URL url) throws MalformedURLException { + try { + return NestedLocation.parse(url.getPath()); + } + catch (IllegalArgumentException ex) { + throw new MalformedURLException(ex.getMessage()); + } + } + + @Override + public int getContentLength() { + long contentLength = getContentLengthLong(); + return (contentLength <= Integer.MAX_VALUE) ? (int) contentLength : -1; + } + + @Override + public long getContentLengthLong() { + try { + connect(); + return this.resources.getContentLength(); + } + catch (IOException ex) { + return -1; + } + } + + @Override + public String getContentType() { + return CONTENT_TYPE; + } + + @Override + public long getLastModified() { + if (this.lastModified == 0) { + this.lastModified = this.resources.getLocation().file().lastModified(); + } + return this.lastModified; + } + + @Override + public Permission getPermission() throws IOException { + if (this.permission == null) { + this.permission = new FilePermission(this.resources.getLocation().file().getCanonicalPath(), "read"); + } + return this.permission; + } + + @Override + public InputStream getInputStream() throws IOException { + connect(); + return new ConnectionInputStream(this.resources.getInputStream()); + } + + @Override + public void connect() throws IOException { + if (this.connected) { + return; + } + this.resources.connect(); + this.connected = true; + } + + /** + * Connection {@link InputStream}. + */ + class ConnectionInputStream extends FilterInputStream { + + private volatile boolean closing; + + ConnectionInputStream(InputStream in) { + super(in); + } + + @Override + public void close() throws IOException { + if (this.closing) { + return; + } + this.closing = true; + try { + super.close(); + } + finally { + try { + NestedUrlConnection.this.cleanup.clean(); + } + catch (UncheckedIOException ex) { + throw ex.getCause(); + } + } + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedUrlConnectionResources.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedUrlConnectionResources.java new file mode 100644 index 0000000000..4582e197c4 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/NestedUrlConnectionResources.java @@ -0,0 +1,128 @@ +/* + * 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.net.protocol.nested; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; + +import org.springframework.boot.loader.ref.Cleaner; +import org.springframework.boot.loader.zip.CloseableDataBlock; +import org.springframework.boot.loader.zip.ZipContent; + +/** + * Resources created managed and cleaned by a {@link NestedUrlConnection} instance and + * suitable for registration with a {@link Cleaner}. + * + * @author Phillip Webb + */ +class NestedUrlConnectionResources implements Runnable { + + private final NestedLocation location; + + private volatile ZipContent zipContent; + + private volatile long size = -1; + + private volatile InputStream inputStream; + + NestedUrlConnectionResources(NestedLocation location) { + this.location = location; + } + + NestedLocation getLocation() { + return this.location; + } + + void connect() throws IOException { + synchronized (this) { + if (this.zipContent == null) { + this.zipContent = ZipContent.open(this.location.file().toPath(), this.location.nestedEntryName()); + try { + connectData(); + } + catch (IOException | RuntimeException ex) { + this.zipContent.close(); + this.zipContent = null; + throw ex; + } + } + } + } + + private void connectData() throws IOException { + CloseableDataBlock data = this.zipContent.openRawZipData(); + try { + this.size = data.size(); + this.inputStream = data.asInputStream(); + } + catch (IOException | RuntimeException ex) { + data.close(); + } + } + + InputStream getInputStream() throws IOException { + synchronized (this) { + if (this.inputStream == null) { + throw new IOException("Nested location not found " + this.location); + } + return this.inputStream; + } + } + + long getContentLength() { + return this.size; + } + + @Override + public void run() { + releaseAll(); + } + + private void releaseAll() { + synchronized (this) { + if (this.zipContent != null) { + IOException exceptionChain = null; + try { + this.inputStream.close(); + } + catch (IOException ex) { + exceptionChain = addToExceptionChain(exceptionChain, ex); + } + try { + this.zipContent.close(); + } + catch (IOException ex) { + exceptionChain = addToExceptionChain(exceptionChain, ex); + } + this.size = -1; + if (exceptionChain != null) { + throw new UncheckedIOException(exceptionChain); + } + } + } + } + + private IOException addToExceptionChain(IOException exceptionChain, IOException ex) { + if (exceptionChain != null) { + exceptionChain.addSuppressed(ex); + return exceptionChain; + } + return ex; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/package-info.java new file mode 100644 index 0000000000..1e0426e2a9 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/nested/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. + */ + +/** + * Nested URL support. + * + * @see org.springframework.boot.loader.net.protocol.nested.NestedLocation + * @see org.springframework.boot.loader.net.protocol.nested.Handler + */ +package org.springframework.boot.loader.net.protocol.nested; 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/net/protocol/package-info.java similarity index 75% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/archive/package-info.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/protocol/package-info.java index 27ce99b006..fa1a2cfb7a 100644 --- 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/net/protocol/package-info.java @@ -15,9 +15,6 @@ */ /** - * Abstraction over logical Archives be they backed by a JAR file or unpacked into a - * directory. - * - * @see org.springframework.boot.loader.archive.Archive + * {@link java.net.URL} protocol support. */ -package org.springframework.boot.loader.archive; +package org.springframework.boot.loader.net.protocol; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/util/UrlDecoder.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/util/UrlDecoder.java new file mode 100644 index 0000000000..999c55140e --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/util/UrlDecoder.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.net.util; + +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharsetDecoder; +import java.nio.charset.CoderResult; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; + +/** + * Utility to decode URL strings. + * + * @author Phillip Webb + * @since 3.2.0 + */ +public final class UrlDecoder { + + private UrlDecoder() { + } + + /** + * Decode the given string by decoding URL {@code '%'} escapes. This method should be + * identical in behavior to the {@code decode} method in the internal + * {@code sun.net.www.ParseUtil} JDK class. + * @param string the string to decode + * @return the decoded string + */ + public static String decode(String string) { + int length = string.length(); + if ((length == 0) || (string.indexOf('%') < 0)) { + return string; + } + StringBuilder result = new StringBuilder(length); + ByteBuffer byteBuffer = ByteBuffer.allocate(length); + CharBuffer charBuffer = CharBuffer.allocate(length); + CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + int index = 0; + while (index < length) { + char ch = string.charAt(index); + if (ch != '%') { + result.append(ch); + if (index + 1 >= length) { + return result.toString(); + } + index++; + continue; + } + index = fillByteBuffer(byteBuffer, string, index, length); + decodeToCharBuffer(byteBuffer, charBuffer, decoder); + result.append(charBuffer.flip()); + + } + return result.toString(); + } + + private static int fillByteBuffer(ByteBuffer byteBuffer, String string, int index, int length) { + byteBuffer.clear(); + while (true) { + byteBuffer.put(unescape(string, index)); + index += 3; + if (index >= length || string.charAt(index) != '%') { + break; + } + } + byteBuffer.flip(); + return index; + } + + private static byte unescape(String string, int index) { + try { + return (byte) Integer.parseInt(string, index + 1, index + 3, 16); + } + catch (NumberFormatException ex) { + throw new IllegalArgumentException(); + } + } + + private static void decodeToCharBuffer(ByteBuffer byteBuffer, CharBuffer charBuffer, CharsetDecoder decoder) { + decoder.reset(); + charBuffer.clear(); + assertNoError(decoder.decode(byteBuffer, charBuffer, true)); + assertNoError(decoder.flush(charBuffer)); + } + + private static void assertNoError(CoderResult result) { + if (result.isError()) { + throw new IllegalArgumentException("Error decoding percent encoded characters"); + } + } + +} 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/net/util/package-info.java similarity index 87% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/util/package-info.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/net/util/package-info.java index d3d7eef2d9..231571bee0 100644 --- 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/net/util/package-info.java @@ -15,6 +15,6 @@ */ /** - * Utilities used by Spring Boot's JAR loading. + * Net utilities. */ -package org.springframework.boot.loader.util; +package org.springframework.boot.loader.net.util; 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 deleted file mode 100644 index 4b32f644f5..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/package-info.java +++ /dev/null @@ -1,26 +0,0 @@ -/* - * 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/ref/Cleaner.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ref/Cleaner.java new file mode 100644 index 0000000000..4b053b78d9 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ref/Cleaner.java @@ -0,0 +1,45 @@ +/* + * 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.ref; + +import java.lang.ref.Cleaner.Cleanable; + +/** + * Wrapper for {@link java.lang.ref.Cleaner} providing registration support. + * + * @author Phillip Webb + * @since 3.2.0 + */ +public interface Cleaner { + + /** + * Provides access to the default clean instance which delegates to + * {@link java.lang.ref.Cleaner}. + */ + Cleaner instance = DefaultCleaner.instance; + + /** + * Registers an object and the clean action to run when the object becomes phantom + * reachable. + * @param obj the object to monitor + * @param action the cleanup action to run + * @return a {@link Cleanable} instance + * @see java.lang.ref.Cleaner#register(Object, Runnable) + */ + Cleanable register(Object obj, Runnable action); + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ref/DefaultCleaner.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ref/DefaultCleaner.java new file mode 100644 index 0000000000..e592de5c85 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ref/DefaultCleaner.java @@ -0,0 +1,44 @@ +/* + * 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.ref; + +import java.lang.ref.Cleaner.Cleanable; +import java.util.function.Consumer; + +/** + * Default {@link Cleaner} implementation that delegates to {@link java.lang.ref.Cleaner}. + * + * @author Phillip Webb + */ +class DefaultCleaner implements Cleaner { + + static final DefaultCleaner instance = new DefaultCleaner(); + + static Consumer tracker; + + private final java.lang.ref.Cleaner cleaner = java.lang.ref.Cleaner.create(); + + @Override + public Cleanable register(Object obj, Runnable action) { + Cleanable cleanable = this.cleaner.register(obj, action); + if (tracker != null) { + tracker.accept(cleanable); + } + return cleanable; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ref/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ref/package-info.java new file mode 100644 index 0000000000..4cb63bb4a6 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ref/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 {@link java.lang.ref.Cleaner}. + */ +package org.springframework.boot.loader.ref; 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 deleted file mode 100644 index df00705e9e..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/util/SystemPropertyUtils.java +++ /dev/null @@ -1,232 +0,0 @@ -/* - * 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/zip/ByteArrayDataBlock.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ByteArrayDataBlock.java new file mode 100644 index 0000000000..577cb2dc3b --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ByteArrayDataBlock.java @@ -0,0 +1,56 @@ +/* + * 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.zip; + +import java.io.IOException; +import java.nio.ByteBuffer; + +/** + * {@link DataBlock} backed by a byte array . + * + * @author Phillip Webb + */ +class ByteArrayDataBlock implements DataBlock { + + private final byte[] bytes; + + /** + * Create a new {@link ByteArrayDataBlock} backed by the given bytes. + * @param bytes the bytes to use + */ + ByteArrayDataBlock(byte... bytes) { + this.bytes = bytes; + } + + @Override + public long size() throws IOException { + return this.bytes.length; + } + + @Override + public int read(ByteBuffer dst, long pos) throws IOException { + return read(dst, (int) pos); + } + + private int read(ByteBuffer dst, int pos) { + int remaining = dst.remaining(); + int length = Math.min(this.bytes.length - pos, remaining); + dst.put(this.bytes, pos, length); + return length; + } + +} 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/zip/CloseableDataBlock.java similarity index 65% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/Bytes.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/CloseableDataBlock.java index d46a22555d..6303daf4dc 100644 --- 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/zip/CloseableDataBlock.java @@ -14,24 +14,16 @@ * limitations under the License. */ -package org.springframework.boot.loader.jar; +package org.springframework.boot.loader.zip; + +import java.io.Closeable; /** - * Utilities for dealing with bytes from ZIP files. + * A {@link Closeable} {@link DataBlock}. * * @author Phillip Webb + * @since 3.2.0 */ -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; - } +public interface CloseableDataBlock extends DataBlock, Closeable { } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/DataBlock.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/DataBlock.java new file mode 100644 index 0000000000..7475b67173 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/DataBlock.java @@ -0,0 +1,81 @@ +/* + * 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.zip; + +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; + +/** + * Provides read access to a block of data contained somewhere in a zip file. + * + * @author Phillip Webb + * @since 3.2.0 + */ +public interface DataBlock { + + /** + * Return the size of this block. + * @return the block size + * @throws IOException on I/O error + */ + long size() throws IOException; + + /** + * Read a sequence of bytes from this channel into the given buffer, starting at the + * given block position. + * @param dst the buffer into which bytes are to be transferred + * @param pos the position within the block at which the transfer is to begin + * @return the number of bytes read, possibly zero, or {@code -1} if the given + * position is greater than or equal to the block size + * @throws IOException on I/O error + * @see #readFully(ByteBuffer, long) + * @see FileChannel#read(ByteBuffer, long) + */ + int read(ByteBuffer dst, long pos) throws IOException; + + /** + * Fully read a sequence of bytes from this channel into the given buffer, starting at + * the given block position and filling {@link ByteBuffer#remaining() remaining} bytes + * in the buffer. + * @param dst the buffer into which bytes are to be transferred + * @param pos the position within the block at which the transfer is to begin + * @throws EOFException if an attempt is made to read past the end of the block + * @throws IOException on I/O error + */ + default void readFully(ByteBuffer dst, long pos) throws IOException { + do { + int count = read(dst, pos); + if (count <= 0) { + throw new EOFException(); + } + pos += count; + } + while (dst.hasRemaining()); + } + + /** + * Return this {@link DataBlock} as an {@link InputStream}. + * @return an {@link InputStream} to read the data block content + */ + default InputStream asInputStream() { + return new DataBlockInputStream(this); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/DataBlockInputStream.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/DataBlockInputStream.java new file mode 100644 index 0000000000..a05ae60f3e --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/DataBlockInputStream.java @@ -0,0 +1,110 @@ +/* + * 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.zip; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.util.zip.ZipException; + +/** + * {@link InputStream} backed by a {@link DataBlock}. + * + * @author Phillip Webb + */ +class DataBlockInputStream extends InputStream { + + private final DataBlock dataBlock; + + private long pos; + + private long remaining; + + private volatile boolean closing; + + DataBlockInputStream(DataBlock dataBlock) { + this.dataBlock = dataBlock; + } + + @Override + public int read() throws IOException { + byte[] b = new byte[1]; + return (read(b, 0, 1) == 1) ? b[0] & 0xFF : -1; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + int result; + ensureOpen(); + ByteBuffer dst = ByteBuffer.wrap(b, off, len); + int count = this.dataBlock.read(dst, this.pos); + if (count > 0) { + this.pos += count; + this.remaining -= count; + } + result = count; + if (this.remaining == 0) { + close(); + } + return result; + } + + @Override + public long skip(long n) throws IOException { + long result; + result = (n > 0) ? maxForwardSkip(n) : maxBackwardSkip(n); + this.pos += result; + this.remaining -= result; + if (this.remaining == 0) { + close(); + } + return result; + } + + private long maxForwardSkip(long n) { + boolean willCauseOverflow = (this.pos + n) < 0; + return (willCauseOverflow || n > this.remaining) ? this.remaining : n; + } + + private long maxBackwardSkip(long n) { + return Math.max(-this.pos, n); + } + + @Override + public int available() { + return (this.remaining < Integer.MAX_VALUE) ? (int) this.remaining : Integer.MAX_VALUE; + } + + private void ensureOpen() throws ZipException { + if (this.closing) { + throw new ZipException("InputStream closed"); + } + } + + @Override + public void close() throws IOException { + if (this.closing) { + return; + } + this.closing = true; + if (this.dataBlock instanceof Closeable closeable) { + closeable.close(); + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/FileChannelDataBlock.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/FileChannelDataBlock.java new file mode 100644 index 0000000000..1824281ede --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/FileChannelDataBlock.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.zip; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.ClosedChannelException; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.function.Supplier; + +import org.springframework.boot.loader.log.DebugLogger; + +/** + * Reference counted {@link DataBlock} implementation backed by a {@link FileChannel} with + * support for slicing. + * + * @author Phillip Webb + */ +class FileChannelDataBlock implements CloseableDataBlock { + + private static final DebugLogger debug = DebugLogger.get(FileChannelDataBlock.class); + + static Tracker tracker; + + private final ManagedFileChannel channel; + + private final long offset; + + private final long size; + + FileChannelDataBlock(Path path) throws IOException { + this.channel = new ManagedFileChannel(path); + this.offset = 0; + this.size = Files.size(path); + } + + FileChannelDataBlock(ManagedFileChannel channel, long offset, long size) { + this.channel = channel; + this.offset = offset; + this.size = size; + } + + @Override + public long size() throws IOException { + return this.size; + } + + @Override + public int read(ByteBuffer dst, long pos) throws IOException { + if (pos < 0) { + throw new IllegalArgumentException("Position must not be negative"); + } + ensureOpen(ClosedChannelException::new); + int remaining = (int) (this.size - pos); + if (remaining <= 0) { + return -1; + } + int originalDestinationLimit = -1; + if (dst.remaining() > remaining) { + originalDestinationLimit = dst.limit(); + dst.limit(dst.position() + remaining); + } + int result = this.channel.read(dst, this.offset + pos); + if (originalDestinationLimit != -1) { + dst.limit(originalDestinationLimit); + } + return result; + } + + /** + * Open a connection to this block, increasing the reference count and re-opening the + * underlying file channel if necessary. + * @throws IOException on I/O error + */ + void open() throws IOException { + this.channel.open(); + } + + /** + * Close a connection to this block, decreasing the reference count and closing the + * underlying file channel if necessary. + * @throws IOException on I/O error + */ + @Override + public void close() throws IOException { + this.channel.close(); + } + + /** + * Ensure that the underlying file channel is currently open. + * @param exceptionSupplier a supplier providing the exception to throw + * @param the exception type + * @throws E if the channel is closed + */ + void ensureOpen(Supplier exceptionSupplier) throws E { + this.channel.ensureOpen(exceptionSupplier); + } + + /** + * Return a new {@link FileChannelDataBlock} slice providing access to a subset of the + * data. The caller is responsible for calling {@link #open()} and {@link #close()} on + * the returned block. + * @param offset the start offset for the slice relative to this block + * @return a new {@link FileChannelDataBlock} instance + * @throws IOException on I/O error + */ + FileChannelDataBlock slice(long offset) throws IOException { + return slice(offset, this.size - offset); + } + + /** + * Return a new {@link FileChannelDataBlock} slice providing access to a subset of the + * data. The caller is responsible for calling {@link #open()} and {@link #close()} on + * the returned block. + * @param offset the start offset for the slice relative to this block + * @param size the size of the new slice + * @return a new {@link FileChannelDataBlock} instance + */ + FileChannelDataBlock slice(long offset, long size) { + if (offset == 0 && size == this.size) { + return this; + } + if (offset < 0) { + throw new IllegalArgumentException("Offset must not be negative"); + } + if (size < 0 || offset + size > this.size) { + throw new IllegalArgumentException("Size must not be negative and must be within bounds"); + } + debug.log("Slicing %s at %s with size %s", this.channel, offset, size); + return new FileChannelDataBlock(this.channel, this.offset + offset, size); + } + + /** + * Manages access to underlying {@link FileChannel}. + */ + static class ManagedFileChannel { + + static final int BUFFER_SIZE = 1024 * 10; + + private final Path path; + + private int referenceCount; + + private FileChannel fileChannel; + + private ByteBuffer buffer; + + private long bufferPosition = -1; + + private int bufferSize; + + private final Object lock = new Object(); + + ManagedFileChannel(Path path) { + if (!Files.isRegularFile(path)) { + throw new IllegalArgumentException(path + " must be a regular file"); + } + this.path = path; + } + + int read(ByteBuffer dst, long position) throws IOException { + synchronized (this.lock) { + if (position < this.bufferPosition || position >= this.bufferPosition + this.bufferSize) { + this.buffer.clear(); + this.bufferSize = this.fileChannel.read(this.buffer, position); + this.bufferPosition = position; + } + if (this.bufferSize <= 0) { + return this.bufferSize; + } + int offset = (int) (position - this.bufferPosition); + int length = Math.min(this.bufferSize - offset, dst.remaining()); + dst.put(dst.position(), this.buffer, offset, length); + dst.position(dst.position() + length); + return length; + } + } + + void open() throws IOException { + synchronized (this.lock) { + if (this.referenceCount == 0) { + debug.log("Opening '%s'", this.path); + this.fileChannel = FileChannel.open(this.path, StandardOpenOption.READ); + this.buffer = ByteBuffer.allocateDirect(BUFFER_SIZE); + if (tracker != null) { + tracker.openedFileChannel(this.path, this.fileChannel); + } + } + this.referenceCount++; + debug.log("Reference count for '%s' incremented to %s", this.path, this.referenceCount); + } + } + + void close() throws IOException { + synchronized (this.lock) { + if (this.referenceCount == 0) { + return; + } + this.referenceCount--; + if (this.referenceCount == 0) { + debug.log("Closing '%s'", this.path); + this.buffer = null; + this.bufferPosition = -1; + this.bufferSize = 0; + this.fileChannel.close(); + if (tracker != null) { + tracker.closedFileChannel(this.path, this.fileChannel); + } + this.fileChannel = null; + } + debug.log("Reference count for '%s' decremented to %s", this.path, this.referenceCount); + } + } + + void ensureOpen(Supplier exceptionSupplier) throws E { + synchronized (this.lock) { + if (this.referenceCount == 0) { + throw exceptionSupplier.get(); + } + } + } + + @Override + public String toString() { + return this.path.toString(); + } + + } + + /** + * Internal tracker used to check open and closing of files in tests. + */ + interface Tracker { + + void openedFileChannel(Path path, FileChannel fileChannel); + + void closedFileChannel(Path path, FileChannel fileChannel); + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/NameOffsetLookups.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/NameOffsetLookups.java new file mode 100644 index 0000000000..d3014448c5 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/NameOffsetLookups.java @@ -0,0 +1,72 @@ +/* + * 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.zip; + +import java.util.BitSet; + +/** + * Tracks entries that have a name that should be offset by a specific amount. This class + * is used with nested directory zip files so that entries under the directory are offset + * correctly. META-INF entries are copied directly and have no offset. + * + * @author Phillip Webb + */ +class NameOffsetLookups { + + public static final NameOffsetLookups NONE = new NameOffsetLookups(0, 0); + + private final int offset; + + private final BitSet enabled; + + NameOffsetLookups(int offset, int size) { + this.offset = offset; + this.enabled = (size != 0) ? new BitSet(size) : null; + } + + void swap(int i, int j) { + if (this.enabled != null) { + boolean temp = this.enabled.get(i); + this.enabled.set(i, this.enabled.get(j)); + this.enabled.set(j, temp); + } + } + + int get(int index) { + return isEnabled(index) ? this.offset : 0; + } + + int enable(int index, boolean enable) { + if (this.enabled != null) { + this.enabled.set(index, enable); + } + return (!enable) ? 0 : this.offset; + } + + boolean isEnabled(int index) { + return (this.enabled != null && this.enabled.get(index)); + } + + boolean hasAnyEnabled() { + return this.enabled != null && this.enabled.cardinality() > 0; + } + + NameOffsetLookups emptyCopy() { + return new NameOffsetLookups(this.offset, this.enabled.size()); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/VirtualDataBlock.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/VirtualDataBlock.java new file mode 100644 index 0000000000..e8d8838700 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/VirtualDataBlock.java @@ -0,0 +1,92 @@ +/* + * 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.zip; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Collection; +import java.util.List; + +/** + * A virtual {@link DataBlock} build from a collection of other {@link DataBlock} + * instances. + * + * @author Phillip Webb + */ +class VirtualDataBlock implements DataBlock { + + private List parts; + + private long size; + + /** + * Create a new {@link VirtualDataBlock} instance. The {@link #setParts(Collection)} + * method must be called before the data block can be used. + */ + protected VirtualDataBlock() { + } + + /** + * Create a new {@link VirtualDataBlock} backed by the given parts. + * @param parts the parts that make up the virtual data block + * @throws IOException in I/O error + */ + VirtualDataBlock(Collection parts) throws IOException { + setParts(parts); + } + + /** + * Set the parts that make up the virtual data block. + * @param parts the data block parts + * @throws IOException on I/O error + */ + protected void setParts(Collection parts) throws IOException { + this.parts = List.copyOf(parts); + long size = 0; + for (DataBlock part : parts) { + size += part.size(); + } + this.size = size; + } + + @Override + public long size() throws IOException { + return this.size; + } + + @Override + public int read(ByteBuffer dst, long pos) throws IOException { + if (pos < 0 || pos >= this.size) { + return -1; + } + long offset = 0; + int result = 0; + for (DataBlock part : this.parts) { + while (pos >= offset && pos < offset + part.size()) { + int count = part.read(dst, pos - offset); + result += Math.max(count, 0); + if (count <= 0 || !dst.hasRemaining()) { + return result; + } + pos += count; + } + offset += part.size(); + } + return result; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/VirtualZipDataBlock.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/VirtualZipDataBlock.java new file mode 100644 index 0000000000..21021da25e --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/VirtualZipDataBlock.java @@ -0,0 +1,140 @@ +/* + * 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.zip; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.file.FileSystem; +import java.util.ArrayList; +import java.util.List; + +/** + * {@link DataBlock} that creates a virtual zip. This class allows us to create virtual + * zip files that can be parsed by regular JDK classes such as the zip {@link FileSystem}. + * + * @author Phillip Webb + */ +class VirtualZipDataBlock extends VirtualDataBlock implements CloseableDataBlock { + + private final FileChannelDataBlock data; + + /** + * Create a new {@link VirtualZipDataBlock} for the given entries. + * @param data the source zip data + * @param nameOffsetLookups the name offsets to apply + * @param centralRecords the records that should be copied to the virtual zip + * @param centralRecordPositions the record positions in the data block. + * @throws IOException on I/O error + */ + VirtualZipDataBlock(FileChannelDataBlock data, NameOffsetLookups nameOffsetLookups, + ZipCentralDirectoryFileHeaderRecord[] centralRecords, long[] centralRecordPositions) throws IOException { + this.data = data; + List parts = new ArrayList<>(); + List centralParts = new ArrayList<>(); + long offset = 0; + long sizeOfCentralDirectory = 0; + for (int i = 0; i < centralRecords.length; i++) { + ZipCentralDirectoryFileHeaderRecord centralRecord = centralRecords[i]; + int nameOffset = nameOffsetLookups.get(i); + long centralRecordPos = centralRecordPositions[i]; + DataBlock name = new DataPart( + centralRecordPos + ZipCentralDirectoryFileHeaderRecord.FILE_NAME_OFFSET + nameOffset, + (centralRecord.fileNameLength() & 0xFFFF) - nameOffset); + ZipLocalFileHeaderRecord localRecord = ZipLocalFileHeaderRecord.load(this.data, + centralRecord.offsetToLocalHeader()); + DataBlock content = new DataPart(centralRecord.offsetToLocalHeader() + localRecord.size(), + centralRecord.compressedSize()); + sizeOfCentralDirectory += addToCentral(centralParts, centralRecord, centralRecordPos, name, (int) offset); + offset += addToLocal(parts, localRecord, name, content); + } + parts.addAll(centralParts); + ZipEndOfCentralDirectoryRecord eocd = new ZipEndOfCentralDirectoryRecord((short) centralRecords.length, + (int) sizeOfCentralDirectory, (int) offset); + parts.add(new ByteArrayDataBlock(eocd.asByteArray())); + setParts(parts); + } + + private long addToCentral(List parts, ZipCentralDirectoryFileHeaderRecord originalRecord, + long originalRecordPos, DataBlock name, int offsetToLocalHeader) throws IOException { + ZipCentralDirectoryFileHeaderRecord record = originalRecord.withFileNameLength((short) (name.size() & 0xFFFF)) + .withOffsetToLocalHeader(offsetToLocalHeader); + int originalExtraFieldLength = originalRecord.extraFieldLength() & 0xFFFF; + int originalFileCommentLength = originalRecord.fileCommentLength() & 0xFFFF; + DataBlock extraFieldAndComment = new DataPart( + originalRecordPos + originalRecord.size() - originalExtraFieldLength - originalFileCommentLength, + originalExtraFieldLength + originalFileCommentLength); + parts.add(new ByteArrayDataBlock(record.asByteArray())); + parts.add(name); + parts.add(extraFieldAndComment); + return record.size(); + } + + private long addToLocal(List parts, ZipLocalFileHeaderRecord originalRecord, DataBlock name, + DataBlock content) throws IOException { + ZipLocalFileHeaderRecord record = originalRecord.withExtraFieldLength((short) 0) + .withFileNameLength((short) (name.size() & 0xFFFF)); + parts.add(new ByteArrayDataBlock(record.asByteArray())); + parts.add(name); + parts.add(content); + return record.size() + content.size(); + } + + @Override + public void close() throws IOException { + this.data.close(); + } + + /** + * {@link DataBlock} that points to part of the original data block. + */ + final class DataPart implements DataBlock { + + private final long offset; + + private final long size; + + DataPart(long offset, long size) { + this.offset = offset; + this.size = size; + } + + @Override + public long size() throws IOException { + return this.size; + } + + @Override + public int read(ByteBuffer dst, long pos) throws IOException { + int remaining = (int) (this.size - pos); + if (remaining <= 0) { + return -1; + } + int originalLimit = -1; + if (dst.remaining() > remaining) { + originalLimit = dst.limit(); + dst.limit(dst.position() + remaining); + } + int result = VirtualZipDataBlock.this.data.read(dst, this.offset + pos); + if (originalLimit != -1) { + dst.limit(originalLimit); + } + return result; + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryLocator.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryLocator.java new file mode 100644 index 0000000000..078c5ad81d --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryLocator.java @@ -0,0 +1,80 @@ +/* + * 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.zip; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import org.springframework.boot.loader.log.DebugLogger; + +/** + * A Zip64 end of central directory locator. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @param pos the position where this record begins in the source {@link DataBlock} + * @param numberOfThisDisk the number of the disk with the start of the zip64 end of + * central directory + * @param offsetToZip64EndOfCentralDirectoryRecord the relative offset of the zip64 end of + * central directory record + * @param totalNumberOfDisks the total number of disks + * @see Chapter + * 4.3.15 of the Zip File Format Specification + */ +record Zip64EndOfCentralDirectoryLocator(long pos, int numberOfThisDisk, long offsetToZip64EndOfCentralDirectoryRecord, + int totalNumberOfDisks) { + + private static final DebugLogger debug = DebugLogger.get(Zip64EndOfCentralDirectoryLocator.class); + + private static final int SIGNATURE = 0x07064b50; + + /** + * The size of this record. + */ + static final int SIZE = 20; + + /** + * Return the {@link Zip64EndOfCentralDirectoryLocator} or {@code null} if this is not + * a Zip64 file. + * @param dataBlock the source data block + * @param endOfCentralDirectoryPos the {@link ZipEndOfCentralDirectoryRecord} position + * @return a {@link Zip64EndOfCentralDirectoryLocator} instance or null + * @throws IOException on I/O error + */ + static Zip64EndOfCentralDirectoryLocator find(DataBlock dataBlock, long endOfCentralDirectoryPos) + throws IOException { + debug.log("Finding Zip64EndOfCentralDirectoryLocator from EOCD at %s", endOfCentralDirectoryPos); + long pos = endOfCentralDirectoryPos - SIZE; + if (pos < 0) { + debug.log("No Zip64EndOfCentralDirectoryLocator due to negative position %s", pos); + return null; + } + ByteBuffer buffer = ByteBuffer.allocate(SIZE); + buffer.order(ByteOrder.LITTLE_ENDIAN); + dataBlock.read(buffer, pos); + buffer.rewind(); + int signature = buffer.getInt(); + if (signature != SIGNATURE) { + debug.log("Found incorrect Zip64EndOfCentralDirectoryLocator signature %s at position %s", signature, pos); + return null; + } + debug.log("Found Zip64EndOfCentralDirectoryLocator at position %s", pos); + return new Zip64EndOfCentralDirectoryLocator(pos, buffer.getInt(), buffer.getLong(), buffer.getInt()); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryRecord.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryRecord.java new file mode 100644 index 0000000000..c593624ff9 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryRecord.java @@ -0,0 +1,89 @@ +/* + * 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.zip; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import org.springframework.boot.loader.log.DebugLogger; + +/** + * A Zip64 end of central directory record. + * + * @author Phillip Webb + * @param size the size of this record + * @param sizeOfZip64EndOfCentralDirectoryRecord the size of zip64 end of central + * directory record + * @param versionMadeBy the version that made the zip + * @param versionNeededToExtract the version needed to extract the zip + * @param numberOfThisDisk the number of this disk + * @param diskWhereCentralDirectoryStarts the disk where central directory starts + * @param numberOfCentralDirectoryEntriesOnThisDisk the number of central directory + * entries on this disk + * @param totalNumberOfCentralDirectoryEntries the total number of central directory + * entries + * @param sizeOfCentralDirectory the size of central directory (bytes) + * @param offsetToStartOfCentralDirectory the offset of start of central directory, + * relative to start of archive + * @see Chapter + * 4.3.14 of the Zip File Format Specification + */ +record Zip64EndOfCentralDirectoryRecord(long size, long sizeOfZip64EndOfCentralDirectoryRecord, short versionMadeBy, + short versionNeededToExtract, int numberOfThisDisk, int diskWhereCentralDirectoryStarts, + long numberOfCentralDirectoryEntriesOnThisDisk, long totalNumberOfCentralDirectoryEntries, + long sizeOfCentralDirectory, long offsetToStartOfCentralDirectory) { + + private static final DebugLogger debug = DebugLogger.get(Zip64EndOfCentralDirectoryRecord.class); + + private static final int SIGNATURE = 0x06064b50; + + private static final int MINIMUM_SIZE = 56; + + /** + * Load the {@link Zip64EndOfCentralDirectoryRecord} from the given data block based + * on the offset given in the locator. + * @param dataBlock the source data block + * @param locator the {@link Zip64EndOfCentralDirectoryLocator} or {@code null} + * @return a new {@link ZipCentralDirectoryFileHeaderRecord} instance or {@code null} + * if the locator is {@code null} + * @throws IOException on I/O error + */ + static Zip64EndOfCentralDirectoryRecord load(DataBlock dataBlock, Zip64EndOfCentralDirectoryLocator locator) + throws IOException { + if (locator == null) { + return null; + } + ByteBuffer buffer = ByteBuffer.allocate(MINIMUM_SIZE); + buffer.order(ByteOrder.LITTLE_ENDIAN); + long size = locator.pos() - locator.offsetToZip64EndOfCentralDirectoryRecord(); + long pos = locator.pos() - size; + debug.log("Loading Zip64EndOfCentralDirectoryRecord from position %s size %s", pos, size); + dataBlock.readFully(buffer, pos); + buffer.rewind(); + int signature = buffer.getInt(); + if (signature != SIGNATURE) { + debug.log("Found incorrect Zip64EndOfCentralDirectoryRecord signature %s at position %s", signature, pos); + throw new IOException("Zip64 'End Of Central Directory Record' not found at position " + pos + + ". Zip file is corrupt or includes prefixed bytes which are not supported with Zip64 files"); + } + return new Zip64EndOfCentralDirectoryRecord(size, buffer.getLong(), buffer.getShort(), buffer.getShort(), + buffer.getInt(), buffer.getInt(), buffer.getLong(), buffer.getLong(), buffer.getLong(), + buffer.getLong()); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipCentralDirectoryFileHeaderRecord.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipCentralDirectoryFileHeaderRecord.java new file mode 100644 index 0000000000..27f03587ae --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipCentralDirectoryFileHeaderRecord.java @@ -0,0 +1,211 @@ +/* + * 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.zip; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoField; +import java.time.temporal.ChronoUnit; +import java.time.temporal.ValueRange; +import java.util.zip.ZipEntry; + +import org.springframework.boot.loader.log.DebugLogger; + +/** + * A ZIP File "Central directory file header record" (CDFH). + * + * @author Phillip Webb + * @param versionMadeBy the version that made the zip + * @param versionNeededToExtract the version needed to extract the zip + * @param generalPurposeBitFlag the general purpose bit flag + * @param compressionMethod the compression method used for this entry + * @param lastModFileTime the last modified file time + * @param lastModFileDate the last modified file date + * @param crc32 the CRC32 checksum + * @param compressedSize the size of the entry when compressed + * @param uncompressedSize the size of the entry when uncompressed + * @param fileNameLength the file name length + * @param extraFieldLength the extra field length + * @param fileCommentLength the comment length + * @param diskNumberStart the disk number where the entry starts + * @param internalFileAttributes the internal file attributes + * @param externalFileAttributes the external file attributes + * @param offsetToLocalHeader the relative offset to the local file header + * @see Chapter + * 4.3.12 of the Zip File Format Specification + */ +record ZipCentralDirectoryFileHeaderRecord(short versionMadeBy, short versionNeededToExtract, + short generalPurposeBitFlag, short compressionMethod, short lastModFileTime, short lastModFileDate, int crc32, + int compressedSize, int uncompressedSize, short fileNameLength, short extraFieldLength, short fileCommentLength, + short diskNumberStart, short internalFileAttributes, int externalFileAttributes, int offsetToLocalHeader) { + + private static final DebugLogger debug = DebugLogger.get(ZipCentralDirectoryFileHeaderRecord.class); + + private static final int SIGNATURE = 0x02014b50; + + private static final int MINIMUM_SIZE = 46; + + /** + * The offset of the file name relative to the record start position. + */ + static final int FILE_NAME_OFFSET = MINIMUM_SIZE; + + /** + * Return the size of this record. + * @return the record size + */ + long size() { + return MINIMUM_SIZE + fileNameLength() + extraFieldLength() + fileCommentLength(); + } + + /** + * Copy values from this block to the given {@link ZipEntry}. + * @param dataBlock the source data block + * @param pos the position of this {@link ZipCentralDirectoryFileHeaderRecord} + * @param zipEntry the destination zip entry + * @throws IOException on I/O error + */ + void copyTo(DataBlock dataBlock, long pos, ZipEntry zipEntry) throws IOException { + int fileNameLength = fileNameLength() & 0xFFFF; + int extraLength = extraFieldLength() & 0xFFFF; + int commentLength = fileCommentLength() & 0xFFFF; + zipEntry.setMethod(compressionMethod() & 0xFFFF); + zipEntry.setTime(decodeMsDosFormatDateTime(lastModFileDate(), lastModFileTime())); + zipEntry.setCrc(crc32() & 0xFFFFFFFFL); + zipEntry.setCompressedSize(compressedSize() & 0xFFFFFFFFL); + zipEntry.setSize(uncompressedSize() & 0xFFFFFFFFL); + if (extraLength > 0) { + long extraPos = pos + MINIMUM_SIZE + fileNameLength; + ByteBuffer buffer = ByteBuffer.allocate(extraLength); + dataBlock.readFully(buffer, extraPos); + zipEntry.setExtra(buffer.array()); + } + if ((fileCommentLength() & 0xFFFF) > 0) { + long commentPos = MINIMUM_SIZE + fileNameLength + extraLength; + zipEntry.setComment(ZipString.readString(dataBlock, commentPos, commentLength)); + } + } + + /** + * Decode MS-DOS Date Time details. See + * Microsoft's documentation for more details of the format. + * @param date the date + * @param time the time + * @return the date and time as milliseconds since the epoch + */ + private long decodeMsDosFormatDateTime(short date, short time) { + int year = getChronoValue(((date >> 9) & 0x7f) + 1980, ChronoField.YEAR); + int month = getChronoValue((date >> 5) & 0x0f, ChronoField.MONTH_OF_YEAR); + int day = getChronoValue(date & 0x1f, ChronoField.DAY_OF_MONTH); + int hour = getChronoValue((time >> 11) & 0x1f, ChronoField.HOUR_OF_DAY); + int minute = getChronoValue((time >> 5) & 0x3f, ChronoField.MINUTE_OF_HOUR); + int second = getChronoValue((time << 1) & 0x3e, ChronoField.SECOND_OF_MINUTE); + return ZonedDateTime.of(year, month, day, hour, minute, second, 0, ZoneId.systemDefault()) + .toInstant() + .truncatedTo(ChronoUnit.SECONDS) + .toEpochMilli(); + } + + private static int getChronoValue(long value, ChronoField field) { + ValueRange range = field.range(); + return Math.toIntExact(Math.min(Math.max(value, range.getMinimum()), range.getMaximum())); + } + + /** + * Return a new {@link ZipCentralDirectoryFileHeaderRecord} with a new + * {@link #fileNameLength()}. + * @param fileNameLength the new file name length + * @return a new {@link ZipCentralDirectoryFileHeaderRecord} instance + */ + ZipCentralDirectoryFileHeaderRecord withFileNameLength(short fileNameLength) { + return (this.fileNameLength != fileNameLength) ? new ZipCentralDirectoryFileHeaderRecord(this.versionMadeBy, + this.versionNeededToExtract, this.generalPurposeBitFlag, this.compressionMethod, this.lastModFileTime, + this.lastModFileDate, this.crc32, this.compressedSize, this.uncompressedSize, fileNameLength, + this.extraFieldLength, this.fileCommentLength, this.diskNumberStart, this.internalFileAttributes, + this.externalFileAttributes, this.offsetToLocalHeader) : this; + } + + /** + * Return a new {@link ZipCentralDirectoryFileHeaderRecord} with a new + * {@link #offsetToLocalHeader()}. + * @param offsetToLocalHeader the new offset to local header + * @return a new {@link ZipCentralDirectoryFileHeaderRecord} instance + */ + ZipCentralDirectoryFileHeaderRecord withOffsetToLocalHeader(int offsetToLocalHeader) { + return (this.offsetToLocalHeader != offsetToLocalHeader) ? new ZipCentralDirectoryFileHeaderRecord( + this.versionMadeBy, this.versionNeededToExtract, this.generalPurposeBitFlag, this.compressionMethod, + this.lastModFileTime, this.lastModFileDate, this.crc32, this.compressedSize, this.uncompressedSize, + this.fileNameLength, this.extraFieldLength, this.fileCommentLength, this.diskNumberStart, + this.internalFileAttributes, this.externalFileAttributes, offsetToLocalHeader) : this; + } + + /** + * Return the contents of this record as a byte array suitable for writing to a zip. + * @return the record as a byte array + */ + byte[] asByteArray() { + ByteBuffer buffer = ByteBuffer.allocate(MINIMUM_SIZE); + buffer.order(ByteOrder.LITTLE_ENDIAN); + buffer.putInt(SIGNATURE); + buffer.putShort(this.versionMadeBy); + buffer.putShort(this.versionNeededToExtract); + buffer.putShort(this.generalPurposeBitFlag); + buffer.putShort(this.compressionMethod); + buffer.putShort(this.lastModFileTime); + buffer.putShort(this.lastModFileDate); + buffer.putInt(this.crc32); + buffer.putInt(this.compressedSize); + buffer.putInt(this.uncompressedSize); + buffer.putShort(this.fileNameLength); + buffer.putShort(this.extraFieldLength); + buffer.putShort(this.fileCommentLength); + buffer.putShort(this.diskNumberStart); + buffer.putShort(this.internalFileAttributes); + buffer.putInt(this.externalFileAttributes); + buffer.putInt(this.offsetToLocalHeader); + return buffer.array(); + } + + /** + * Load the {@link ZipCentralDirectoryFileHeaderRecord} from the given data block. + * @param dataBlock the source data block + * @param pos the position of the record + * @return a new {@link ZipCentralDirectoryFileHeaderRecord} instance + * @throws IOException on I/O error + */ + static ZipCentralDirectoryFileHeaderRecord load(DataBlock dataBlock, long pos) throws IOException { + debug.log("Loading CentralDirectoryFileHeaderRecord from position %s", pos); + ByteBuffer buffer = ByteBuffer.allocate(MINIMUM_SIZE); + buffer.order(ByteOrder.LITTLE_ENDIAN); + dataBlock.readFully(buffer, pos); + buffer.rewind(); + int signature = buffer.getInt(); + if (signature != SIGNATURE) { + debug.log("Found incorrect CentralDirectoryFileHeaderRecord signature %s at position %s", signature, pos); + throw new IOException("Zip 'Central Directory File Header Record' not found at position " + pos); + } + return new ZipCentralDirectoryFileHeaderRecord(buffer.getShort(), buffer.getShort(), buffer.getShort(), + buffer.getShort(), buffer.getShort(), buffer.getShort(), buffer.getInt(), buffer.getInt(), + buffer.getInt(), buffer.getShort(), buffer.getShort(), buffer.getShort(), buffer.getShort(), + buffer.getShort(), buffer.getInt(), buffer.getInt()); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipContent.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipContent.java new file mode 100644 index 0000000000..2130b0fc1e --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipContent.java @@ -0,0 +1,811 @@ +/* + * 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.zip; + +import java.io.Closeable; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.lang.ref.Cleaner.Cleanable; +import java.lang.ref.SoftReference; +import java.nio.ByteBuffer; +import java.nio.channels.ClosedChannelException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.zip.ZipEntry; + +import org.springframework.boot.loader.log.DebugLogger; + +/** + * Provides raw access to content from a regular or nested zip file. This class performs + * the low level parsing of a zip file and provide access to raw entry data that it + * contains. Unlike {@link java.util.zip.ZipFile}, this implementation can load content + * from a zip file nested inside another file as long as the entry is not compressed. + *

+ * In order to reduce memory consumption, this implementation stores only the the hash of + * the entry names, the central directory offsets and the original positions. Entries are + * stored internally in {@code hashCode} order so that a binary search can be used to + * quickly find an entry by name or determine if the zip file doesn't have a given entry. + *

+ * {@link ZipContent} for a typical Spring Boot application JAR will have somewhere in the + * region of 10,500 entries which should consume about 122K. + *

+ * {@link ZipContent} results are cached and it is assumed that zip content will not + * change once loaded. Entries and Strings are not cached and will be recreated on each + * access which may produce a lot of garbage. + *

+ * This implementation does not use {@link Cleanable} so care must be taken to release + * {@link ZipContent} resources. The {@link #close()} method should be called explicitly + * or by try-with-resources. Care must be take to only call close once. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @since 3.2.0 + */ +public final class ZipContent implements Closeable { + + private static final String META_INF = "META-INF/"; + + private static final byte[] SIGNATURE_SUFFIX = ".DSA".getBytes(StandardCharsets.UTF_8); + + private static final DebugLogger debug = DebugLogger.get(ZipContent.class); + + private static final Map cache = new ConcurrentHashMap<>(); + + private final Source source; + + private final FileChannelDataBlock data; + + private final long centralDirectoryPos; + + private final long commentPos; + + private final long commentLength; + + private final int[] lookupIndexes; + + private final int[] nameHashLookups; + + private final int[] relativeCentralDirectoryOffsetLookups; + + private final NameOffsetLookups nameOffsetLookups; + + private final boolean hasJarSignatureFile; + + private SoftReference virtualData; + + private SoftReference, Object>> info; + + private ZipContent(Source source, FileChannelDataBlock data, long centralDirectoryPos, long commentPos, + long commentLength, int[] lookupIndexes, int[] nameHashLookups, int[] relativeCentralDirectoryOffsetLookups, + NameOffsetLookups nameOffsetLookups, boolean hasJarSignatureFile) { + this.source = source; + this.data = data; + this.centralDirectoryPos = centralDirectoryPos; + this.commentPos = commentPos; + this.commentLength = commentLength; + this.lookupIndexes = lookupIndexes; + this.nameHashLookups = nameHashLookups; + this.relativeCentralDirectoryOffsetLookups = relativeCentralDirectoryOffsetLookups; + this.nameOffsetLookups = nameOffsetLookups; + this.hasJarSignatureFile = hasJarSignatureFile; + } + + /** + * Open a {@link DataBlock} containing the raw zip data. For container zip files, this + * may be smaller than the original file since additional bytes are permitted at the + * front of a zip file. For nested zip files, this will be only the contents of the + * nest zip. + *

+ * For nested directory zip files, a virtual data block will be created containing + * only the relevant content. + *

+ * To release resources, the {@link #close()} method of the data block should be + * called explicitly or by try-with-resources. + *

+ * The returned data block should not be accessed once {@link #close()} has been + * called. + * @return the zip data + * @throws IOException on I/O error + */ + public CloseableDataBlock openRawZipData() throws IOException { + this.data.open(); + return (!this.nameOffsetLookups.hasAnyEnabled()) ? this.data : getVirtualData(); + } + + private CloseableDataBlock getVirtualData() throws IOException { + CloseableDataBlock virtualData = (this.virtualData != null) ? this.virtualData.get() : null; + if (virtualData != null) { + return virtualData; + } + virtualData = createVirtualData(); + this.virtualData = new SoftReference<>(virtualData); + return virtualData; + } + + private CloseableDataBlock createVirtualData() throws IOException { + int size = size(); + NameOffsetLookups nameOffsetLookups = this.nameOffsetLookups.emptyCopy(); + ZipCentralDirectoryFileHeaderRecord[] centralRecords = new ZipCentralDirectoryFileHeaderRecord[size]; + long[] centralRecordPositions = new long[size]; + for (int i = 0; i < size; i++) { + int lookupIndex = ZipContent.this.lookupIndexes[i]; + long pos = getCentralDirectoryFileHeaderRecordPos(lookupIndex); + nameOffsetLookups.enable(i, this.nameOffsetLookups.isEnabled(lookupIndex)); + centralRecords[i] = ZipCentralDirectoryFileHeaderRecord.load(this.data, pos); + centralRecordPositions[i] = pos; + } + return new VirtualZipDataBlock(this.data, nameOffsetLookups, centralRecords, centralRecordPositions); + } + + /** + * Returns the number of entries in the ZIP file. + * @return the number of entries + */ + public int size() { + return this.lookupIndexes.length; + } + + /** + * Return the zip comment, if any. + * @return the comment or {@code null} + */ + public String getComment() { + try { + return ZipString.readString(this.data, this.commentPos, this.commentLength); + } + catch (UncheckedIOException ex) { + if (ex.getCause() instanceof ClosedChannelException) { + throw new IllegalStateException("Zip content closed", ex); + } + throw ex; + } + } + + /** + * Return the entry with the given name, if any. + * @param name the name of the entry to find + * @return the entry or {@code null} + */ + public Entry getEntry(CharSequence name) { + return getEntry(null, name); + } + + /** + * Return the entry with the given name, if any. + * @param namePrefix an optional prefix for the name + * @param name the name of the entry to find + * @return the entry or {@code null} + */ + public Entry getEntry(CharSequence namePrefix, CharSequence name) { + int nameHash = nameHash(namePrefix, name); + int lookupIndex = getFirstLookupIndex(nameHash); + int size = size(); + while (lookupIndex >= 0 && lookupIndex < size && this.nameHashLookups[lookupIndex] == nameHash) { + long pos = getCentralDirectoryFileHeaderRecordPos(lookupIndex); + ZipCentralDirectoryFileHeaderRecord centralRecord = loadZipCentralDirectoryFileHeaderRecord(pos); + if (hasName(lookupIndex, centralRecord, pos, namePrefix, name)) { + return new Entry(lookupIndex, centralRecord); + } + lookupIndex++; + } + return null; + } + + /** + * Return if an entry with the given name exists. + * @param namePrefix an optional prefix for the name + * @param name the name of the entry to find + * @return the entry or {@code null} + */ + public boolean hasEntry(CharSequence namePrefix, CharSequence name) { + int nameHash = nameHash(namePrefix, name); + int lookupIndex = getFirstLookupIndex(nameHash); + int size = size(); + while (lookupIndex >= 0 && lookupIndex < size && this.nameHashLookups[lookupIndex] == nameHash) { + long pos = getCentralDirectoryFileHeaderRecordPos(lookupIndex); + ZipCentralDirectoryFileHeaderRecord centralRecord = loadZipCentralDirectoryFileHeaderRecord(pos); + if (hasName(lookupIndex, centralRecord, pos, namePrefix, name)) { + return true; + } + lookupIndex++; + } + return false; + } + + /** + * Return the entry at the specified index. + * @param index the entry index + * @return the entry + * @throws IndexOutOfBoundsException if the index is out of bounds + */ + public Entry getEntry(int index) { + int lookupIndex = ZipContent.this.lookupIndexes[index]; + long pos = getCentralDirectoryFileHeaderRecordPos(lookupIndex); + ZipCentralDirectoryFileHeaderRecord centralRecord = loadZipCentralDirectoryFileHeaderRecord(pos); + return new Entry(lookupIndex, centralRecord); + } + + private ZipCentralDirectoryFileHeaderRecord loadZipCentralDirectoryFileHeaderRecord(long pos) { + try { + return ZipCentralDirectoryFileHeaderRecord.load(this.data, pos); + } + catch (IOException ex) { + if (ex instanceof ClosedChannelException) { + throw new IllegalStateException("Zip content closed", ex); + } + throw new UncheckedIOException(ex); + } + } + + private int nameHash(CharSequence namePrefix, CharSequence name) { + int nameHash = 0; + nameHash = (namePrefix != null) ? ZipString.hash(nameHash, namePrefix, false) : nameHash; + nameHash = ZipString.hash(nameHash, name, true); + return nameHash; + } + + private int getFirstLookupIndex(int nameHash) { + int lookupIndex = Arrays.binarySearch(this.nameHashLookups, 0, this.nameHashLookups.length, nameHash); + if (lookupIndex < 0) { + return -1; + } + while (lookupIndex > 0 && this.nameHashLookups[lookupIndex - 1] == nameHash) { + lookupIndex--; + } + return lookupIndex; + } + + private long getCentralDirectoryFileHeaderRecordPos(int lookupIndex) { + return this.centralDirectoryPos + this.relativeCentralDirectoryOffsetLookups[lookupIndex]; + } + + private boolean hasName(int lookupIndex, ZipCentralDirectoryFileHeaderRecord centralRecord, long pos, + CharSequence namePrefix, CharSequence name) { + int offset = this.nameOffsetLookups.get(lookupIndex); + pos += ZipCentralDirectoryFileHeaderRecord.FILE_NAME_OFFSET + offset; + int len = centralRecord.fileNameLength() - offset; + ByteBuffer buffer = ByteBuffer.allocate(ZipString.BUFFER_SIZE); + if (namePrefix != null) { + int startsWithNamePrefix = ZipString.startsWith(buffer, this.data, pos, len, namePrefix); + if (startsWithNamePrefix == -1) { + return false; + } + pos += startsWithNamePrefix; + len -= startsWithNamePrefix; + } + return ZipString.matches(buffer, this.data, pos, len, name, true); + } + + /** + * Get or compute information based on the {@link ZipContent}. + * @param the info type to get or compute + * @param type the info type to get or compute + * @param function the function used to compute the information + * @return the computed or existing information + */ + @SuppressWarnings("unchecked") + public I getInfo(Class type, Function function) { + Map, Object> info = (this.info != null) ? this.info.get() : null; + if (info == null) { + info = new ConcurrentHashMap<>(); + this.info = new SoftReference<>(info); + } + return (I) info.computeIfAbsent(type, (key) -> { + debug.log("Getting %s info from zip '%s'", type.getName(), this); + return function.apply(this); + }); + } + + /** + * Returns {@code true} if this zip contains a jar signature file + * ({@code META-INF/*.DSA}). + * @return if the zip contains a jar signature file + */ + public boolean hasJarSignatureFile() { + return this.hasJarSignatureFile; + } + + /** + * Close this jar file, releasing the underlying file if this was the last reference. + * @see java.io.Closeable#close() + */ + @Override + public void close() throws IOException { + this.data.close(); + } + + @Override + public String toString() { + return this.source.toString(); + } + + /** + * Open {@link ZipContent} from the specified path. The resulting {@link ZipContent} + * must be {@link #close() closed} by the caller. + * @param path the zip path + * @return a {@link ZipContent} instance + * @throws IOException on I/O error + */ + public static ZipContent open(Path path) throws IOException { + return open(new Source(path.toAbsolutePath(), null)); + } + + /** + * Open nested {@link ZipContent} from the specified path. The resulting + * {@link ZipContent} must be {@link #close() closed} by the caller. + * @param path the zip path + * @param nestedEntryName the nested entry name to open + * @return a {@link ZipContent} instance + * @throws IOException on I/O error + */ + public static ZipContent open(Path path, String nestedEntryName) throws IOException { + return open(new Source(path.toAbsolutePath(), nestedEntryName)); + } + + private static ZipContent open(Source source) throws IOException { + ZipContent zipContent = cache.get(source); + if (zipContent != null) { + debug.log("Opening existing cached zip content for %s", zipContent); + zipContent.data.open(); + return zipContent; + } + debug.log("Loading zip content from %s", source); + zipContent = Loader.load(source); + ZipContent previouslyCached = cache.putIfAbsent(source, zipContent); + if (previouslyCached != null) { + debug.log("Closing zip content from %s since cache was populated from another thread", source); + zipContent.close(); + previouslyCached.data.open(); + return previouslyCached; + } + return zipContent; + } + + /** + * The source of {@link ZipContent}. Used as a cache key. + * + * @param path the path of the zip or container zip + * @param nestedEntryName the name of the nested entry to use or {@code null} + */ + private record Source(Path path, String nestedEntryName) { + + /** + * Return if this is the source of a nested zip. + * @return if this is for a nested zip + */ + boolean isNested() { + return this.nestedEntryName != null; + } + + @Override + public String toString() { + return (!isNested()) ? path().toString() : path() + "[" + nestedEntryName() + "]"; + } + + } + + /** + * Internal class used to load the zip content create a new {@link ZipContent} + * instance. + */ + private static final class Loader { + + private final ByteBuffer buffer = ByteBuffer.allocate(ZipString.BUFFER_SIZE); + + private final Source source; + + private final FileChannelDataBlock data; + + private final long centralDirectoryPos; + + private final int[] index; + + private int[] nameHashLookups; + + private int[] relativeCentralDirectoryOffsetLookups; + + private final NameOffsetLookups nameOffsetLookups; + + private int cursor; + + private Loader(Source source, Entry directoryEntry, FileChannelDataBlock data, long centralDirectoryPos, + int maxSize) { + this.source = source; + this.data = data; + this.centralDirectoryPos = centralDirectoryPos; + this.index = new int[maxSize]; + this.nameHashLookups = new int[maxSize]; + this.relativeCentralDirectoryOffsetLookups = new int[maxSize]; + this.nameOffsetLookups = (directoryEntry != null) + ? new NameOffsetLookups(directoryEntry.getName().length(), maxSize) : NameOffsetLookups.NONE; + } + + private void add(ZipCentralDirectoryFileHeaderRecord centralRecord, long pos, boolean enableNameOffset) + throws IOException { + int nameOffset = this.nameOffsetLookups.enable(this.cursor, enableNameOffset); + int hash = ZipString.hash(this.buffer, this.data, + pos + ZipCentralDirectoryFileHeaderRecord.FILE_NAME_OFFSET + nameOffset, + centralRecord.fileNameLength() - nameOffset, true); + this.nameHashLookups[this.cursor] = hash; + this.relativeCentralDirectoryOffsetLookups[this.cursor] = (int) ((pos - this.centralDirectoryPos)); + this.index[this.cursor] = this.cursor; + this.cursor++; + } + + private ZipContent finish(long commentPos, long commentLength, boolean hasJarSignatureFile) { + if (this.cursor != this.nameHashLookups.length) { + this.nameHashLookups = Arrays.copyOf(this.nameHashLookups, this.cursor); + this.relativeCentralDirectoryOffsetLookups = Arrays.copyOf(this.relativeCentralDirectoryOffsetLookups, + this.cursor); + } + int size = this.nameHashLookups.length; + sort(0, size - 1); + int[] lookupIndexes = new int[size]; + for (int i = 0; i < size; i++) { + lookupIndexes[this.index[i]] = i; + } + return new ZipContent(this.source, this.data, this.centralDirectoryPos, commentPos, commentLength, + lookupIndexes, this.nameHashLookups, this.relativeCentralDirectoryOffsetLookups, + this.nameOffsetLookups, hasJarSignatureFile); + } + + private void sort(int left, int right) { + // Quick sort algorithm, uses nameHashCode as the source but sorts all arrays + if (left < right) { + int pivot = this.nameHashLookups[left + (right - left) / 2]; + int i = left; + int j = right; + while (i <= j) { + while (this.nameHashLookups[i] < pivot) { + i++; + } + while (this.nameHashLookups[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.index, i, j); + swap(this.nameHashLookups, i, j); + swap(this.relativeCentralDirectoryOffsetLookups, i, j); + this.nameOffsetLookups.swap(i, j); + } + + private static void swap(int[] array, int i, int j) { + int temp = array[i]; + array[i] = array[j]; + array[j] = temp; + } + + static ZipContent load(Source source) throws IOException { + if (!source.isNested()) { + return loadNonNested(source); + } + try (ZipContent zip = open(source.path())) { + Entry entry = zip.getEntry(source.nestedEntryName()); + if (entry == null) { + throw new IOException("Nested entry '%s' not found in container zip '%s'" + .formatted(source.nestedEntryName(), source.path())); + } + return (!entry.isDirectory()) ? loadNestedZip(source, entry) : loadNestedDirectory(source, zip, entry); + } + } + + private static ZipContent loadNonNested(Source source) throws IOException { + debug.log("Loading non-nested zip '%s'", source.path()); + return openAndLoad(source, new FileChannelDataBlock(source.path())); + } + + private static ZipContent loadNestedZip(Source source, Entry entry) throws IOException { + if (entry.centralRecord.compressionMethod() != ZipEntry.STORED) { + throw new IOException("Nested entry '%s' in container zip '%s' must not be compressed" + .formatted(source.nestedEntryName(), source.path())); + } + debug.log("Loading nested zip entry '%s' from '%s'", source.nestedEntryName(), source.path()); + return openAndLoad(source, entry.getContent()); + } + + private static ZipContent openAndLoad(Source source, FileChannelDataBlock data) throws IOException { + try { + data.open(); + return loadContent(source, data); + } + catch (IOException | RuntimeException ex) { + data.close(); + throw ex; + } + } + + private static ZipContent loadContent(Source source, FileChannelDataBlock data) throws IOException { + ZipEndOfCentralDirectoryRecord.Located locatedEocd = ZipEndOfCentralDirectoryRecord.load(data); + ZipEndOfCentralDirectoryRecord eocd = locatedEocd.endOfCentralDirectoryRecord(); + long eocdPos = locatedEocd.pos(); + Zip64EndOfCentralDirectoryLocator zip64Locator = Zip64EndOfCentralDirectoryLocator.find(data, eocdPos); + Zip64EndOfCentralDirectoryRecord zip64Eocd = Zip64EndOfCentralDirectoryRecord.load(data, zip64Locator); + data = data.slice(getStartOfZipContent(data, eocd, zip64Eocd)); + long centralDirectoryPos = (zip64Eocd != null) ? zip64Eocd.offsetToStartOfCentralDirectory() + : eocd.offsetToStartOfCentralDirectory(); + long numberOfEntries = (zip64Eocd != null) ? zip64Eocd.totalNumberOfCentralDirectoryEntries() + : eocd.totalNumberOfCentralDirectoryEntries(); + if (numberOfEntries > 0xFFFFFFFFL) { + throw new IllegalStateException("Too many zip entries in " + source); + } + Loader loader = new Loader(source, null, data, centralDirectoryPos, (int) (numberOfEntries & 0xFFFFFFFFL)); + ByteBuffer signatureNameSuffixBuffer = ByteBuffer.allocate(SIGNATURE_SUFFIX.length); + boolean hasJarSignatureFile = false; + long pos = centralDirectoryPos; + for (int i = 0; i < numberOfEntries; i++) { + ZipCentralDirectoryFileHeaderRecord centralRecord = ZipCentralDirectoryFileHeaderRecord.load(data, pos); + if (!hasJarSignatureFile) { + long filenamePos = pos + ZipCentralDirectoryFileHeaderRecord.FILE_NAME_OFFSET; + if (centralRecord.fileNameLength() > SIGNATURE_SUFFIX.length && ZipString.startsWith(loader.buffer, + data, filenamePos, centralRecord.fileNameLength(), META_INF) >= 0) { + signatureNameSuffixBuffer.clear(); + data.readFully(signatureNameSuffixBuffer, + filenamePos + centralRecord.fileNameLength() - SIGNATURE_SUFFIX.length); + hasJarSignatureFile = Arrays.equals(SIGNATURE_SUFFIX, signatureNameSuffixBuffer.array()); + } + } + loader.add(centralRecord, pos, false); + pos += centralRecord.size(); + } + long commentPos = locatedEocd.pos() + ZipEndOfCentralDirectoryRecord.COMMENT_OFFSET; + return loader.finish(commentPos, eocd.commentLength(), hasJarSignatureFile); + } + + /** + * 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 + * @param eocd the end of central directory record + * @param zip64Eocd the zip64 end of central directory record or {@code null} + * @return the offset within the data where the archive begins + * @throws IOException on I/O error + */ + private static long getStartOfZipContent(FileChannelDataBlock data, ZipEndOfCentralDirectoryRecord eocd, + Zip64EndOfCentralDirectoryRecord zip64Eocd) throws IOException { + long specifiedOffsetToStartOfCentralDirectory = (zip64Eocd != null) + ? zip64Eocd.offsetToStartOfCentralDirectory() : eocd.offsetToStartOfCentralDirectory(); + long sizeOfCentralDirectoryAndEndRecords = getSizeOfCentralDirectoryAndEndRecords(eocd, zip64Eocd); + long actualOffsetToStartOfCentralDirectory = data.size() - sizeOfCentralDirectoryAndEndRecords; + return actualOffsetToStartOfCentralDirectory - specifiedOffsetToStartOfCentralDirectory; + } + + private static long getSizeOfCentralDirectoryAndEndRecords(ZipEndOfCentralDirectoryRecord eocd, + Zip64EndOfCentralDirectoryRecord zip64Eocd) { + long result = 0; + result += eocd.size(); + if (zip64Eocd != null) { + result += Zip64EndOfCentralDirectoryLocator.SIZE; + result += zip64Eocd.size(); + } + result += (zip64Eocd != null) ? zip64Eocd.sizeOfCentralDirectory() : eocd.sizeOfCentralDirectory(); + return result; + } + + private static ZipContent loadNestedDirectory(Source source, ZipContent zip, Entry directoryEntry) + throws IOException { + debug.log("Loading nested directry entry '%s' from '%s'", source.nestedEntryName(), source.path()); + if (!source.nestedEntryName().endsWith("/")) { + throw new IllegalArgumentException("Nested entry name must end with '/'"); + } + String directoryName = directoryEntry.getName(); + zip.data.open(); + try { + Loader loader = new Loader(source, directoryEntry, zip.data, zip.centralDirectoryPos, zip.size()); + for (int cursor = 0; cursor < zip.size(); cursor++) { + int index = zip.lookupIndexes[cursor]; + if (index != directoryEntry.getLookupIndex()) { + long pos = zip.getCentralDirectoryFileHeaderRecordPos(index); + ZipCentralDirectoryFileHeaderRecord centralRecord = ZipCentralDirectoryFileHeaderRecord + .load(zip.data, pos); + long namePos = pos + ZipCentralDirectoryFileHeaderRecord.FILE_NAME_OFFSET; + short nameLen = centralRecord.fileNameLength(); + if (ZipString.startsWith(loader.buffer, zip.data, namePos, nameLen, META_INF) != -1) { + loader.add(centralRecord, pos, false); + } + else if (ZipString.startsWith(loader.buffer, zip.data, namePos, nameLen, directoryName) != -1) { + loader.add(centralRecord, pos, true); + } + } + } + return loader.finish(zip.commentPos, zip.commentLength, zip.hasJarSignatureFile); + } + catch (IOException | RuntimeException ex) { + zip.data.close(); + throw ex; + } + } + + } + + /** + * A single zip content entry. + */ + public class Entry { + + private final int lookupIndex; + + private final ZipCentralDirectoryFileHeaderRecord centralRecord; + + private volatile String name; + + private volatile FileChannelDataBlock content; + + /** + * Create a new {@link Entry} instance. + * @param lookupIndex the lookup index of the entry + * @param centralRecord the {@link ZipCentralDirectoryFileHeaderRecord} for the + * entry + */ + Entry(int lookupIndex, ZipCentralDirectoryFileHeaderRecord centralRecord) { + this.lookupIndex = lookupIndex; + this.centralRecord = centralRecord; + } + + /** + * Return the lookup index of the entry. Each entry has a unique lookup index but + * they aren't the same as the order that the entry was loaded. + * @return the entry lookup index + */ + public int getLookupIndex() { + return this.lookupIndex; + } + + /** + * Return {@code true} if this is a directory entry. + * @return if the entry is a directory + */ + public boolean isDirectory() { + return getName().endsWith("/"); + } + + /** + * Returns {@code true} if this entry has a name starting with the given prefix. + * @param prefix the required prefix + * @return if the entry name starts with the prefix + */ + public boolean hasNameStartingWith(CharSequence prefix) { + String name = this.name; + if (name != null) { + return name.startsWith(prefix.toString()); + } + long pos = getCentralDirectoryFileHeaderRecordPos(this.lookupIndex) + + ZipCentralDirectoryFileHeaderRecord.FILE_NAME_OFFSET; + return ZipString.startsWith(null, ZipContent.this.data, pos, this.centralRecord.fileNameLength(), + prefix) != -1; + } + + /** + * Return the name of this entry. + * @return the entry name + */ + public String getName() { + String name = this.name; + if (name == null) { + int offset = ZipContent.this.nameOffsetLookups.get(this.lookupIndex); + long pos = getCentralDirectoryFileHeaderRecordPos(this.lookupIndex) + + ZipCentralDirectoryFileHeaderRecord.FILE_NAME_OFFSET + offset; + name = ZipString.readString(ZipContent.this.data, pos, this.centralRecord.fileNameLength() - offset); + this.name = name; + } + return name; + } + + /** + * Return the compression method for this entry. + * @return the compression method + * @see ZipEntry#STORED + * @see ZipEntry#DEFLATED + */ + public int getCompressionMethod() { + return this.centralRecord.compressionMethod(); + } + + /** + * Return the uncompressed size of this entry. + * @return the uncompressed size + */ + public int getUncompressedSize() { + return this.centralRecord.uncompressedSize(); + } + + /** + * Open a {@link DataBlock} providing access to raw contents of the entry (not + * including the local file header). + *

+ * To release resources, the {@link #close()} method of the data block should be + * called explicitly or by try-with-resources. + * @return the contents of the entry + * @throws IOException on I/O error + */ + public CloseableDataBlock openContent() throws IOException { + FileChannelDataBlock content = getContent(); + content.open(); + return content; + } + + private FileChannelDataBlock getContent() throws IOException { + FileChannelDataBlock content = this.content; + if (content == null) { + int pos = this.centralRecord.offsetToLocalHeader(); + checkNotZip64Extended(pos); + ZipLocalFileHeaderRecord localHeader = ZipLocalFileHeaderRecord.load(ZipContent.this.data, pos); + int size = this.centralRecord.compressedSize(); + checkNotZip64Extended(size); + content = ZipContent.this.data.slice(pos + localHeader.size(), size); + this.content = content; + } + return content; + } + + private void checkNotZip64Extended(int value) throws IOException { + if (value == 0xFFFFFFFF) { + throw new IOException("Zip64 extended information extra fields are not supported"); + } + } + + /** + * Adapt the raw entry into a {@link ZipEntry} or {@link ZipEntry} subclass. + * @param the entry type + * @param factory the factory used to create the {@link ZipEntry} + * @return a fully populated zip entry + */ + public E as(Function factory) { + return as((entry, name) -> factory.apply(name)); + } + + /** + * Adapt the raw entry into a {@link ZipEntry} or {@link ZipEntry} subclass. + * @param the entry type + * @param factory the factory used to create the {@link ZipEntry} + * @return a fully populated zip entry + */ + public E as(BiFunction factory) { + try { + E result = factory.apply(this, getName()); + long pos = getCentralDirectoryFileHeaderRecordPos(this.lookupIndex); + this.centralRecord.copyTo(ZipContent.this.data, pos, result); + return result; + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipEndOfCentralDirectoryRecord.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipEndOfCentralDirectoryRecord.java new file mode 100644 index 0000000000..af2d8e57bf --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipEndOfCentralDirectoryRecord.java @@ -0,0 +1,160 @@ +/* + * 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.zip; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import org.springframework.boot.loader.log.DebugLogger; + +/** + * A ZIP File "End of central directory record" (EOCD). + * + * @author Phillip Webb + * @param numberOfThisDisk the number of this disk (or 0xffff for Zip64) + * @param diskWhereCentralDirectoryStarts the disk where central directory starts (or + * 0xffff for Zip64) + * @param numberOfCentralDirectoryEntriesOnThisDisk the number of central directory + * entries on this disk (or 0xffff for Zip64) + * @param totalNumberOfCentralDirectoryEntries the total number of central directory + * entries (or 0xffff for Zip64) + * @param sizeOfCentralDirectory the size of central directory (bytes) (or 0xffffffff for + * Zip64) + * @param offsetToStartOfCentralDirectory the offset of start of central directory, + * relative to start of archive (or 0xffffffff for Zip64) + * @param commentLength the length of the comment field + * @see Chapter + * 4.3.16 of the Zip File Format Specification + */ +record ZipEndOfCentralDirectoryRecord(short numberOfThisDisk, short diskWhereCentralDirectoryStarts, + short numberOfCentralDirectoryEntriesOnThisDisk, short totalNumberOfCentralDirectoryEntries, + int sizeOfCentralDirectory, int offsetToStartOfCentralDirectory, short commentLength) { + + ZipEndOfCentralDirectoryRecord(short totalNumberOfCentralDirectoryEntries, int sizeOfCentralDirectory, + int offsetToStartOfCentralDirectory) { + this((short) 0, (short) 0, totalNumberOfCentralDirectoryEntries, totalNumberOfCentralDirectoryEntries, + sizeOfCentralDirectory, offsetToStartOfCentralDirectory, (short) 0); + } + + private static final DebugLogger debug = DebugLogger.get(ZipEndOfCentralDirectoryRecord.class); + + private static final int SIGNATURE = 0x06054b50; + + private static final int MAXIMUM_COMMENT_LENGTH = 0xFFFF; + + private static final int MINIMUM_SIZE = 22; + + private static final int MAXIMUM_SIZE = MINIMUM_SIZE + MAXIMUM_COMMENT_LENGTH; + + static final int BUFFER_SIZE = 256; + + /** + * The offset of the file comment relative to the record start position. + */ + static final int COMMENT_OFFSET = MINIMUM_SIZE; + + /** + * Return the size of this record. + * @return the record size + */ + long size() { + return MINIMUM_SIZE + this.commentLength; + } + + /** + * Return the contents of this record as a byte array suitable for writing to a zip. + * @return the record as a byte array + */ + byte[] asByteArray() { + ByteBuffer buffer = ByteBuffer.allocate(MINIMUM_SIZE); + buffer.order(ByteOrder.LITTLE_ENDIAN); + buffer.putInt(SIGNATURE); + buffer.putShort(this.numberOfThisDisk); + buffer.putShort(this.diskWhereCentralDirectoryStarts); + buffer.putShort(this.numberOfCentralDirectoryEntriesOnThisDisk); + buffer.putShort(this.totalNumberOfCentralDirectoryEntries); + buffer.putInt(this.sizeOfCentralDirectory); + buffer.putInt(this.offsetToStartOfCentralDirectory); + buffer.putShort(this.commentLength); + return buffer.array(); + } + + /** + * Create a new {@link ZipEndOfCentralDirectoryRecord} instance from the specified + * {@link DataBlock} by searching backwards from the end until a valid record is + * located. + * @param dataBlock the source data block + * @return the {@link Located located} {@link ZipEndOfCentralDirectoryRecord} + * @throws IOException if the {@link ZipEndOfCentralDirectoryRecord} cannot be read + */ + static Located load(DataBlock dataBlock) throws IOException { + ByteBuffer buffer = ByteBuffer.allocate(BUFFER_SIZE); + buffer.order(ByteOrder.LITTLE_ENDIAN); + long pos = locate(dataBlock, buffer); + return new Located(pos, new ZipEndOfCentralDirectoryRecord(buffer.getShort(), buffer.getShort(), + buffer.getShort(), buffer.getShort(), buffer.getInt(), buffer.getInt(), buffer.getShort())); + } + + private static long locate(DataBlock dataBlock, ByteBuffer buffer) throws IOException { + long endPos = dataBlock.size(); + debug.log("Finding EndOfCentralDirectoryRecord starting at end position %s", endPos); + while (endPos > 0) { + buffer.clear(); + long totalRead = dataBlock.size() - endPos; + if (totalRead > MAXIMUM_SIZE) { + throw new IOException( + "Zip 'End Of Central Directory Record' not found after reading " + totalRead + " bytes"); + } + long startPos = endPos - buffer.limit(); + if (startPos < 0) { + buffer.limit((int) startPos + buffer.limit()); + startPos = 0; + } + debug.log("Finding EndOfCentralDirectoryRecord from %s with limit %s", startPos, buffer.limit()); + dataBlock.readFully(buffer, startPos); + int offset = findInBuffer(buffer); + if (offset >= 0) { + debug.log("Found EndOfCentralDirectoryRecord at %s + %s", startPos, offset); + return startPos + offset; + } + endPos = endPos - BUFFER_SIZE + MINIMUM_SIZE; + } + throw new IOException("Zip 'End Of Central Directory Record' not found after reading entire data block"); + } + + private static int findInBuffer(ByteBuffer buffer) { + for (int pos = buffer.limit() - 4; pos >= 0; pos--) { + buffer.position(pos); + if (buffer.getInt() == SIGNATURE) { + return pos; + } + } + return -1; + } + + /** + * A located {@link ZipEndOfCentralDirectoryRecord}. + * + * @param pos the position of the record + * @param endOfCentralDirectoryRecord the located end of central directory record + */ + record Located(long pos, ZipEndOfCentralDirectoryRecord endOfCentralDirectoryRecord) { + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipLocalFileHeaderRecord.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipLocalFileHeaderRecord.java new file mode 100644 index 0000000000..8d77ca585a --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipLocalFileHeaderRecord.java @@ -0,0 +1,124 @@ +/* + * 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.zip; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import org.springframework.boot.loader.log.DebugLogger; + +/** + * A ZIP File "Local file header record" (LFH). + * + * @param versionNeededToExtract the version needed to extract the zip + * @param generalPurposeBitFlag the general purpose bit flag + * @param compressionMethod the compression method used for this entry + * @param lastModFileTime the last modified file time + * @param lastModFileDate the last modified file date + * @param crc32 the CRC32 checksum + * @param compressedSize the size of the entry when compressed + * @param uncompressedSize the size of the entry when uncompressed + * @param fileNameLength the file name length + * @param extraFieldLength the extra field length + * @author Phillip Webb + * @see Chapter + * 4.3.7 of the Zip File Format Specification + */ +record ZipLocalFileHeaderRecord(short versionNeededToExtract, short generalPurposeBitFlag, short compressionMethod, + short lastModFileTime, short lastModFileDate, int crc32, int compressedSize, int uncompressedSize, + short fileNameLength, short extraFieldLength) { + + private static final DebugLogger debug = DebugLogger.get(ZipLocalFileHeaderRecord.class); + + private static final int SIGNATURE = 0x04034b50; + + private static final int MINIMUM_SIZE = 30; + + /** + * Return the size of this record. + * @return the record size + */ + long size() { + return MINIMUM_SIZE + fileNameLength() + extraFieldLength(); + } + + /** + * Return a new {@link ZipLocalFileHeaderRecord} with a new + * {@link #extraFieldLength()}. + * @param extraFieldLength the new extra field length + * @return a new {@link ZipLocalFileHeaderRecord} instance + */ + ZipLocalFileHeaderRecord withExtraFieldLength(short extraFieldLength) { + return new ZipLocalFileHeaderRecord(this.versionNeededToExtract, this.generalPurposeBitFlag, + this.compressionMethod, this.lastModFileTime, this.lastModFileDate, this.crc32, this.compressedSize, + this.uncompressedSize, this.fileNameLength, extraFieldLength); + } + + /** + * Return a new {@link ZipLocalFileHeaderRecord} with a new {@link #fileNameLength()}. + * @param fileNameLength the new file name length + * @return a new {@link ZipLocalFileHeaderRecord} instance + */ + ZipLocalFileHeaderRecord withFileNameLength(short fileNameLength) { + return new ZipLocalFileHeaderRecord(this.versionNeededToExtract, this.generalPurposeBitFlag, + this.compressionMethod, this.lastModFileTime, this.lastModFileDate, this.crc32, this.compressedSize, + this.uncompressedSize, fileNameLength, this.extraFieldLength); + } + + /** + * Return the contents of this record as a byte array suitable for writing to a zip. + * @return the record as a byte array + */ + byte[] asByteArray() { + ByteBuffer buffer = ByteBuffer.allocate(MINIMUM_SIZE); + buffer.order(ByteOrder.LITTLE_ENDIAN); + buffer.putInt(SIGNATURE); + buffer.putShort(this.versionNeededToExtract); + buffer.putShort(this.generalPurposeBitFlag); + buffer.putShort(this.compressionMethod); + buffer.putShort(this.lastModFileTime); + buffer.putShort(this.lastModFileDate); + buffer.putInt(this.crc32); + buffer.putInt(this.compressedSize); + buffer.putInt(this.uncompressedSize); + buffer.putShort(this.fileNameLength); + buffer.putShort(this.extraFieldLength); + return buffer.array(); + } + + /** + * Load the {@link ZipLocalFileHeaderRecord} from the given data block. + * @param dataBlock the source data block + * @param pos the position of the record + * @return a new {@link ZipLocalFileHeaderRecord} instance + * @throws IOException on I/O error + */ + static ZipLocalFileHeaderRecord load(DataBlock dataBlock, long pos) throws IOException { + debug.log("Loading LocalFileHeaderRecord from position %s", pos); + ByteBuffer buffer = ByteBuffer.allocate(MINIMUM_SIZE); + buffer.order(ByteOrder.LITTLE_ENDIAN); + dataBlock.readFully(buffer, pos); + buffer.rewind(); + if (buffer.getInt() != SIGNATURE) { + throw new IOException("Zip 'Local File Header Record' not found at position " + pos); + } + return new ZipLocalFileHeaderRecord(buffer.getShort(), buffer.getShort(), buffer.getShort(), buffer.getShort(), + buffer.getShort(), buffer.getInt(), buffer.getInt(), buffer.getInt(), buffer.getShort(), + buffer.getShort()); + } +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipString.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipString.java new file mode 100644 index 0000000000..bf246e0c7d --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/ZipString.java @@ -0,0 +1,320 @@ +/* + * 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.zip; + +import java.io.EOFException; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; + +import org.springframework.boot.loader.log.DebugLogger; + +/** + * Internal utility class for working with the string content of zip records. Provides + * methods that work with raw bytes to save creating temporary strings. + * + * @author Phillip Webb + * @author Andy Wilkinson + */ +final class ZipString { + + private static final DebugLogger debug = DebugLogger.get(ZipString.class); + + static final int BUFFER_SIZE = 256; + + private static final int[] INITIAL_BYTE_BITMASK = { 0x7F, 0x1F, 0x0F, 0x07 }; + + private static final int SUBSEQUENT_BYTE_BITMASK = 0x3F; + + private static final int EMPTY_HASH = "".hashCode(); + + private static final int EMPTY_SLASH_HASH = "/".hashCode(); + + private ZipString() { + } + + /** + * Return a hash for a char sequence, optionally appending '/'. + * @param charSequence the source char sequence + * @param addEndSlash if slash should be added to the string if it's not already + * present + * @return the hash + */ + static int hash(CharSequence charSequence, boolean addEndSlash) { + return hash(0, charSequence, addEndSlash); + } + + /** + * Return a hash for a char sequence, optionally appending '/'. + * @param initialHash the initial hash value + * @param charSequence the source char sequence + * @param addEndSlash if slash should be added to the string if it's not already + * present + * @return the hash + */ + static int hash(int initialHash, CharSequence charSequence, boolean addEndSlash) { + if (charSequence == null || charSequence.isEmpty()) { + return (!addEndSlash) ? EMPTY_HASH : EMPTY_SLASH_HASH; + } + boolean endsWithSlash = charSequence.charAt(charSequence.length() - 1) == '/'; + int hash = initialHash; + if (charSequence instanceof String && initialHash == 0) { + // We're compatible with String.hashCode and it might be already calculated + hash = charSequence.hashCode(); + } + else { + for (int i = 0; i < charSequence.length(); i++) { + char ch = charSequence.charAt(i); + hash = 31 * hash + ch; + } + } + hash = (addEndSlash && !endsWithSlash) ? 31 * hash + '/' : hash; + debug.log("%s calculated for charsequence '%s' (addEndSlash=%s)", hash, charSequence, endsWithSlash); + return hash; + } + + /** + * Return a hash for bytes read from a {@link DataBlock}, optionally appending '/'. + * @param buffer the buffer to use or {@code null} + * @param dataBlock the source data block + * @param pos the position in the data block where the string starts + * @param len the number of bytes to read from the block + * @param addEndSlash if slash should be added to the string if it's not already + * present + * @return the hash + * @throws IOException on I/O error + */ + static int hash(ByteBuffer buffer, DataBlock dataBlock, long pos, int len, boolean addEndSlash) throws IOException { + if (len == 0) { + return (!addEndSlash) ? EMPTY_HASH : EMPTY_SLASH_HASH; + } + buffer = (buffer != null) ? buffer : ByteBuffer.allocate(BUFFER_SIZE); + byte[] bytes = buffer.array(); + int hash = 0; + char lastChar = 0; + while (len > 0) { + int count = readInBuffer(dataBlock, pos, buffer, len); + len -= count; + pos += count; + for (int byteIndex = 0; byteIndex < count;) { + int codePointSize = getCodePointSize(bytes, byteIndex); + if (!hasEnoughBytes(byteIndex, codePointSize, count)) { + pos--; + len++; + break; + } + int codePoint = getCodePoint(bytes, byteIndex, codePointSize); + byteIndex += codePointSize; + if (codePoint <= 0xFFFF) { + lastChar = (char) (codePoint & 0xFFFF); + hash = 31 * hash + lastChar; + } + else { + lastChar = 0; + hash = 31 * hash + Character.highSurrogate(codePoint); + hash = 31 * hash + Character.lowSurrogate(codePoint); + } + } + } + hash = (addEndSlash && lastChar != '/') ? 31 * hash + '/' : hash; + debug.log("%08X calculated for datablock position %s size %s (addEndSlash=%s)", hash, pos, len, addEndSlash); + return hash; + } + + /** + * Return if the bytes read from a {@link DataBlock} matches the give + * {@link CharSequence}. + * @param buffer the buffer to use or {@code null} + * @param dataBlock the source data block + * @param pos the position in the data block where the string starts + * @param len the number of bytes to read from the block + * @param charSequence the char sequence with which to compare + * @param addSlash also accept {@code charSequence + '/'} when it doesn't already end + * with one + * @return true if the contents are considered equal + */ + static boolean matches(ByteBuffer buffer, DataBlock dataBlock, long pos, int len, CharSequence charSequence, + boolean addSlash) { + if (charSequence.isEmpty()) { + return true; + } + buffer = (buffer != null) ? buffer : ByteBuffer.allocate(BUFFER_SIZE); + try { + return compare(buffer, dataBlock, pos, len, charSequence, + (!addSlash) ? CompareType.MATCHES : CompareType.MATCHES_ADDING_SLASH) != -1; + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + /** + * Returns if the bytes read from a {@link DataBlock} starts with the given + * {@link CharSequence}. + * @param buffer the buffer to use or {@code null} + * @param dataBlock the source data block + * @param pos the position in the data block where the string starts + * @param len the number of bytes to read from the block + * @param charSequence the required starting chars + * @return {@code -1} if the data block does not start with the char sequence, or a + * positive number indicating the number of bytes that contain the starting chars + */ + static int startsWith(ByteBuffer buffer, DataBlock dataBlock, long pos, int len, CharSequence charSequence) { + if (charSequence.isEmpty()) { + return 0; + } + buffer = (buffer != null) ? buffer : ByteBuffer.allocate(BUFFER_SIZE); + try { + return compare(buffer, dataBlock, pos, len, charSequence, CompareType.STARTS_WITH); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + private static int compare(ByteBuffer buffer, DataBlock dataBlock, long pos, int len, CharSequence charSequence, + CompareType compareType) throws IOException { + if (charSequence.isEmpty()) { + return 0; + } + boolean addSlash = compareType == CompareType.MATCHES_ADDING_SLASH && !endsWith(charSequence, '/'); + int charSequenceIndex = 0; + int maxCharSequenceLength = (!addSlash) ? charSequence.length() : charSequence.length() + 1; + int result = 0; + byte[] bytes = buffer.array(); + while (len > 0) { + int count = readInBuffer(dataBlock, pos, buffer, len); + len -= count; + pos += count; + for (int byteIndex = 0; byteIndex < count;) { + int codePointSize = getCodePointSize(bytes, byteIndex); + if (!hasEnoughBytes(byteIndex, codePointSize, count)) { + pos--; + len++; + break; + } + int codePoint = getCodePoint(bytes, byteIndex, codePointSize); + result += codePointSize; + if (codePoint <= 0xFFFF) { + char ch = (char) (codePoint & 0xFFFF); + if (charSequenceIndex >= maxCharSequenceLength + || getChar(charSequence, charSequenceIndex++) != ch) { + return -1; + } + } + else { + char ch = Character.highSurrogate(codePoint); + if (charSequenceIndex >= maxCharSequenceLength + || getChar(charSequence, charSequenceIndex++) != ch) { + return -1; + } + ch = Character.lowSurrogate(codePoint); + if (charSequenceIndex >= charSequence.length() + || getChar(charSequence, charSequenceIndex++) != ch) { + return -1; + } + } + if (compareType == CompareType.STARTS_WITH && charSequenceIndex >= charSequence.length()) { + return result; + } + byteIndex += codePointSize; + } + } + return (charSequenceIndex >= charSequence.length()) ? result : -1; + } + + private static boolean hasEnoughBytes(int byteIndex, int codePointSize, int count) { + return (byteIndex + codePointSize - 1) < count; + } + + private static boolean endsWith(CharSequence charSequence, char ch) { + return !charSequence.isEmpty() && charSequence.charAt(charSequence.length() - 1) == ch; + } + + private static char getChar(CharSequence charSequence, int index) { + return (index != charSequence.length()) ? charSequence.charAt(index) : '/'; + } + + /** + * Read a string value from the given data block. + * @param data the source data + * @param pos the position to read from + * @param len the number of bytes to read + * @return the contents as a string + */ + static String readString(DataBlock data, long pos, long len) { + try { + if (len > Integer.MAX_VALUE) { + throw new IllegalStateException("String is too long to read"); + } + ByteBuffer buffer = ByteBuffer.allocate((int) len); + buffer.order(ByteOrder.LITTLE_ENDIAN); + data.readFully(buffer, pos); + return new String(buffer.array(), StandardCharsets.UTF_8); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + private static int readInBuffer(DataBlock dataBlock, long pos, ByteBuffer buffer, int maxLen) throws IOException { + buffer.clear(); + if (buffer.remaining() > maxLen) { + buffer.limit(maxLen); + } + int count = dataBlock.read(buffer, pos); + if (count <= 0) { + throw new EOFException(); + } + return count; + } + + private static int getCodePointSize(byte[] bytes, int i) { + int b = bytes[i] & 0xFF; + if ((b & 0b1_0000000) == 0b0_0000000) { + return 1; + } + if ((b & 0b111_00000) == 0b110_00000) { + return 2; + } + if ((b & 0b1111_0000) == 0b1110_0000) { + return 3; + } + return 4; + } + + private static int getCodePoint(byte[] bytes, int i, int codePointSize) { + int codePoint = bytes[i] & 0xFF; + codePoint &= INITIAL_BYTE_BITMASK[codePointSize - 1]; + for (int j = 1; j < codePointSize; j++) { + codePoint = (codePoint << 6) + (bytes[i + j] & SUBSEQUENT_BYTE_BITMASK); + } + return codePoint; + } + + /** + * Supported compare types. + */ + private enum CompareType { + + MATCHES, MATCHES_ADDING_SLASH, STARTS_WITH + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/package-info.java new file mode 100644 index 0000000000..38bd93390b --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/zip/package-info.java @@ -0,0 +1,21 @@ +/* + * 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. + */ + +/** + * Provides low-level support for handling zip content, including support for nested and + * virtual zip files. + */ +package org.springframework.boot.loader.zip; 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 deleted file mode 100644 index 58084bba8a..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/LaunchedURLClassLoaderTests.java +++ /dev/null @@ -1,111 +0,0 @@ -/* - * 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/archive/ExplodedArchiveTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/archive/ExplodedArchiveTests.java deleted file mode 100755 index 77d2ce185c..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/archive/ExplodedArchiveTests.java +++ /dev/null @@ -1,189 +0,0 @@ -/* - * 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 deleted file mode 100755 index 4b2ce93af6..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/archive/JarFileArchiveTests.java +++ /dev/null @@ -1,207 +0,0 @@ -/* - * 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 deleted file mode 100644 index 6713814def..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/data/RandomAccessDataFileTests.java +++ /dev/null @@ -1,300 +0,0 @@ -/* - * 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 deleted file mode 100644 index dd25050163..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/AsciiBytesTests.java +++ /dev/null @@ -1,196 +0,0 @@ -/* - * 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 deleted file mode 100644 index 4d15c21fe3..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/CentralDirectoryParserTests.java +++ /dev/null @@ -1,139 +0,0 @@ -/* - * 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 deleted file mode 100644 index 1a64de6431..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/HandlerTests.java +++ /dev/null @@ -1,210 +0,0 @@ -/* - * 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 deleted file mode 100644 index b37a99183a..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarFileTests.java +++ /dev/null @@ -1,736 +0,0 @@ -/* - * 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 deleted file mode 100644 index 8ae25b72e1..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarFileWrapperTests.java +++ /dev/null @@ -1,281 +0,0 @@ -/* - * 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 deleted file mode 100644 index d962a72fc5..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarURLConnectionTests.java +++ /dev/null @@ -1,246 +0,0 @@ -/* - * 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 deleted file mode 100644 index d9e5eb2814..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/JarUrlProtocolHandler.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * 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/ManifestInfoTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/ManifestInfoTests.java new file mode 100644 index 0000000000..619080b175 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/ManifestInfoTests.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.jar; + +import java.util.jar.Attributes.Name; +import java.util.jar.Manifest; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ManifestInfo}. + * + * @author Phillip Webb + */ +class ManifestInfoTests { + + @Test + void noneReturnsNoDetails() { + assertThat(ManifestInfo.NONE.getManifest()).isNull(); + assertThat(ManifestInfo.NONE.isMultiRelease()).isFalse(); + } + + @Test + void getManifestReturnsManifest() { + Manifest manifest = new Manifest(); + ManifestInfo info = new ManifestInfo(manifest); + assertThat(info.getManifest()).isSameAs(manifest); + } + + @Test + void isMultiReleaseWhenHasMultiReleaseAttributeReturnsTrue() { + Manifest manifest = new Manifest(); + manifest.getMainAttributes().put(new Name("Multi-Release"), "true"); + ManifestInfo info = new ManifestInfo(manifest); + assertThat(info.isMultiRelease()).isTrue(); + } + + @Test + void isMultiReleaseWhenHasNoMultiReleaseAttributeReturnsFalse() { + Manifest manifest = new Manifest(); + manifest.getMainAttributes().put(new Name("Random-Release"), "true"); + ManifestInfo info = new ManifestInfo(manifest); + assertThat(info.isMultiRelease()).isFalse(); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/MetaInfVersionsInfoTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/MetaInfVersionsInfoTests.java new file mode 100644 index 0000000000..d556c9cbea --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/MetaInfVersionsInfoTests.java @@ -0,0 +1,84 @@ +/* + * 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.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.loader.zip.ZipContent; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link MetaInfVersionsInfo}. + * + * @author Phillip Webb + */ +class MetaInfVersionsInfoTests { + + @Test + void getParsesVersionsAndEntries() { + List entries = new ArrayList<>(); + entries.add(mockEntry("META-INF/")); + entries.add(mockEntry("META-INF/MANIFEST.MF")); + entries.add(mockEntry("META-INF/versions/")); + entries.add(mockEntry("META-INF/versions/9/")); + entries.add(mockEntry("META-INF/versions/9/Foo.class")); + entries.add(mockEntry("META-INF/versions/11/")); + entries.add(mockEntry("META-INF/versions/11/Foo.class")); + entries.add(mockEntry("META-INF/versions/10/")); + entries.add(mockEntry("META-INF/versions/10/Foo.class")); + MetaInfVersionsInfo info = MetaInfVersionsInfo.get(entries.size(), entries::get); + assertThat(info.versions()).containsExactly(9, 10, 11); + assertThat(info.directories()).containsExactly("META-INF/versions/9/", "META-INF/versions/10/", + "META-INF/versions/11/"); + } + + @Test + void getWhenHasBadEntryParsesGoodVersionsAndEntries() { + List entries = new ArrayList<>(); + entries.add(mockEntry("META-INF/versions/9/Foo.class")); + entries.add(mockEntry("META-INF/versions/0x11/Foo.class")); + MetaInfVersionsInfo info = MetaInfVersionsInfo.get(entries.size(), entries::get); + assertThat(info.versions()).containsExactly(9); + assertThat(info.directories()).containsExactly("META-INF/versions/9/"); + } + + @Test + void getWhenHasNoEntriesReturnsNone() { + List entries = new ArrayList<>(); + MetaInfVersionsInfo info = MetaInfVersionsInfo.get(entries.size(), entries::get); + assertThat(info.versions()).isEmpty(); + assertThat(info.directories()).isEmpty(); + assertThat(info).isSameAs(MetaInfVersionsInfo.NONE); + } + + private ZipContent.Entry mockEntry(String name) { + ZipContent.Entry entry = mock(ZipContent.Entry.class); + given(entry.getName()).willReturn(name); + given(entry.hasNameStartingWith(any())) + .willAnswer((invocation) -> name.startsWith(invocation.getArgument(0, CharSequence.class).toString())); + given(entry.isDirectory()).willAnswer((invocation) -> name.endsWith("/")); + return entry; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/NestedJarFileTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/NestedJarFileTests.java new file mode 100644 index 0000000000..1944f30b9f --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/NestedJarFileTests.java @@ -0,0 +1,368 @@ +/* + * 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.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.ref.Cleaner.Cleanable; +import java.nio.charset.Charset; +import java.util.Enumeration; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.Manifest; +import java.util.zip.ZipFile; + +import org.assertj.core.extractor.Extractors; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; + +import org.springframework.boot.loader.ref.Cleaner; +import org.springframework.boot.loader.testsupport.TestJar; +import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed; +import org.springframework.boot.loader.zip.ZipContent; +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.assertThatIllegalStateException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.atMostOnce; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link NestedJarFile}. + * + * @author Phillip Webb + * @author Martin Lau + * @author Andy Wilkinson + * @author Madhura Bhave + */ +@AssertFileChannelDataBlocksClosed +class NestedJarFileTests { + + @TempDir + File tempDir; + + private File file; + + @BeforeEach + void setup() throws Exception { + this.file = new File(this.tempDir, "test.jar"); + TestJar.create(this.file); + } + + @Test + void createOpensJar() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file)) { + try (JarFile jdkJar = new JarFile(this.file)) { + assertThat(jar.size()).isEqualTo(jdkJar.size()); + assertThat(jar.getComment()).isEqualTo(jdkJar.getComment()); + Enumeration entries = jar.entries(); + Enumeration jdkEntries = jdkJar.entries(); + while (entries.hasMoreElements()) { + assertThat(entries.nextElement().getName()).isEqualTo(jdkEntries.nextElement().getName()); + } + assertThat(jdkEntries.hasMoreElements()).isFalse(); + try (InputStream in = jar.getInputStream(jar.getEntry("1.dat"))) { + assertThat(in.readAllBytes()).containsExactly(new byte[] { 1 }); + } + } + } + } + + @Test + void createWhenNestedJarFileOpensJar() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file, "nested.jar")) { + assertThat(jar.size()).isEqualTo(5); + assertThat(jar.stream().map(JarEntry::getName)).containsExactly("META-INF/", "META-INF/MANIFEST.MF", + "3.dat", "4.dat", "\u00E4.dat"); + } + } + + @Test + void createWhenNestedJarDirectoryOpensJar() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file, "d/")) { + assertThat(jar.getName()).isEqualTo(this.file.getAbsolutePath() + "!/d/"); + assertThat(jar.size()).isEqualTo(3); + assertThat(jar.stream().map(JarEntry::getName)).containsExactly("META-INF/", "META-INF/MANIFEST.MF", + "9.dat"); + } + } + + @Test + void createWhenJarHasFrontMatterOpensJar() throws IOException { + File file = new File(this.tempDir, "frontmatter.jar"); + InputStream sourceJarContent = new FileInputStream(this.file); + FileOutputStream outputStream = new FileOutputStream(file); + StreamUtils.copy("#/bin/bash", Charset.defaultCharset(), outputStream); + FileCopyUtils.copy(sourceJarContent, outputStream); + try (NestedJarFile jar = new NestedJarFile(file)) { + assertThat(jar.size()).isEqualTo(12); + } + try (NestedJarFile jar = new NestedJarFile(this.file, "nested.jar")) { + assertThat(jar.size()).isEqualTo(5); + } + } + + @Test + void getEntryReturnsEntry() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file)) { + JarEntry entry = jar.getEntry("1.dat"); + assertEntryOne(entry); + } + } + + @Test + void getEntryWhenClosedThrowsException() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file)) { + jar.close(); + assertThatIllegalStateException().isThrownBy(() -> jar.getEntry("1.dat")).withMessage("Zip file closed"); + } + } + + @Test + void getJarEntryReturnsEntry() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file)) { + JarEntry entry = jar.getJarEntry("1.dat"); + assertEntryOne(entry); + } + } + + @Test + void getJarEntryWhenClosedThrowsException() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file)) { + jar.close(); + assertThatIllegalStateException().isThrownBy(() -> jar.getJarEntry("1.dat")).withMessage("Zip file closed"); + } + } + + private void assertEntryOne(JarEntry entry) { + assertThat(entry.getName()).isEqualTo("1.dat"); + assertThat(entry.getRealName()).isEqualTo("1.dat"); + assertThat(entry.getSize()).isEqualTo(1); + assertThat(entry.getCompressedSize()).isEqualTo(3); + assertThat(entry.getCrc()).isEqualTo(2768625435L); + assertThat(entry.getMethod()).isEqualTo(8); + } + + @Test + void getEntryWhenMultiReleaseEntryReturnsEntry() throws IOException { + File multiReleaseFile = new File(this.tempDir, "mutli.zip"); + try (ZipContent zip = ZipContent.open(this.file.toPath(), "multi-release.jar")) { + try (InputStream in = zip.openRawZipData().asInputStream()) { + try (FileOutputStream out = new FileOutputStream(multiReleaseFile)) { + in.transferTo(out); + } + } + } + try (NestedJarFile jar = new NestedJarFile(this.file, "multi-release.jar", JarFile.runtimeVersion())) { + try (JarFile jdkJar = new JarFile(multiReleaseFile, true, ZipFile.OPEN_READ, JarFile.runtimeVersion())) { + JarEntry entry = jar.getJarEntry("multi-release.dat"); + JarEntry jdkEntry = jdkJar.getJarEntry("multi-release.dat"); + assertThat(entry.getName()).isEqualTo(jdkEntry.getName()); + assertThat(entry.getRealName()).isEqualTo(jdkEntry.getRealName()); + try (InputStream inputStream = jdkJar.getInputStream(entry)) { + assertThat(inputStream.available()).isOne(); + assertThat(inputStream.read()).isEqualTo(Runtime.version().feature()); + } + try (InputStream inputStream = jar.getInputStream(entry)) { + assertThat(inputStream.available()).isOne(); + assertThat(inputStream.read()).isEqualTo(Runtime.version().feature()); + } + } + } + } + + @Test + void getManifestReturnsManifest() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file)) { + Manifest manifest = jar.getManifest(); + assertThat(manifest).isNotNull(); + assertThat(manifest.getEntries()).isEmpty(); + assertThat(manifest.getMainAttributes().getValue("Manifest-Version")).isEqualTo("1.0"); + } + } + + @Test + void getCommentReturnsComment() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file)) { + assertThat(jar.getComment()).isEqualTo("outer"); + } + } + + @Test + void getCommentWhenClosedThrowsException() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file)) { + jar.close(); + assertThatIllegalStateException().isThrownBy(() -> jar.getComment()).withMessage("Zip file closed"); + } + } + + @Test + void getNameReturnsName() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file)) { + assertThat(jar.getName()).isEqualTo(this.file.getAbsolutePath()); + } + } + + @Test + void getNameWhenNestedReturnsName() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file, "nested.jar")) { + assertThat(jar.getName()).isEqualTo(this.file.getAbsolutePath() + "!/nested.jar"); + } + } + + @Test + void sizeReturnsSize() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file)) { + assertThat(jar.size()).isEqualByComparingTo(12); + } + } + + @Test + void sizeWhenClosedThowsException() throws Exception { + try (NestedJarFile jar = new NestedJarFile(this.file)) { + jar.close(); + assertThatIllegalStateException().isThrownBy(() -> jar.size()).withMessage("Zip file closed"); + } + } + + @Test + void getEntryTime() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file)) { + try (JarFile jdkJar = new JarFile(this.file)) { + assertThat(jar.getEntry("META-INF/MANIFEST.MF").getTime()) + .isEqualTo(jar.getEntry("META-INF/MANIFEST.MF").getTime()); + } + } + } + + @Test + void closeTriggersCleanupOnlyOnce() throws IOException { + Cleaner cleaner = mock(Cleaner.class); + ArgumentCaptor action = ArgumentCaptor.forClass(Runnable.class); + Cleanable cleanable = mock(Cleanable.class); + given(cleaner.register(any(), action.capture())).willReturn(cleanable); + NestedJarFile jar = new NestedJarFile(this.file, null, null, false, cleaner); + jar.close(); + jar.close(); + then(cleanable).should(atMostOnce()).clean(); + action.getValue().run(); + } + + @Test + void cleanupFromReleasesResources() throws IOException { + Cleaner cleaner = mock(Cleaner.class); + ArgumentCaptor action = ArgumentCaptor.forClass(Runnable.class); + Cleanable cleanable = mock(Cleanable.class); + given(cleaner.register(any(), action.capture())).willReturn(cleanable); + try (NestedJarFile jar = new NestedJarFile(this.file, null, null, false, cleaner)) { + Object channel = Extractors.byName("resources.zipContent.data.channel").apply(jar); + assertThat(channel).extracting("referenceCount").isEqualTo(1); + action.getValue().run(); + assertThat(channel).extracting("referenceCount").isEqualTo(0); + } + } + + @Test + void getInputStreamReturnsInputStream() throws IOException { + try (NestedJarFile jarFile = new NestedJarFile(this.file)) { + JarEntry entry = jarFile.getJarEntry("2.dat"); + try (InputStream in = jarFile.getInputStream(entry)) { + assertThat(in).hasBinaryContent(new byte[] { 0x02 }); + } + } + } + + @Test + void getInputStreamWhenIsDirectory() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file)) { + try (InputStream inputStream = jar.getInputStream(jar.getEntry("d/"))) { + assertThat(inputStream).isNotNull(); + assertThat(inputStream.read()).isEqualTo(-1); + } + } + } + + @Test + void getInputStreamWhenNameWithoutSlashAndIsDirectory() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file)) { + try (InputStream inputStream = jar.getInputStream(jar.getEntry("d"))) { + assertThat(inputStream).isNotNull(); + assertThat(inputStream.read()).isEqualTo(-1); + } + } + } + + @Test + void verifySignedJar() throws Exception { + File signedJarFile = TestJar.getSigned(); + assertThat(signedJarFile).exists(); + try (JarFile expected = new JarFile(signedJarFile)) { + try (NestedJarFile actual = new NestedJarFile(signedJarFile)) { + StopWatch stopWatch = new StopWatch(); + Enumeration actualEntries = actual.entries(); + while (actualEntries.hasMoreElements()) { + JarEntry actualEntry = actualEntries.nextElement(); + 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); + } + } + } + + @Test + void closeAllowsFileToBeDeleted() throws Exception { + new NestedJarFile(this.file).close(); + assertThat(this.file.delete()).isTrue(); + } + + @Test + void streamStreamsEnties() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file, "multi-release.jar")) { + assertThat(jar.stream().map((entry) -> entry.getName() + ":" + entry.getRealName())).containsExactly( + "META-INF/:META-INF/", "META-INF/MANIFEST.MF:META-INF/MANIFEST.MF", + "multi-release.dat:multi-release.dat", + "META-INF/versions/17/multi-release.dat:META-INF/versions/17/multi-release.dat"); + } + } + + @Test + void versionedStreamStreamsEntries() throws IOException { + try (NestedJarFile jar = new NestedJarFile(this.file, "multi-release.jar", Runtime.version())) { + assertThat(jar.versionedStream().map((entry) -> entry.getName() + ":" + entry.getRealName())) + .containsExactly("META-INF/:META-INF/", "META-INF/MANIFEST.MF:META-INF/MANIFEST.MF", + "multi-release.dat:META-INF/versions/17/multi-release.dat"); + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/SecurityInfoTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/SecurityInfoTests.java new file mode 100644 index 0000000000..c5de14ad2e --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/SecurityInfoTests.java @@ -0,0 +1,84 @@ +/* + * 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 org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.testsupport.TestJar; +import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed; +import org.springframework.boot.loader.zip.ZipContent; +import org.springframework.boot.loader.zip.ZipContent.Entry; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link SecurityInfo}. + * + * @author Phillip Webb + */ +@AssertFileChannelDataBlocksClosed +class SecurityInfoTests { + + @TempDir + File temp; + + @Test + void getWhenNoSignatureFileReturnsNone() throws Exception { + File file = new File(this.temp, "test.jar"); + TestJar.create(file); + try (ZipContent content = ZipContent.open(file.toPath())) { + SecurityInfo info = SecurityInfo.get(content); + assertThat(info).isSameAs(SecurityInfo.NONE); + for (int i = 0; i < content.size(); i++) { + Entry entry = content.getEntry(i); + assertThat(info.getCertificates(entry)).isNull(); + assertThat(info.getCodeSigners(entry)).isNull(); + } + } + } + + @Test + void getWhenHasSignatureFileButNoSecuityMaterialReturnsNone() throws Exception { + File file = new File(this.temp, "test.jar"); + TestJar.create(file, false, true); + try (ZipContent content = ZipContent.open(file.toPath())) { + assertThat(content.hasJarSignatureFile()).isTrue(); + SecurityInfo info = SecurityInfo.get(content); + assertThat(info).isSameAs(SecurityInfo.NONE); + } + } + + @Test + void getWhenJarIsSigned() throws Exception { + File file = TestJar.getSigned(); + try (ZipContent content = ZipContent.open(file.toPath())) { + assertThat(content.hasJarSignatureFile()).isTrue(); + SecurityInfo info = SecurityInfo.get(content); + for (int i = 0; i < content.size(); i++) { + Entry entry = content.getEntry(i); + if (entry.getName().endsWith(".class")) { + assertThat(info.getCertificates(entry)).isNotNull(); + assertThat(info.getCodeSigners(entry)).isNotNull(); + } + } + } + } + +} 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 deleted file mode 100644 index ee7170f08c..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jar/StringSequenceTests.java +++ /dev/null @@ -1,220 +0,0 @@ -/* - * 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/main/java/org/springframework/boot/loader/jarmode/TestJarMode.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jarmode/TestJarMode.java similarity index 100% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jarmode/TestJarMode.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jarmode/TestJarMode.java 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/launch/AbstractExecutableArchiveLauncherTests.java similarity index 90% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/AbstractExecutableArchiveLauncherTests.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/AbstractExecutableArchiveLauncherTests.java index 60e3cb2765..efdc7012d2 100644 --- 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/launch/AbstractExecutableArchiveLauncherTests.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.boot.loader; +package org.springframework.boot.loader.launch; import java.io.ByteArrayOutputStream; import java.io.File; @@ -27,9 +27,7 @@ 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; @@ -39,7 +37,6 @@ import java.util.zip.ZipEntry; import org.junit.jupiter.api.io.TempDir; -import org.springframework.boot.loader.archive.Archive; import org.springframework.util.FileCopyUtils; /** @@ -49,7 +46,7 @@ import org.springframework.util.FileCopyUtils; * @author Madhura Bhave * @author Scott Frederick */ -public abstract class AbstractExecutableArchiveLauncherTests { +abstract class AbstractExecutableArchiveLauncherTests { @TempDir File tempDir; @@ -58,13 +55,11 @@ public abstract class AbstractExecutableArchiveLauncherTests { 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); @@ -129,14 +124,6 @@ public abstract class AbstractExecutableArchiveLauncherTests { 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(); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/ArchiveTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/ArchiveTests.java new file mode 100644 index 0000000000..900511176f --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/ArchiveTests.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.launch; + +import java.io.File; +import java.security.CodeSource; +import java.security.ProtectionDomain; +import java.util.function.Predicate; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.launch.Archive.Entry; +import org.springframework.boot.loader.testsupport.TestJar; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.withSettings; + +/** + * Tests for {@link Archive}. + * + * @author Phillip Webb + */ +class ArchiveTests { + + @TempDir + File temp; + + @Test + void getClassPathUrlsWithOnlyIncludeFilterSearchesAllDirectories() throws Exception { + Archive archive = mock(Archive.class, withSettings().defaultAnswer(CALLS_REAL_METHODS)); + Predicate includeFilter = (entry) -> false; + archive.getClassPathUrls(includeFilter); + then(archive).should().getClassPathUrls(includeFilter, Archive.ALL_ENTRIES); + } + + @Test + void isExplodedWhenHasRootDirectoryReturnsTrue() { + Archive archive = mock(Archive.class, withSettings().defaultAnswer(CALLS_REAL_METHODS)); + given(archive.getRootDirectory()).willReturn(this.temp); + assertThat(archive.isExploded()).isTrue(); + } + + @Test + void isExplodedWhenHasNoRootDirectoryReturnsFalse() { + Archive archive = mock(Archive.class, withSettings().defaultAnswer(CALLS_REAL_METHODS)); + given(archive.getRootDirectory()).willReturn(null); + assertThat(archive.isExploded()).isFalse(); + } + + @Test + void createFromProtectionDomainCreatesJarArchive() throws Exception { + File jarFile = new File(this.temp, "test.jar"); + TestJar.create(jarFile); + ProtectionDomain protectionDomain = mock(ProtectionDomain.class); + CodeSource codeSource = mock(CodeSource.class); + given(protectionDomain.getCodeSource()).willReturn(codeSource); + given(codeSource.getLocation()).willReturn(jarFile.toURI().toURL()); + Archive archive = Archive.create(protectionDomain); + assertThat(archive).isInstanceOf(JarFileArchive.class); + } + + @Test + void createFromProtectionDomainWhenNoLocationThrowsException() throws Exception { + File jarFile = new File(this.temp, "test.jar"); + TestJar.create(jarFile); + ProtectionDomain protectionDomain = mock(ProtectionDomain.class); + assertThatIllegalStateException().isThrownBy(() -> Archive.create(protectionDomain)) + .withMessage("Unable to determine code source archive"); + } + + @Test + void createFromFileWhenFileDoesNotExistThrowsException() { + File target = new File(this.temp, "missing"); + assertThatIllegalStateException().isThrownBy(() -> Archive.create(target)) + .withMessageContaining("Unable to determine code source archive"); + } + + @Test + void createFromFileWhenJarFileReturnsJarFileArchive() throws Exception { + File target = new File(this.temp, "missing"); + TestJar.create(target); + assertThat(Archive.create(target)).isInstanceOf(JarFileArchive.class); + } + + @Test + void createFromFileWhenDirectoryReturnsExplodedFileArchive() throws Exception { + File target = this.temp; + assertThat(Archive.create(target)).isInstanceOf(ExplodedArchive.class); + } + +} 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/launch/ClassPathIndexFileTests.java similarity index 82% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/ClassPathIndexFileTests.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/ClassPathIndexFileTests.java index 4cd1b4e8d2..4f175b2832 100644 --- 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/launch/ClassPathIndexFileTests.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.boot.loader; +package org.springframework.boot.loader.launch; import java.io.File; import java.io.IOException; @@ -28,7 +28,6 @@ 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}. @@ -41,24 +40,17 @@ 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(); + assertThat(ClassPathIndexFile.loadIfPossible(root, "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(); + assertThat(ClassPathIndexFile.loadIfPossible(root, "test.idx")).isNull(); } @Test @@ -97,7 +89,7 @@ class ClassPathIndexFileTests { private ClassPathIndexFile copyAndLoadTestIndexFile() throws IOException { copyTestIndexFile(); - ClassPathIndexFile indexFile = ClassPathIndexFile.loadIfPossible(this.temp.toURI().toURL(), "test.idx"); + ClassPathIndexFile indexFile = ClassPathIndexFile.loadIfPossible(this.temp, "test.idx"); return indexFile; } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/ExplodedArchiveTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/ExplodedArchiveTests.java new file mode 100755 index 0000000000..96107512a3 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/ExplodedArchiveTests.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.launch; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Enumeration; +import java.util.Set; +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.launch.Archive.Entry; +import org.springframework.boot.loader.testsupport.TestJar; +import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed; +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 + */ +@AssertFileChannelDataBlocksClosed +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(); + } + } + + @Test + void isExplodedReturnsTrue() { + assertThat(this.archive.isExploded()).isTrue(); + } + + @Test + void getRootDirectoryReturnsRootDirectory() { + assertThat(this.archive.getRootDirectory()).isEqualTo(this.rootDirectory); + } + + @Test + void getManifestReturnsManifest() throws Exception { + assertThat(this.archive.getManifest().getMainAttributes().getValue("Built-By")).isEqualTo("j1"); + } + + @Test + void getClassPathUrlsWhenNoPredicartesReturnsUrls() throws Exception { + Set urls = this.archive.getClassPathUrls(Archive.ALL_ENTRIES); + URL[] expectedUrls = TestJar.expectedEntries().stream().map(this::toUrl).toArray(URL[]::new); + assertThat(urls).containsExactlyInAnyOrder(expectedUrls); + } + + @Test + void getClassPathUrlsWhenHasIncludeFilterReturnsUrls() throws Exception { + Set urls = this.archive.getClassPathUrls(this::entryNameIsNestedJar); + assertThat(urls).containsOnly(toUrl("nested.jar")); + } + + @Test + void getClassPathUrlsWhenHasIncludeFilterAndSpaceInRootNameReturnsUrls() throws Exception { + createArchive("spaces in the name"); + Set urls = this.archive.getClassPathUrls(this::entryNameIsNestedJar); + assertThat(urls).containsOnly(toUrl("nested.jar")); + } + + @Test + void getClassPathUrlsWhenHasSearchFilterReturnsUrls() throws Exception { + Set urls = this.archive.getClassPathUrls(Archive.ALL_ENTRIES, (entry) -> !entry.name().equals("d/")); + assertThat(urls).contains(toUrl("nested.jar")).doesNotContain(toUrl("d/9.dat")); + } + + private void createArchive() throws Exception { + createArchive(null); + } + + private void createArchive(String directoryName) throws Exception { + File file = new File(this.tempDir, "test.jar"); + TestJar.create(file); + this.rootDirectory = (StringUtils.hasText(directoryName) ? new File(this.tempDir, directoryName) + : new File(this.tempDir, UUID.randomUUID().toString())); + try (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 { + try (InputStream in = jarFile.getInputStream(entry); + OutputStream out = new FileOutputStream(destination)) { + in.transferTo(out); + } + } + } + this.archive = new ExplodedArchive(this.rootDirectory); + } + } + + private URL toUrl(String name) { + return toUrl(new File(this.rootDirectory, name)); + } + + private URL toUrl(File file) { + try { + return file.toURI().toURL(); + } + catch (MalformedURLException ex) { + throw new IllegalStateException(ex); + } + } + + private boolean entryNameIsNestedJar(Entry entry) { + return entry.name().equals("nested.jar"); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/JarFileArchiveTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/JarFileArchiveTests.java new file mode 100755 index 0000000000..723890f165 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/JarFileArchiveTests.java @@ -0,0 +1,172 @@ +/* + * 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; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.util.Set; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; + +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.launch.Archive.Entry; +import org.springframework.boot.loader.net.protocol.jar.JarUrl; +import org.springframework.boot.loader.testsupport.TestJar; +import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed; +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 + */ +@AssertFileChannelDataBlocksClosed +class JarFileArchiveTests { + + @TempDir + File tempDir; + + private File file; + + private JarFileArchive archive; + + @BeforeEach + void setup() throws Exception { + createTestJarArchive(false); + } + + @AfterEach + void tearDown() throws Exception { + this.archive.close(); + } + + @Test + void isExplodedReturnsFalse() { + assertThat(this.archive.isExploded()).isFalse(); + } + + @Test + void getRootDirectoryReturnsNull() { + assertThat(this.archive.getRootDirectory()).isNull(); + } + + @Test + void getManifestReturnsManifest() throws Exception { + assertThat(this.archive.getManifest().getMainAttributes().getValue("Built-By")).isEqualTo("j1"); + } + + @Test + void getClassPathUrlsWhenNoPredicartesReturnsUrls() throws Exception { + Set urls = this.archive.getClassPathUrls(Archive.ALL_ENTRIES); + URL[] expected = TestJar.expectedEntries() + .stream() + .map((name) -> JarUrl.create(this.file, name)) + .toArray(URL[]::new); + assertThat(urls).containsExactly(expected); + } + + @Test + void getClassPathUrlsWhenHasIncludeFilterReturnsUrls() throws Exception { + Set urls = this.archive.getClassPathUrls(this::entryNameIsNestedJar); + assertThat(urls).containsOnly(JarUrl.create(this.file, "nested.jar")); + } + + @Test + void getClassPathUrlsWhenHasSearchFilterAllUrlsSinceSearchFilterIsNotUsed() throws Exception { + Set urls = this.archive.getClassPathUrls(Archive.ALL_ENTRIES, (entry) -> false); + URL[] expected = TestJar.expectedEntries() + .stream() + .map((name) -> JarUrl.create(this.file, name)) + .toArray(URL[]::new); + assertThat(urls).containsExactly(expected); + } + + @Test + void getClassPathUrlsWhenHasUnpackCommentUnpacksAndReturnsUrls() throws Exception { + createTestJarArchive(true); + Set urls = this.archive.getClassPathUrls(this::entryNameIsNestedJar); + assertThat(urls).hasSize(1); + URL url = urls.iterator().next(); + assertThat(url).isNotEqualTo(JarUrl.create(this.file, "nested.jar")); + assertThat(url.toString()).startsWith("jar:file:").endsWith("/nested.jar!/"); + } + + @Test + void getClassPathUrlsWhenHasUnpackCommentUnpacksToUniqueLocationsPerArchive() throws Exception { + createTestJarArchive(true); + URL firstNestedUrl = this.archive.getClassPathUrls(this::entryNameIsNestedJar).iterator().next(); + createTestJarArchive(true); + URL secondNestedUrl = this.archive.getClassPathUrls(this::entryNameIsNestedJar).iterator().next(); + assertThat(secondNestedUrl).isNotEqualTo(firstNestedUrl); + } + + @Test + void getClassPathUrlsWhenHasUnpackCommentUnpacksAndShareSameParent() throws Exception { + createTestJarArchive(true); + URL nestedUrl = this.archive.getClassPathUrls(this::entryNameIsNestedJar).iterator().next(); + URL anotherNestedUrl = this.archive.getClassPathUrls((entry) -> entry.name().equals("another-nested.jar")) + .iterator() + .next(); + assertThat(nestedUrl.toString()) + .isEqualTo(anotherNestedUrl.toString().replace("another-nested.jar", "nested.jar")); + } + + @Test + void getClassPathUrlsWhenZip64ListsAllEntries() throws Exception { + File file = new File(this.tempDir, "test.jar"); + FileCopyUtils.copy(writeZip64Jar(), file); + try (Archive jarArchive = new JarFileArchive(file)) { + Set urls = jarArchive.getClassPathUrls(Archive.ALL_ENTRIES); + assertThat(urls).hasSize(65537); + } + } + + 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 void createTestJarArchive(boolean unpackNested) throws Exception { + if (this.archive != null) { + this.archive.close(); + } + this.file = new File(this.tempDir, "root.jar"); + TestJar.create(this.file, unpackNested); + this.archive = new JarFileArchive(this.file); + } + + private boolean entryNameIsNestedJar(Entry entry) { + return entry.name().equals("nested.jar"); + } + +} 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/launch/JarLauncherTests.java similarity index 69% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/JarLauncherTests.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/JarLauncherTests.java index afa32a7c4f..7e231949be 100644 --- 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/launch/JarLauncherTests.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.boot.loader; +package org.springframework.boot.loader.launch; import java.io.File; import java.io.FileOutputStream; @@ -23,17 +23,16 @@ 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.Set; 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.boot.loader.net.protocol.jar.JarUrl; +import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed; import org.springframework.core.io.ClassPathResource; import org.springframework.core.test.tools.SourceFile; import org.springframework.core.test.tools.TestCompiler; @@ -47,19 +46,17 @@ import static org.assertj.core.api.Assertions.assertThat; * * @author Andy Wilkinson * @author Madhura Bhave + * @author Phillip Webb */ +@AssertFileChannelDataBlocksClosed 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(); - } + JarLauncher launcher = new JarLauncher(new ExplodedArchive(explodedRoot)); + Set urls = launcher.getClassPathUrls(); + assertThat(urls).containsExactlyInAnyOrder(getExpectedFileUrls(explodedRoot)); } @Test @@ -67,41 +64,33 @@ class JarLauncherTests extends AbstractExecutableArchiveLauncherTests { 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(); - } + Set urls = launcher.getClassPathUrls(); + List expectedUrls = new ArrayList<>(); + expectedUrls.add(JarUrl.create(jarRoot, "BOOT-INF/classes/")); + expectedUrls.add(JarUrl.create(jarRoot, "BOOT-INF/lib/foo.jar")); + expectedUrls.add(JarUrl.create(jarRoot, "BOOT-INF/lib/bar.jar")); + expectedUrls.add(JarUrl.create(jarRoot, "BOOT-INF/lib/baz.jar")); + assertThat(urls).containsOnlyOnceElementsOf(expectedUrls); } } @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)); + JarLauncher launcher = new JarLauncher(new ExplodedArchive(explodedRoot)); + URLClassLoader classLoader = createClassLoader(launcher); + assertThat(classLoader.getURLs()).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(); + JarLauncher launcher = new JarLauncher(new ExplodedArchive(explodedRoot)); + URLClassLoader classLoader = createClassLoader(launcher); List expectedFiles = getExpectedFilesWithExtraLibs(explodedRoot); URL[] expectedFileUrls = expectedFiles.stream().map(this::toUrl).toArray(URL[]::new); - assertThat(urls).containsExactly(expectedFileUrls); + assertThat(classLoader.getURLs()).containsExactly(expectedFileUrls); } @Test @@ -119,19 +108,22 @@ class JarLauncherTests extends AbstractExecutableArchiveLauncherTests { 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); + JarLauncher launcher = new JarLauncher(new ExplodedArchive(explodedRoot)); + URLClassLoader classLoader = createClassLoader(launcher); Class loaded = classLoader.loadClass("explodedsample.ExampleClass"); assertThat(loaded.getPackage().getImplementationTitle()).isEqualTo("test"); })); } - protected final URL[] getExpectedFileUrls(File explodedRoot) { + private URLClassLoader createClassLoader(JarLauncher launcher) throws Exception { + return (URLClassLoader) launcher.createClassLoader(launcher.getClassPathUrls()); + } + + private URL[] getExpectedFileUrls(File explodedRoot) { return getExpectedFiles(explodedRoot).stream().map(this::toUrl).toArray(URL[]::new); } - protected final List getExpectedFiles(File parent) { + private 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")); @@ -140,7 +132,7 @@ class JarLauncherTests extends AbstractExecutableArchiveLauncherTests { return expected; } - protected final List getExpectedFilesWithExtraLibs(File parent) { + private 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")); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/LaunchedClassLoaderTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/LaunchedClassLoaderTests.java new file mode 100644 index 0000000000..d3a92f50c6 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/LaunchedClassLoaderTests.java @@ -0,0 +1,49 @@ +/* + * 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; + +import java.net.URL; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.loader.jarmode.JarMode; +import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link LaunchedClassLoader}. + * + * @author Dave Syer + * @author Phillip Webb + * @author Andy Wilkinson + */ +@AssertFileChannelDataBlocksClosed +class LaunchedClassLoaderTests { + + @Test + void loadClassWhenJarModeClassLoadsInLaunchedClassLoader() throws Exception { + try (LaunchedClassLoader classLoader = new LaunchedClassLoader(false, new URL[] {}, + getClass().getClassLoader())) { + Class jarModeClass = classLoader.loadClass(JarMode.class.getName()); + Class jarModeRunnerClass = classLoader.loadClass(JarModeRunner.class.getName()); + assertThat(jarModeClass.getClassLoader()).isSameAs(classLoader); + assertThat(jarModeRunnerClass.getClassLoader()).isSameAs(classLoader); + } + } + +} 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/launch/LauncherTests.java similarity index 52% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/jarmode/LauncherJarModeTests.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/LauncherTests.java index dec587e18b..f300970c6d 100644 --- 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/launch/LauncherTests.java @@ -14,54 +14,64 @@ * limitations under the License. */ -package org.springframework.boot.loader.jarmode; +package org.springframework.boot.loader.launch; +import java.net.URL; import java.util.Collections; -import java.util.Iterator; +import java.util.Set; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; 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.loader.zip.AssertFileChannelDataBlocksClosed; 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. + * Tests for {@link Launcher}. * * @author Phillip Webb */ @ExtendWith(OutputCaptureExtension.class) -class LauncherJarModeTests { +@AssertFileChannelDataBlocksClosed +class LauncherTests { - @BeforeEach - void setup() { - System.setProperty(JarModeLauncher.DISABLE_SYSTEM_EXIT, "true"); - } + /** + * Jar Mode tests. + */ + @Nested + class JarMode { - @AfterEach - void cleanup() { - System.clearProperty("jarmode"); - System.clearProperty(JarModeLauncher.DISABLE_SYSTEM_EXIT); - } + @BeforeEach + void setup() { + System.setProperty(JarModeRunner.DISABLE_SYSTEM_EXIT, "true"); + } - @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]"); - } + @AfterEach + void cleanup() { + System.clearProperty("jarmode"); + System.clearProperty(JarModeRunner.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'"); + } - @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 { @@ -72,8 +82,13 @@ class LauncherJarModeTests { } @Override - protected Iterator getClassPathArchivesIterator() throws Exception { - return Collections.emptyIterator(); + protected Archive getArchive() { + return null; + } + + @Override + protected Set getClassPathUrls() throws Exception { + return Collections.emptySet(); } @Override 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/launch/PropertiesLauncherTests.java similarity index 75% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/PropertiesLauncherTests.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/PropertiesLauncherTests.java index ab7c296b38..6e41ed6f91 100644 --- 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/launch/PropertiesLauncherTests.java @@ -14,20 +14,17 @@ * limitations under the License. */ -package org.springframework.boot.loader; +package org.springframework.boot.loader.launch; 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.LinkedHashSet; import java.util.List; -import java.util.Map; +import java.util.Set; import java.util.jar.Attributes; import java.util.jar.Manifest; @@ -39,11 +36,9 @@ 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.loader.net.protocol.jar.JarUrl; +import org.springframework.boot.loader.testsupport.TestJar; +import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed; import org.springframework.boot.testsupport.system.CapturedOutput; import org.springframework.boot.testsupport.system.OutputCaptureExtension; import org.springframework.core.io.FileSystemResource; @@ -51,7 +46,7 @@ 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.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.hamcrest.Matchers.containsString; /** @@ -61,6 +56,7 @@ import static org.hamcrest.Matchers.containsString; * @author Andy Wilkinson */ @ExtendWith(OutputCaptureExtension.class) +@AssertFileChannelDataBlocksClosed class PropertiesLauncherTests { @TempDir @@ -73,9 +69,8 @@ class PropertiesLauncherTests { private CapturedOutput output; @BeforeEach - void setup(CapturedOutput capturedOutput) throws Exception { + void setup(CapturedOutput capturedOutput) { this.contextClassLoader = Thread.currentThread().getContextClassLoader(); - clearHandlerCache(); System.setProperty("loader.home", new File("src/test/resources").getAbsolutePath()); this.output = capturedOutput; } @@ -90,26 +85,13 @@ class PropertiesLauncherTests { 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() { + void testDefaultHome() throws Exception { System.clearProperty("loader.home"); this.launcher = new PropertiesLauncher(); assertThat(this.launcher.getHomeDirectory()).isEqualTo(new File(System.getProperty("user.dir"))); @@ -126,9 +108,8 @@ class PropertiesLauncherTests { @Test void testNonExistentHome() { System.setProperty("loader.home", "src/test/resources/nonexistent"); - assertThatIllegalStateException().isThrownBy(PropertiesLauncher::new) - .withMessageContaining("Invalid source directory") - .withCauseInstanceOf(IllegalArgumentException.class); + assertThatIllegalArgumentException().isThrownBy(PropertiesLauncher::new) + .withMessageContaining("Invalid source directory"); } @Test @@ -154,7 +135,7 @@ class PropertiesLauncherTests { } @Test - void testUserSpecifiedDotPath() { + void testUserSpecifiedDotPath() throws Exception { System.setProperty("loader.path", "."); this.launcher = new PropertiesLauncher(); assertThat(ReflectionTestUtils.getField(this.launcher, "paths")).hasToString("[.]"); @@ -165,9 +146,8 @@ class PropertiesLauncherTests { 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")); + Set urls = this.launcher.getClassPathUrls(); + assertThat(urls).areExactly(1, endingWith("app.jar")); } @Test @@ -196,29 +176,26 @@ class PropertiesLauncherTests { 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")); + Set urls = this.launcher.getClassPathUrls(); + assertThat(urls).areExactly(1, endingWith("foo.jar!/")); + assertThat(urls).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")); + Set urls = this.launcher.getClassPathUrls(); + assertThat(urls).areExactly(1, endingWith("foo.jar!/")); + assertThat(urls).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!/")); + Set urls = this.launcher.getClassPathUrls(); + assertThat(urls).areExactly(1, endingWith("foo.jar!/")); } @Test @@ -226,10 +203,9 @@ class PropertiesLauncherTests { 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")); + Set urls = this.launcher.getClassPathUrls(); + assertThat(urls).areExactly(1, endingWith("foo.jar!/")); + assertThat(urls).areExactly(1, endingWith("app.jar")); } @Test @@ -287,32 +263,21 @@ class PropertiesLauncherTests { void testCustomClassLoaderCreation() throws Exception { System.setProperty("loader.classLoader", TestLoader.class.getName()); this.launcher = new PropertiesLauncher(); - ClassLoader loader = this.launcher.createClassLoader(archives()); + ClassLoader loader = this.launcher.createClassLoader(classPathUrls()); 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); + private Set classPathUrls() throws Exception { + Set urls = new LinkedHashSet<>(); + String classPath = System.getProperty("java.class.path"); + for (String path : classPath.split(File.pathSeparator)) { + File file = new FileSystemResource(path).getFile(); + if (file.exists()) { + urls.add(file.toURI().toURL()); } } - 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); + return urls; } @Test @@ -331,7 +296,7 @@ class PropertiesLauncherTests { } @Test - void testSystemPropertiesSet() { + void testSystemPropertiesSet() throws Exception { System.setProperty("loader.system", "true"); new PropertiesLauncher(); assertThat(System.getProperty("loader.main")).isEqualTo("demo.Application"); @@ -374,17 +339,15 @@ class PropertiesLauncherTests { 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); + Set urls = this.launcher.getClassPathUrls(); + assertThat(urls).hasSize(1); + assertThat(urls.iterator().next()).isEqualTo(loaderPath.toURI().toURL()); } @Test // gh-21575 void loadResourceFromJarFile() throws Exception { - File jarFile = new File(this.tempDir, "app.jar"); - TestJarCreator.createTestJar(jarFile); + File file = new File(this.tempDir, "app.jar"); + TestJar.create(file); System.setProperty("loader.home", this.tempDir.getAbsolutePath()); System.setProperty("loader.path", "app.jar"); this.launcher = new PropertiesLauncher(); @@ -393,11 +356,10 @@ class PropertiesLauncherTests { } catch (Exception ex) { // Expected ClassNotFoundException - LaunchedURLClassLoader classLoader = (LaunchedURLClassLoader) Thread.currentThread() - .getContextClassLoader(); + LaunchedClassLoader classLoader = (LaunchedClassLoader) Thread.currentThread().getContextClassLoader(); classLoader.close(); } - URL resource = new URL("jar:" + jarFile.toURI() + "!/nested.jar!/3.dat"); + URL resource = JarUrl.create(file, "nested.jar", "3.dat"); byte[] bytes = FileCopyUtils.copyToByteArray(resource.openStream()); assertThat(bytes).isNotEmpty(); } @@ -406,11 +368,11 @@ class PropertiesLauncherTests { Awaitility.waitAtMost(Duration.ofSeconds(5)).until(this.output::toString, containsString(value)); } - private Condition endingWith(String value) { + private Condition endingWith(String value) { return new Condition<>() { @Override - public boolean matches(Archive archive) { + public boolean matches(URL archive) { return archive.toString().endsWith(value); } 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/launch/WarLauncherTests.java similarity index 64% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/WarLauncherTests.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/launch/WarLauncherTests.java index fbab8d36ed..cea89eabe7 100644 --- 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/launch/WarLauncherTests.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.boot.loader; +package org.springframework.boot.loader.launch; import java.io.File; import java.net.URL; @@ -22,14 +22,13 @@ 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.Set; 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.boot.loader.net.protocol.jar.JarUrl; +import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed; import static org.assertj.core.api.Assertions.assertThat; @@ -38,45 +37,39 @@ import static org.assertj.core.api.Assertions.assertThat; * * @author Andy Wilkinson * @author Scott Frederick + * @author Phillip Webb */ +@AssertFileChannelDataBlocksClosed 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(); - } + WarLauncher launcher = new WarLauncher(new ExplodedArchive(explodedRoot)); + Set urls = launcher.getClassPathUrls(); + assertThat(urls).containsExactlyInAnyOrder(getExpectedFileUrls(explodedRoot)); } @Test void archivedWarHasOnlyWebInfClassesAndContentsOfWebInfLibOnClasspath() throws Exception { - File jarRoot = createJarArchive("archive.war", "WEB-INF"); - try (JarFileArchive archive = new JarFileArchive(jarRoot)) { + File file = createJarArchive("archive.war", "WEB-INF"); + try (JarFileArchive archive = new JarFileArchive(file)) { 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(); - } + Set urls = launcher.getClassPathUrls(); + List expected = new ArrayList<>(); + expected.add(JarUrl.create(file, "WEB-INF/classes/")); + expected.add(JarUrl.create(file, "WEB-INF/lib/foo.jar")); + expected.add(JarUrl.create(file, "WEB-INF/lib/bar.jar")); + expected.add(JarUrl.create(file, "WEB-INF/lib/baz.jar")); + assertThat(urls).containsOnly(expected.toArray(URL[]::new)); } } @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); + WarLauncher launcher = new WarLauncher(new ExplodedArchive(explodedRoot)); + URLClassLoader classLoader = createClassLoader(launcher); URL[] urls = classLoader.getURLs(); assertThat(urls).containsExactly(getExpectedFileUrls(explodedRoot)); } @@ -85,20 +78,23 @@ class WarLauncherTests extends AbstractExecutableArchiveLauncherTests { 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); + WarLauncher launcher = new WarLauncher(new ExplodedArchive(explodedRoot)); + URLClassLoader classLoader = createClassLoader(launcher); 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) { + private URLClassLoader createClassLoader(Launcher launcher) throws Exception { + return (URLClassLoader) launcher.createClassLoader(launcher.getClassPathUrls()); + } + + private URL[] getExpectedFileUrls(File explodedRoot) { return getExpectedFiles(explodedRoot).stream().map(this::toUrl).toArray(URL[]::new); } - protected final List getExpectedFiles(File parent) { + private 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")); @@ -107,7 +103,7 @@ class WarLauncherTests extends AbstractExecutableArchiveLauncherTests { return expected; } - protected final List getExpectedFilesWithExtraLibs(File parent) { + private 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")); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/CanonicalizerTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/CanonicalizerTests.java new file mode 100644 index 0000000000..1e59e50d34 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/CanonicalizerTests.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.net.protocol.jar; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link Canonicalizer}. + * + * @author Phillip Webb + */ +class CanonicalizerTests { + + @Test + void canonicalizeAfterOnlyChangesAfterPos() { + String prefix = "/foo/.././bar/.!/foo/.././bar/."; + String canonicalized = Canonicalizer.canonicalizeAfter(prefix, prefix.indexOf("!/")); + assertThat(canonicalized).isEqualTo("/foo/.././bar/.!/bar/"); + } + + @Test + void canonicalizeWhenHasEmbdeddSlashDotDotSlash() { + assertThat(Canonicalizer.canonicalize("/foo/../bar/bif/bam/../../baz")).isEqualTo("/bar/baz"); + } + + @Test + void canonicalizeWhenHasEmbdeddSlashDotSlash() { + assertThat(Canonicalizer.canonicalize("/foo/./bar/bif/bam/././baz")).isEqualTo("/foo/bar/bif/bam/baz"); + } + + @Test + void canonicalizeWhenHasTrailingSlashDotDot() { + assertThat(Canonicalizer.canonicalize("/foo/bar/baz/../..")).isEqualTo("/foo/"); + } + + @Test + void canonicalizeWhenHasTrailingSlashDot() { + assertThat(Canonicalizer.canonicalize("/foo/bar/baz/./.")).isEqualTo("/foo/bar/baz/"); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/HandlerTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/HandlerTests.java new file mode 100644 index 0000000000..8d69572115 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/HandlerTests.java @@ -0,0 +1,203 @@ +/* + * 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.net.protocol.jar; + +import java.net.MalformedURLException; +import java.net.URL; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Tests for {@link Handler}. + * + * @author Andy Wilkinson + * @author Phillip Webb + */ +@AssertFileChannelDataBlocksClosed +class HandlerTests { + + private final Handler handler = new Handler(); + + @Test + void indexOfSeparator() { + String spec = "jar:nested:foo!bar!/some/entry#foo"; + assertThat(Handler.indexOfSeparator(spec, 0, spec.indexOf('#'))).isEqualTo(spec.lastIndexOf("!/")); + } + + @Test + void indexOfSeparatorWhenHasStartAndLimit() { + String spec = "a!/jar:nested:foo!bar!/some/entry#foo!/b"; + int beginIndex = 3; + int endIndex = spec.length() - 4; + String substring = spec.substring(beginIndex, endIndex); + assertThat(Handler.indexOfSeparator(spec, 0, spec.indexOf('#'))) + .isEqualTo(substring.lastIndexOf("!/") + beginIndex); + } + + @Test + void parseUrlWhenAbsoluteParses() throws MalformedURLException { + URL url = createJarUrl(""); + String spec = "jar:file:example.jar!/entry.txt"; + this.handler.parseURL(url, spec, 4, spec.length()); + assertThat(url.toExternalForm()).isEqualTo(spec); + } + + @Test + void parseUrlWhenAbsoluteWithAnchorParses() throws MalformedURLException { + URL url = createJarUrl(""); + String spec = "jar:file:example.jar!/entry.txt"; + this.handler.parseURL(url, spec + "#foo", 4, spec.length()); + assertThat(url.toExternalForm()).isEqualTo(spec + "#foo"); + } + + @Test + void parseUrlWhenAbsoluteWithNoSeparatorThrowsException() throws MalformedURLException { + URL url = createJarUrl(""); + String spec = "jar:file:example.jar!\\entry.txt"; + assertThatIllegalStateException().isThrownBy(() -> this.handler.parseURL(url, spec, 4, spec.length())) + .withMessage("no !/ in spec"); + } + + @Test + void parseUrlWhenAbsoluteWithMalformedInnerUrlThrowsException() throws MalformedURLException { + URL url = createJarUrl(""); + String spec = "jar:example.jar!/entry.txt"; + assertThatIllegalStateException().isThrownBy(() -> this.handler.parseURL(url, spec, 4, spec.length())) + .withMessage( + "invalid url: jar:example.jar!/entry.txt (java.net.MalformedURLException: no protocol: example.jar)"); + } + + @Test + void parseUrlWhenRelativeWithLeadingSlashParses() throws MalformedURLException { + URL url = createJarUrl("file:example.jar!/entry.txt"); + String spec = "/other.txt"; + this.handler.parseURL(url, spec, 0, spec.length()); + assertThat(url.toExternalForm()).isEqualTo("jar:file:example.jar!/other.txt"); + } + + @Test + void parseUrlWhenRelativeWithLeadingSlashAndAnchorParses() throws MalformedURLException { + URL url = createJarUrl("file:example.jar!/entry.txt"); + String spec = "/other.txt"; + this.handler.parseURL(url, spec + "#relative", 0, spec.length()); + assertThat(url.toExternalForm()).isEqualTo("jar:file:example.jar!/other.txt#relative"); + } + + @Test + void parseUrlWhenRelativeWithLeadingSlashAndNoSeparator() throws MalformedURLException { + URL url = createJarUrl("file:example.jar/entry.txt"); + String spec = "/other.txt"; + assertThatIllegalStateException().isThrownBy(() -> this.handler.parseURL(url, spec, 0, spec.length())) + .withMessage("malformed context url:jar:file:example.jar/entry.txt: no !/"); + } + + @Test + void parseUrlWhenRelativeWithoutLeadingSlashParses() throws MalformedURLException { + URL url = createJarUrl("file:example.jar!/foo/"); + String spec = "bar.txt"; + this.handler.parseURL(url, spec, 0, spec.length()); + assertThat(url.toExternalForm()).isEqualTo("jar:file:example.jar!/foo/bar.txt"); + } + + @Test + void parseUrlWhenRelativeWithoutLeadingSlashAndWithoutTrailingSlashParses() throws MalformedURLException { + URL url = createJarUrl("file:example.jar!/foo/baz"); + String spec = "bar.txt"; + this.handler.parseURL(url, spec, 0, spec.length()); + assertThat(url.toExternalForm()).isEqualTo("jar:file:example.jar!/foo/bar.txt"); + } + + @Test + void parseUrlWhenRelativeWithoutLeadingSlashAndWithoutContextSlashThrowsException() throws MalformedURLException { + URL url = createJarUrl("file:example.jar"); + String spec = "bar.txt"; + assertThatIllegalStateException().isThrownBy(() -> this.handler.parseURL(url, spec, 0, spec.length())) + .withMessage("malformed context url:jar:file:example.jar"); + } + + @Test + void parseUrlWhenAnchorOnly() throws MalformedURLException { + URL url = createJarUrl("file:example.jar!/entry.txt"); + String spec = "#runtime"; + this.handler.parseURL(url, spec, 0, 0); + assertThat(url.toExternalForm()).isEqualTo("jar:file:example.jar!/entry.txt#runtime"); + } + + @Test + void hashCodeGeneratesHashCode() throws MalformedURLException { + URL url = createJarUrl("file:example.jar!/entry.txt"); + assertThat(this.handler.hashCode(url)).isEqualTo(1873709601); + } + + @Test + void hashCodeWhenMalformedInnerUrlGeneratesHashCode() throws MalformedURLException { + URL url = createJarUrl("example.jar!/entry.txt"); + assertThat(this.handler.hashCode(url)).isEqualTo(1870566566); + } + + @Test + void sameFileWhenSameReturnsTrue() throws MalformedURLException { + URL url1 = createJarUrl("file:example.jar!/entry.txt"); + URL url2 = createJarUrl("file:example.jar!/entry.txt"); + assertThat(this.handler.sameFile(url1, url2)).isTrue(); + } + + @Test + void sameFileWhenMissingSeparatorReturnsFalse() throws MalformedURLException { + URL url1 = createJarUrl("file:example.jar!/entry.txt"); + URL url2 = createJarUrl("file:example.jar/entry.txt"); + assertThat(this.handler.sameFile(url1, url2)).isFalse(); + } + + @Test + void sameFileWhenDifferentEntryReturnsFalse() throws MalformedURLException { + URL url1 = createJarUrl("file:example.jar!/entry1.txt"); + URL url2 = createJarUrl("file:example.jar!/entry2.txt"); + assertThat(this.handler.sameFile(url1, url2)).isFalse(); + } + + @Test + void sameFileWhenDifferentInnerUrlReturnsFalse() throws MalformedURLException { + URL url1 = createJarUrl("file:example1.jar!/entry.txt"); + URL url2 = createJarUrl("file:example2.jar!/entry.txt"); + assertThat(this.handler.sameFile(url1, url2)).isFalse(); + } + + @Test + void sameFileWhenSameMalformedInnerUrlReturnsTrue() throws MalformedURLException { + URL url1 = createJarUrl("example.jar!/entry.txt"); + URL url2 = createJarUrl("example.jar!/entry.txt"); + assertThat(this.handler.sameFile(url1, url2)).isTrue(); + } + + @Test + void sameFileWhenDifferentMalformedInnerUrlReturnsFalse() throws MalformedURLException { + URL url1 = createJarUrl("example1.jar!/entry.txt"); + URL url2 = createJarUrl("example2.jar!/entry.txt"); + assertThat(this.handler.sameFile(url1, url2)).isFalse(); + } + + private URL createJarUrl(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/net/protocol/jar/JarFileUrlKeyTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarFileUrlKeyTests.java new file mode 100644 index 0000000000..b4131123d5 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarFileUrlKeyTests.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.net.protocol.jar; + +import java.net.URL; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.loader.net.protocol.Handlers; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link JarFileUrlKey}. + * + * @author Phillip Webb + */ +class JarFileUrlKeyTests { + + @BeforeAll + static void setup() { + Handlers.register(); + } + + @Test + void getCreatesKey() throws Exception { + URL url = new URL("jar:nested:/my.jar/!mynested.jar!/my/path"); + assertThat(JarFileUrlKey.get(url)).isEqualTo("jar:nested:/my.jar/!mynested.jar!/my/path"); + } + + @Test + void getWhenUppercaseProtocolCreatesKey() throws Exception { + URL url = new URL("JAR:nested:/my.jar/!mynested.jar!/my/path"); + assertThat(JarFileUrlKey.get(url)).isEqualTo("jar:nested:/my.jar/!mynested.jar!/my/path"); + } + + @Test + void getWhenHasHostAndPortCreatesKey() throws Exception { + URL url = new URL("https://example.com:1234/test"); + assertThat(JarFileUrlKey.get(url)).isEqualTo("https:example.com:1234/test"); + } + + @Test + void getWhenHasUppercaseHostCreatesKey() throws Exception { + URL url = new URL("https://EXAMPLE.com:1234/test"); + assertThat(JarFileUrlKey.get(url)).isEqualTo("https:example.com:1234/test"); + } + + @Test + void getWhenHasNoPortCreatesKeyWithDefaultPort() throws Exception { + URL url = new URL("https://EXAMPLE.com/test"); + assertThat(JarFileUrlKey.get(url)).isEqualTo("https:example.com:443/test"); + } + + @Test + void getWhenHasNoFileCreatesKey() throws Exception { + URL url = new URL("https://EXAMPLE.com"); + assertThat(JarFileUrlKey.get(url)).isEqualTo("https:example.com:443"); + } + + @Test + void getWhenHasRuntimeRefCreatesKey() throws Exception { + URL url = new URL("jar:nested:/my.jar/!mynested.jar!/my/path#runtime"); + assertThat(JarFileUrlKey.get(url)).isEqualTo("jar:nested:/my.jar/!mynested.jar!/my/path#runtime"); + } + + @Test + void getWhenHasOtherRefCreatesKeyWithoutRef() throws Exception { + URL url = new URL("jar:nested:/my.jar/!mynested.jar!/my/path#example"); + assertThat(JarFileUrlKey.get(url)).isEqualTo("jar:nested:/my.jar/!mynested.jar!/my/path"); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlClassLoaderTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlClassLoaderTests.java new file mode 100644 index 0000000000..d4eeed8c29 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlClassLoaderTests.java @@ -0,0 +1,147 @@ +/* + * 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.net.protocol.jar; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.file.Files; +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.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.net.protocol.Handlers; +import org.springframework.boot.loader.testsupport.TestJar; +import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link JarUrlClassLoader}. + * + * @author Phillip Webb + */ +@AssertFileChannelDataBlocksClosed +class JarUrlClassLoaderTests { + + private static final URL APP_JAR; + static { + try { + APP_JAR = new URL("jar:file:src/test/resources/jars/app.jar!/"); + } + catch (MalformedURLException ex) { + throw new IllegalStateException(ex); + } + } + + @TempDir + File tempDir; + + @BeforeAll + static void setup() { + Handlers.register(); + } + + @Test + void resolveResourceFromArchive() throws Exception { + try (JarUrlClassLoader loader = new TestJarUrlClassLoader(APP_JAR)) { + assertThat(loader.getResource("demo/Application.java")).isNotNull(); + } + } + + @Test + void resolveResourcesFromArchive() throws Exception { + try (JarUrlClassLoader loader = new TestJarUrlClassLoader(APP_JAR)) { + assertThat(loader.getResources("demo/Application.java").hasMoreElements()).isTrue(); + } + } + + @Test + void resolveRootPathFromArchive() throws Exception { + try (JarUrlClassLoader loader = new TestJarUrlClassLoader(APP_JAR)) { + assertThat(loader.getResource("")).isNotNull(); + } + } + + @Test + void resolveRootResourcesFromArchive() throws Exception { + try (JarUrlClassLoader loader = new TestJarUrlClassLoader(APP_JAR)) { + assertThat(loader.getResources("").hasMoreElements()).isTrue(); + } + } + + @Test + void resolveFromNested() throws Exception { + File jarFile = new File(this.tempDir, "test.jar"); + TestJar.create(jarFile); + URL url = JarUrl.create(jarFile, "nested.jar"); + try (JarUrlClassLoader loader = new TestJarUrlClassLoader(url)) { + URL resource = loader.getResource("3.dat"); + assertThat(resource).hasToString(url + "3.dat"); + try (InputStream input = resource.openConnection().getInputStream()) { + assertThat(input.read()).isEqualTo(3); + } + } + } + + @Test + void loadClass() throws Exception { + try (JarUrlClassLoader loader = new TestJarUrlClassLoader(APP_JAR)) { + assertThat(loader.loadClass("demo.Application")).isNotNull().hasToString("class demo.Application"); + } + } + + @Test + void loadClassFromNested() throws Exception { + File appJar = new File("src/test/resources/jars/app.jar"); + File jarFile = new File(this.tempDir, "test.jar"); + FileOutputStream fileOutputStream = new FileOutputStream(jarFile); + try (JarOutputStream jarOutputStream = new JarOutputStream(fileOutputStream)) { + JarEntry nestedEntry = new JarEntry("app.jar"); + byte[] nestedJarData = Files.readAllBytes(appJar.toPath()); + nestedEntry.setSize(nestedJarData.length); + nestedEntry.setCompressedSize(nestedJarData.length); + CRC32 crc32 = new CRC32(); + crc32.update(nestedJarData); + nestedEntry.setCrc(crc32.getValue()); + nestedEntry.setMethod(ZipEntry.STORED); + jarOutputStream.putNextEntry(nestedEntry); + jarOutputStream.write(nestedJarData); + jarOutputStream.closeEntry(); + } + URL url = JarUrl.create(jarFile, "app.jar"); + try (JarUrlClassLoader loader = new TestJarUrlClassLoader(url)) { + assertThat(loader.loadClass("demo.Application")).isNotNull().hasToString("class demo.Application"); + } + } + + static class TestJarUrlClassLoader extends JarUrlClassLoader { + + TestJarUrlClassLoader(URL... urls) { + super(urls, JarUrlClassLoaderTests.class.getClassLoader()); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlConnectionTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlConnectionTests.java new file mode 100644 index 0000000000..5d7ccf616b --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlConnectionTests.java @@ -0,0 +1,480 @@ +/* + * 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.net.protocol.jar; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLConnection; +import java.nio.charset.StandardCharsets; +import java.security.Permission; +import java.util.List; +import java.util.Map; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.JarOutputStream; +import java.util.zip.ZipEntry; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.net.protocol.Handlers; +import org.springframework.boot.loader.testsupport.TestJar; +import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed; +import org.springframework.boot.loader.zip.ZipContent; +import org.springframework.test.util.ReflectionTestUtils; + +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.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; + +/** + * Tests for {@link JarUrlConnection}. + * + * @author Phillip Webb + */ +@AssertFileChannelDataBlocksClosed +class JarUrlConnectionTests { + + @TempDir + File temp; + + private File file; + + private URL url; + + @BeforeAll + static void registerHandlers() { + Handlers.register(); + } + + @BeforeEach + @AfterEach + void reset() { + JarUrlConnection.clearCache(); + Optimizations.disable(); + } + + @BeforeEach + void setup() throws Exception { + this.file = new File(this.temp, "test.jar"); + TestJar.create(this.file); + this.url = JarUrl.create(this.file, "nested.jar"); + } + + @Test + void getJarFileReturnsJarFile() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + JarFile jarFile = connection.getJarFile(); + assertThat(jarFile).isNotNull(); + assertThat(jarFile.getEntry("3.dat")).isNotNull(); + } + + @Test + void getJarEntryReturnsJarEntry() throws Exception { + URL url = JarUrl.create(this.file, "nested.jar", "3.dat"); + JarUrlConnection connection = JarUrlConnection.open(url); + JarEntry entry = connection.getJarEntry(); + assertThat(entry).isNotNull(); + assertThat(entry.getName()).isEqualTo("3.dat"); + } + + @Test + void getJarEntryWhenHasNoEntryNameReturnsNull() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + JarEntry entry = connection.getJarEntry(); + assertThat(entry).isNull(); + } + + @Test + void getContentLengthReturnsContentLength() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + try (ZipContent content = ZipContent.open(this.file.toPath())) { + int expected = content.getEntry("nested.jar").getUncompressedSize(); + assertThat(connection.getContentLength()).isEqualTo(expected); + } + } + + @Test + void getContentLengthWhenLengthIsLargerThanMaxIntReturnsMinusOne() { + JarUrlConnection connection = mock(JarUrlConnection.class); + given(connection.getContentLength()).willCallRealMethod(); + given(connection.getContentLengthLong()).willReturn((long) Integer.MAX_VALUE + 1); + assertThat(connection.getContentLength()).isEqualTo(-1); + } + + @Test + void getContentLengthLongWhenHasNoEntryReturnsSizeOfJar() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + try (ZipContent content = ZipContent.open(this.file.toPath())) { + int expected = content.getEntry("nested.jar").getUncompressedSize(); + assertThat(connection.getContentLengthLong()).isEqualTo(expected); + } + } + + @Test + void getContentLengthLongWhenHasEntryReturnsEntrySize() throws Exception { + URL url = JarUrl.create(this.file, "nested.jar", "3.dat"); + JarUrlConnection connection = JarUrlConnection.open(url); + assertThat(connection.getContentLengthLong()).isEqualTo(1); + } + + @Test + void getContentLengthLongWhenCannotConnectReturnsMinusOne() throws IOException { + JarUrlConnection connection = mock(JarUrlConnection.class); + willThrow(IOException.class).given(connection).connect(); + given(connection.getContentLengthLong()).willCallRealMethod(); + assertThat(connection.getContentLengthLong()).isEqualTo(-1); + } + + @Test + void getContentTypeWhenHasNoEntryReturnsJavaJar() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + assertThat(connection.getContentType()).isEqualTo("x-java/jar"); + } + + @Test + void getContentTypeWhenHasKnownStreamReturnsDeducedType() throws Exception { + String content = ""; + try (JarOutputStream out = new JarOutputStream(new FileOutputStream(this.file))) { + out.putNextEntry(new ZipEntry("test.dat")); + out.write(content.getBytes(StandardCharsets.UTF_8)); + out.closeEntry(); + } + JarUrlConnection connection = JarUrlConnection + .open(new URL("jar:file:" + this.file.getAbsolutePath() + "!/test.dat")); + assertThat(connection.getContentType()).isEqualTo("application/xml"); + } + + @Test + void getContentTypeWhenNotKnownInStreamButKnownNameReturnsDeducedType() throws Exception { + String content = "nothinguseful"; + try (JarOutputStream out = new JarOutputStream(new FileOutputStream(this.file))) { + out.putNextEntry(new ZipEntry("test.xml")); + out.write(content.getBytes(StandardCharsets.UTF_8)); + out.closeEntry(); + } + JarUrlConnection connection = JarUrlConnection + .open(new URL("jar:file:" + this.file.getAbsolutePath() + "!/test.xml")); + assertThat(connection.getContentType()).isEqualTo("application/xml"); + } + + @Test + void getContentTypeWhenCannotBeDeducedReturnsContentUnknown() throws Exception { + String content = "nothinguseful"; + try (JarOutputStream out = new JarOutputStream(new FileOutputStream(this.file))) { + out.putNextEntry(new ZipEntry("test.dat")); + out.write(content.getBytes(StandardCharsets.UTF_8)); + out.closeEntry(); + } + JarUrlConnection connection = JarUrlConnection + .open(new URL("jar:file:" + this.file.getAbsolutePath() + "!/test.dat")); + assertThat(connection.getContentType()).isEqualTo("content/unknown"); + } + + @Test + void getHeaderFieldDelegatesToJarFileConnection() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + URLConnection jarFileConnection = mock(URLConnection.class); + given(jarFileConnection.getHeaderField("test")).willReturn("test"); + ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection); + assertThat(connection.getHeaderField("test")).isEqualTo("test"); + } + + @Test + void getContentWhenHasEntryReturnsContentFromEntry() throws Exception { + String content = "hello"; + try (JarOutputStream out = new JarOutputStream(new FileOutputStream(this.file))) { + out.putNextEntry(new ZipEntry("test.txt")); + out.write(content.getBytes(StandardCharsets.UTF_8)); + out.closeEntry(); + } + JarUrlConnection connection = JarUrlConnection + .open(new URL("jar:file:" + this.file.getAbsolutePath() + "!/test.txt")); + assertThat(connection.getContent()).isInstanceOf(FilterInputStream.class); + } + + @Test + void getContentWhenHasNoEntryReturnsJarFile() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + assertThat(connection.getContent()).isInstanceOf(JarFile.class); + } + + @Test + void getPermissionReturnJarConnectionPermission() throws IOException { + JarUrlConnection connection = JarUrlConnection.open(this.url); + URLConnection jarFileConnection = mock(URLConnection.class); + Permission permission = mock(Permission.class); + given(jarFileConnection.getPermission()).willReturn(permission); + ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection); + assertThat(connection.getPermission()).isSameAs(permission); + } + + @Test + void getInputStreamWhenHasNoEntryThrowsException() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + assertThatIOException().isThrownBy(() -> connection.getInputStream()).withMessage("no entry name specified"); + } + + @Test + void getInputStreamWhenOptimizedWithoutReadAndHasCachedJarWithEntryReturnsEmptyInputStream() throws Exception { + JarUrlConnection setupConnection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar")); + setupConnection.connect(); + assertThat(JarUrlConnection.jarFiles.getCached(setupConnection.getJarFileURL())).isNotNull(); + JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar", "3.dat")); + connection.setUseCaches(false); + Optimizations.enable(false); + assertThat(connection.getInputStream()).isSameAs(JarUrlConnection.emptyInputStream); + } + + @Test + void getInputStreamWhenNoEntryAndOptimzedThrowsException() throws Exception { + JarUrlConnection setupConnection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar")); + setupConnection.connect(); + assertThat(JarUrlConnection.jarFiles.getCached(setupConnection.getJarFileURL())).isNotNull(); + JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar", "missing.dat")); + Optimizations.enable(false); + assertThatExceptionOfType(FileNotFoundException.class).isThrownBy(connection::getInputStream) + .isSameAs(JarUrlConnection.FILE_NOT_FOUND_EXCEPTION); + } + + @Test + void getInputStreamWhenNoEntryAndNotOptimzedThrowsException() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar", "missing.dat")); + assertThatExceptionOfType(FileNotFoundException.class).isThrownBy(connection::getInputStream) + .withMessageContaining("JAR entry missing.dat not found in"); + } + + @Test + void getInputStreamReturnsInputStream() throws IOException { + JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar", "3.dat")); + try (InputStream in = connection.getInputStream()) { + assertThat(in).hasBinaryContent(new byte[] { 3 }); + } + } + + @Test + void getInputStreamWhenNoCachedClosesJarFileOnClose() throws IOException { + JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar", "3.dat")); + connection.setUseCaches(false); + InputStream in = connection.getInputStream(); + JarFile jarFile = (JarFile) ReflectionTestUtils.getField(connection, "jarFile"); + jarFile = spy(jarFile); + ReflectionTestUtils.setField(connection, "jarFile", jarFile); + in.close(); + then(jarFile).should().close(); + } + + @Test + void getAllowUserInteractionDelegatesToJarFileConnection() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + URLConnection jarFileConnection = mock(URLConnection.class); + ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection); + given(jarFileConnection.getAllowUserInteraction()).willReturn(true); + assertThat(connection.getAllowUserInteraction()).isTrue(); + then(jarFileConnection).should().getAllowUserInteraction(); + } + + @Test + void setAllowUserInteractionDelegatesToJarFileConnection() throws IOException { + JarUrlConnection connection = JarUrlConnection.open(this.url); + URLConnection jarFileConnection = mock(URLConnection.class); + ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection); + connection.setAllowUserInteraction(true); + then(jarFileConnection).should().setAllowUserInteraction(true); + } + + @Test + void getUseCachesDelegatesToJarFileConnection() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + URLConnection jarFileConnection = mock(URLConnection.class); + ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection); + given(jarFileConnection.getUseCaches()).willReturn(true); + assertThat(connection.getUseCaches()).isTrue(); + then(jarFileConnection).should().getUseCaches(); + } + + @Test + void setUseCachesDelegatesToJarFileConnection() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + URLConnection jarFileConnection = mock(URLConnection.class); + ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection); + connection.setUseCaches(true); + then(jarFileConnection).should().setUseCaches(true); + } + + @Test + void getDefaultUseCachesDelegatesToJarFileConnection() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + URLConnection jarFileConnection = mock(URLConnection.class); + ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection); + given(jarFileConnection.getDefaultUseCaches()).willReturn(true); + assertThat(connection.getDefaultUseCaches()).isTrue(); + then(jarFileConnection).should().getDefaultUseCaches(); + } + + @Test + void setDefaultUseCachesDelegatesToJarFileConnection() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + URLConnection jarFileConnection = mock(URLConnection.class); + ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection); + connection.setDefaultUseCaches(true); + then(jarFileConnection).should().setDefaultUseCaches(true); + } + + @Test + void setIfModifiedSinceDelegatesToJarFileConnection() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + URLConnection jarFileConnection = mock(URLConnection.class); + ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection); + connection.setIfModifiedSince(123L); + then(jarFileConnection).should().setIfModifiedSince(123L); + } + + @Test + void getRequestPropertyDelegatesToJarFileConnection() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + URLConnection jarFileConnection = mock(URLConnection.class); + ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection); + given(jarFileConnection.getRequestProperty("test")).willReturn("test"); + assertThat(connection.getRequestProperty("test")).isEqualTo("test"); + then(jarFileConnection).should().getRequestProperty("test"); + } + + @Test + void setRequestPropertyDelegatesToJarFileConnection() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + URLConnection jarFileConnection = mock(URLConnection.class); + ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection); + connection.setRequestProperty("test", "testvalue"); + then(jarFileConnection).should().setRequestProperty("test", "testvalue"); + } + + @Test + void addRequestPropertyDelegatesToJarFileConnection() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + URLConnection jarFileConnection = mock(URLConnection.class); + ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection); + connection.addRequestProperty("test", "testvalue"); + then(jarFileConnection).should().addRequestProperty("test", "testvalue"); + } + + @Test + void getRequestPropertiesDelegatesToJarFileConnection() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + URLConnection jarFileConnection = mock(URLConnection.class); + ReflectionTestUtils.setField(connection, "jarFileConnection", jarFileConnection); + Map> properties = Map.of("test", List.of("testvalue")); + given(jarFileConnection.getRequestProperties()).willReturn(properties); + assertThat(connection.getRequestProperties()).isEqualTo(properties); + then(jarFileConnection).should().getRequestProperties(); + } + + @Test + void connectWhenConnectedDoesNotReconnect() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + connection.connect(); + ReflectionTestUtils.setField(connection, "jarFile", null); + connection.connect(); + assertThat(ReflectionTestUtils.getField(connection, "jarFile")).isNull(); + } + + @Test + void connectWhenHasNotFoundSupplierThrowsException() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar", "missing.dat")); + assertThat(connection).extracting("notFound").isNotNull(); + assertThatExceptionOfType(FileNotFoundException.class).isThrownBy(connection::connect) + .withMessageContaining("JAR entry missing.dat not found in"); + } + + @Test + void connectWhenOptimizationsEnabledAndHasCachedJarWithoutEntryThrowsException() throws Exception { + JarUrlConnection setupConnection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar")); + setupConnection.connect(); + assertThat(JarUrlConnection.jarFiles.getCached(setupConnection.getJarFileURL())).isNotNull(); + JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar", "missing.dat")); + Optimizations.enable(true); + assertThatExceptionOfType(FileNotFoundException.class).isThrownBy(connection::connect) + .isSameAs(JarUrlConnection.FILE_NOT_FOUND_EXCEPTION); + } + + @Test + void connectWhenHasNoEntryConnects() throws Exception { + JarUrlConnection setupConnection = JarUrlConnection.open(this.url); + setupConnection.connect(); + assertThat(setupConnection.getJarFile()).isNotNull(); + } + + @Test + void connectWhenEntryDoesNotExistAndOptimizationsEnabledThrowsException() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar", "missing.dat")); + Optimizations.enable(true); + assertThatExceptionOfType(FileNotFoundException.class).isThrownBy(connection::connect) + .isSameAs(JarUrlConnection.FILE_NOT_FOUND_EXCEPTION); + } + + @Test + void connectWhenEntryDoesNotExistAndNoOptimizationsEnabledThrowsException() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar", "missing.dat")); + assertThatExceptionOfType(FileNotFoundException.class).isThrownBy(connection::connect) + .withMessageContaining("JAR entry missing.dat not found in"); + } + + @Test + void connectWhenEntryExists() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar", "3.dat")); + connection.connect(); + assertThat(connection.getJarEntry()).isNotNull(); + } + + @Test + void connectWhenAddedToCacheReconnects() throws IOException { + JarUrlConnection connection = JarUrlConnection.open(this.url); + Object originalConnection = ReflectionTestUtils.getField(connection, "jarFileConnection"); + connection.connect(); + assertThat(connection).extracting("jarFileConnection").isNotSameAs(originalConnection); + } + + @Test + void openWhenNestedAndInCachedWithoutEntryAndOptimzationsEnabledReturnsNoFoundConnection() throws Exception { + JarUrlConnection setupConnection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar")); + setupConnection.connect(); + assertThat(JarUrlConnection.jarFiles.getCached(setupConnection.getJarFileURL())).isNotNull(); + Optimizations.enable(true); + JarUrlConnection connection = JarUrlConnection.open(JarUrl.create(this.file, "nested.jar", "missing.dat")); + assertThat(connection).isSameAs(JarUrlConnection.NOT_FOUND_CONNECTION); + } + + @Test + void openReturnsConnection() throws Exception { + JarUrlConnection connection = JarUrlConnection.open(this.url); + assertThat(connection).isNotNull(); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlTests.java new file mode 100644 index 0000000000..082550058e --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/JarUrlTests.java @@ -0,0 +1,87 @@ +/* + * 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.net.protocol.jar; + +import java.io.File; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.jar.JarEntry; + +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; + +/** + * Tests for {@link JarUrl}. + * + * @author Phillip Webb + */ +class JarUrlTests { + + @TempDir + File temp; + + File jarFile; + + String jarFileUrlPath; + + @BeforeEach + void setup() throws MalformedURLException { + this.jarFile = new File(this.temp, "my.jar"); + this.jarFileUrlPath = this.temp.toURI().toURL().toString().substring("file:".length()); + } + + @Test + void createWithFileReturnsUrl() { + URL url = JarUrl.create(this.temp); + assertThat(url).hasToString("jar:file:%s!/".formatted(this.jarFileUrlPath)); + } + + @Test + void createWithFileAndEntryReturnsUrl() { + JarEntry entry = new JarEntry("lib.jar"); + URL url = JarUrl.create(this.temp, entry); + assertThat(url).hasToString("jar:nested:%s/!lib.jar!/".formatted(this.jarFileUrlPath)); + } + + @Test + void createWithFileAndNullEntryReturnsUrl() { + URL url = JarUrl.create(this.temp, (JarEntry) null); + assertThat(url).hasToString("jar:file:%s!/".formatted(this.jarFileUrlPath)); + } + + @Test + void createWithFileAndNameReturnsUrl() { + URL url = JarUrl.create(this.temp, "lib.jar"); + assertThat(url).hasToString("jar:nested:%s/!lib.jar!/".formatted(this.jarFileUrlPath)); + } + + @Test + void createWithFileAndNullNameReturnsUrl() { + URL url = JarUrl.create(this.temp, (String) null); + assertThat(url).hasToString("jar:file:%s!/".formatted(this.jarFileUrlPath)); + } + + @Test + void createWithFileNameAndPathReturnsUrl() { + URL url = JarUrl.create(this.temp, "lib.jar", "com/example/My.class"); + assertThat(url).hasToString("jar:nested:%s/!lib.jar!/com/example/My.class".formatted(this.jarFileUrlPath)); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/LazyDelegatingInputStreamTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/LazyDelegatingInputStreamTests.java new file mode 100644 index 0000000000..f272a6d8aa --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/LazyDelegatingInputStreamTests.java @@ -0,0 +1,127 @@ +/* + * 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.net.protocol.jar; + +import java.io.IOException; +import java.io.InputStream; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link LazyDelegatingInputStream}. + * + * @author Phillip Webb + */ +class LazyDelegatingInputStreamTests { + + private InputStream delegate = mock(InputStream.class); + + private TestLazyDelegatingInputStream inputStream = new TestLazyDelegatingInputStream(); + + @Test + void noOperationsDoesNotGetDelegateInputStream() { + then(this.delegate).shouldHaveNoInteractions(); + } + + @Test + void readDelegatesToInputStream() throws Exception { + this.inputStream.read(); + then(this.delegate).should().read(); + } + + @Test + void readWithByteArrayDelegatesToInputStream() throws Exception { + byte[] bytes = new byte[1]; + this.inputStream.read(bytes); + then(this.delegate).should().read(bytes); + } + + @Test + void readWithByteArrayAndOffsetAndLenDelegatesToInputStream() throws Exception { + byte[] bytes = new byte[1]; + this.inputStream.read(bytes, 0, 1); + then(this.delegate).should().read(bytes, 0, 1); + } + + @Test + void skipDelegatesToInputStream() throws Exception { + this.inputStream.skip(10); + then(this.delegate).should().skip(10); + } + + @Test + void availableDelegatesToInputStream() throws Exception { + this.inputStream.available(); + then(this.delegate).should().available(); + } + + @Test + void markSupportedDelegatesToInputStream() { + this.inputStream.markSupported(); + then(this.delegate).should().markSupported(); + } + + @Test + void markDelegatesToInputStream() { + this.inputStream.mark(10); + then(this.delegate).should().mark(10); + } + + @Test + void resetDelegatesToInputStream() throws Exception { + this.inputStream.reset(); + then(this.delegate).should().reset(); + } + + @Test + void closeWhenDelegateNotCreatedDoesNothing() throws Exception { + this.inputStream.close(); + then(this.delegate).shouldHaveNoInteractions(); + } + + @Test + void closeDelegatesToInputStream() throws Exception { + this.inputStream.available(); + this.inputStream.close(); + then(this.delegate).should().close(); + } + + @Test + void getDelegateInputStreamIsOnlyCalledOnce() throws Exception { + this.inputStream.available(); + this.inputStream.mark(10); + this.inputStream.read(); + assertThat(this.inputStream.count).isOne(); + } + + private class TestLazyDelegatingInputStream extends LazyDelegatingInputStream { + + private int count; + + @Override + protected InputStream getDelegateInputStream() throws IOException { + this.count++; + return LazyDelegatingInputStreamTests.this.delegate; + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/OptimizationsTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/OptimizationsTests.java new file mode 100644 index 0000000000..40afdb813a --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/OptimizationsTests.java @@ -0,0 +1,79 @@ +/* + * 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.net.protocol.jar; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link Optimizations}. + * + * @author Phillip Webb + */ +class OptimizationsTests { + + @AfterEach + void reset() { + Optimizations.disable(); + } + + @Test + void defaultIsNotEnabled() { + assertThat(Optimizations.isEnabled()).isFalse(); + assertThat(Optimizations.isEnabled(true)).isFalse(); + assertThat(Optimizations.isEnabled(false)).isFalse(); + } + + @Test + void enableWithReadContentsEnables() { + Optimizations.enable(true); + assertThat(Optimizations.isEnabled()).isTrue(); + assertThat(Optimizations.isEnabled(true)).isTrue(); + assertThat(Optimizations.isEnabled(false)).isFalse(); + } + + @Test + void enableWithoutReadContentsEnables() { + Optimizations.enable(false); + assertThat(Optimizations.isEnabled()).isTrue(); + assertThat(Optimizations.isEnabled(true)).isFalse(); + assertThat(Optimizations.isEnabled(false)).isTrue(); + } + + @Test + void enableIsByThread() throws InterruptedException { + Optimizations.enable(true); + boolean[] enabled = new boolean[1]; + Thread thread = new Thread(() -> enabled[0] = Optimizations.isEnabled()); + thread.start(); + thread.join(); + assertThat(enabled[0]).isFalse(); + } + + @Test + void disableDisables() { + Optimizations.enable(true); + Optimizations.disable(); + assertThat(Optimizations.isEnabled()).isFalse(); + assertThat(Optimizations.isEnabled(true)).isFalse(); + assertThat(Optimizations.isEnabled(false)).isFalse(); + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarEntryTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarEntryTests.java new file mode 100644 index 0000000000..44d71008f3 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarEntryTests.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.net.protocol.jar; + +import java.util.jar.Attributes; +import java.util.jar.JarEntry; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link UrlJarEntry}. + * + * @author Phillip Webb + */ +class UrlJarEntryTests { + + @Test + void ofWhenEntryIsNullReturnsNull() { + assertThat(UrlJarEntry.of(null, null)).isNull(); + } + + @Test + void ofReturnsUrlJarEntry() { + JarEntry entry = new JarEntry("test"); + assertThat(UrlJarEntry.of(entry, null)).isNotNull(); + + } + + @Test + void getAttributesDelegatesToUrlJarManifest() throws Exception { + JarEntry entry = new JarEntry("test"); + UrlJarManifest manifest = mock(UrlJarManifest.class); + Attributes attributes = mock(Attributes.class); + given(manifest.getEntryAttributes(any())).willReturn(attributes); + assertThat(UrlJarEntry.of(entry, manifest).getAttributes()).isSameAs(attributes); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileFactoryTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileFactoryTests.java new file mode 100644 index 0000000000..5f16d8c6cb --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileFactoryTests.java @@ -0,0 +1,114 @@ +/* + * 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.net.protocol.jar; + +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.net.URL; +import java.util.function.Consumer; +import java.util.jar.JarFile; + +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import org.springframework.boot.loader.net.protocol.Handlers; +import org.springframework.boot.loader.testsupport.TestJar; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link UrlJarFileFactory}. + * + * @author Phillip Webb + */ +class UrlJarFileFactoryTests { + + @TempDir + File temp; + + private final UrlJarFileFactory factory = new UrlJarFileFactory(); + + @Mock + private Consumer closeAction; + + @BeforeAll + static void registerHandlers() { + Handlers.register(); + } + + @BeforeEach + void setup() { + MockitoAnnotations.openMocks(this); + } + + @Test + void createJarFileWhenLocalFile() throws Throwable { + File file = new File(this.temp, "test.jar"); + TestJar.create(file); + URL url = file.toURI().toURL(); + JarFile jarFile = this.factory.createJarFile(url, this.closeAction); + assertThat(jarFile).isInstanceOf(UrlJarFile.class); + assertThat(jarFile).hasFieldOrPropertyWithValue("closeAction", this.closeAction); + } + + @Test + void createJarFileWhenNested() throws Throwable { + File file = new File(this.temp, "test.jar"); + TestJar.create(file); + URL url = new URL("nested:" + file.getPath() + "/!nested.jar"); + JarFile jarFile = this.factory.createJarFile(url, this.closeAction); + assertThat(jarFile).isInstanceOf(UrlNestedJarFile.class); + assertThat(jarFile).hasFieldOrPropertyWithValue("closeAction", this.closeAction); + } + + @Test + void createJarFileWhenStream() throws Exception { + File file = new File(this.temp, "test.jar"); + TestJar.create(file); + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/test", (exchange) -> { + exchange.sendResponseHeaders(200, file.length()); + try (InputStream in = new FileInputStream(file)) { + in.transferTo(exchange.getResponseBody()); + } + exchange.close(); + }); + server.start(); + try { + URL url = new URL("http://localhost:" + server.getAddress().getPort() + "/test"); + JarFile jarFile = this.factory.createJarFile(url, this.closeAction); + assertThat(jarFile).isInstanceOf(UrlJarFile.class); + assertThat(jarFile).hasFieldOrPropertyWithValue("closeAction", this.closeAction); + } + finally { + server.stop(0); + } + } + + @Test + void createWhenHasRuntimeRef() { + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileTests.java new file mode 100644 index 0000000000..0640483c8c --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFileTests.java @@ -0,0 +1,89 @@ +/* + * 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.net.protocol.jar; + +import java.io.File; +import java.util.function.Consumer; +import java.util.jar.JarFile; +import java.util.jar.Manifest; + +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.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import org.springframework.boot.loader.testsupport.TestJar; +import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; + +/** + * Tests for {@link UrlJarFile}. + * + * @author Phillip Webb + */ +@AssertFileChannelDataBlocksClosed +class UrlJarFileTests { + + @TempDir + File temp; + + private UrlJarFile jarFile; + + @Mock + private Consumer closeAction; + + @BeforeEach + void setup() throws Exception { + MockitoAnnotations.openMocks(this); + File file = new File(this.temp, "test.jar"); + TestJar.create(file); + this.jarFile = new UrlJarFile(file, Runtime.version(), this.closeAction); + } + + @AfterEach + void cleanup() throws Exception { + this.jarFile.close(); + } + + @Test + void getEntryWhenNotfoundReturnsNull() { + assertThat(this.jarFile.getEntry("missing")).isNull(); + } + + @Test + void getEntryWhenFoundReturnsUrlJarEntry() { + assertThat(this.jarFile.getEntry("1.dat")).isInstanceOf(UrlJarEntry.class); + } + + @Test + void getManifestReturnsNewCopy() throws Exception { + Manifest manifest1 = this.jarFile.getManifest(); + Manifest manifest2 = this.jarFile.getManifest(); + assertThat(manifest1).isNotSameAs(manifest2); + } + + @Test + void closeCallsCloseAction() throws Exception { + this.jarFile.close(); + then(this.closeAction).should().accept(this.jarFile); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFilesTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFilesTests.java new file mode 100644 index 0000000000..f7a6ed089f --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarFilesTests.java @@ -0,0 +1,164 @@ +/* + * 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.net.protocol.jar; + +import java.io.File; +import java.net.URL; +import java.net.URLConnection; +import java.util.jar.JarFile; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.net.protocol.Handlers; +import org.springframework.boot.loader.testsupport.TestJar; +import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +/** + * Tests for {@link UrlJarFiles}. + * + * @author Phillip Webb + */ +@AssertFileChannelDataBlocksClosed +class UrlJarFilesTests { + + @TempDir + File temp; + + private UrlJarFileFactory factory = mock(UrlJarFileFactory.class); + + private final UrlJarFiles jarFiles = new UrlJarFiles(this.factory); + + private File file; + + private URL url; + + @BeforeAll + static void registerHandlers() { + Handlers.register(); + } + + @BeforeEach + void setup() throws Exception { + this.file = new File(this.temp, "test.jar"); + this.url = new URL("nested:" + this.file.getAbsolutePath() + "/!nested.jar"); + TestJar.create(this.file); + } + + @Test + void getOrCreateWhenNotUsingCachesAlwaysCreatesNewJarFile() throws Exception { + given(this.factory.createJarFile(any(), any())).willCallRealMethod(); + JarFile jarFile1 = this.jarFiles.getOrCreate(false, this.url); + JarFile jarFile2 = this.jarFiles.getOrCreate(false, this.url); + JarFile jarFile3 = this.jarFiles.getOrCreate(false, this.url); + assertThat(jarFile1).isNotSameAs(jarFile2).isNotSameAs(jarFile3); + } + + @Test + void getOrCreateWhenUsingCachingReturnsCachedWhenAvailable() throws Exception { + given(this.factory.createJarFile(any(), any())).willCallRealMethod(); + JarFile jarFile1 = this.jarFiles.getOrCreate(true, this.url); + this.jarFiles.cacheIfAbsent(true, this.url, jarFile1); + JarFile jarFile2 = this.jarFiles.getOrCreate(true, this.url); + JarFile jarFile3 = this.jarFiles.getOrCreate(true, this.url); + assertThat(jarFile1).isSameAs(jarFile2).isSameAs(jarFile3); + } + + @Test + void getCachedWhenNotCachedReturnsNull() { + assertThat(this.jarFiles.getCached(this.url)).isNull(); + } + + @Test + void getCachedWhenCachedReturnsCachedJar() throws Exception { + given(this.factory.createJarFile(any(), any())).willCallRealMethod(); + JarFile jarFile = this.factory.createJarFile(this.url, null); + this.jarFiles.cacheIfAbsent(true, this.url, jarFile); + assertThat(this.jarFiles.getCached(this.url)).isSameAs(jarFile); + } + + @Test + void cacheIfAbsentWhenNotUsingCachesDoesNotCacheAndReturnsFalse() throws Exception { + given(this.factory.createJarFile(any(), any())).willCallRealMethod(); + JarFile jarFile = this.factory.createJarFile(this.url, null); + this.jarFiles.cacheIfAbsent(false, this.url, jarFile); + assertThat(this.jarFiles.getCached(this.url)).isNull(); + } + + @Test + void cacheIfAbsentWhenUsingCachingAndNotAlreadyCachedCachesAndReturnsTrue() throws Exception { + given(this.factory.createJarFile(any(), any())).willCallRealMethod(); + JarFile jarFile = this.factory.createJarFile(this.url, null); + assertThat(this.jarFiles.cacheIfAbsent(true, this.url, jarFile)).isTrue(); + assertThat(this.jarFiles.getCached(this.url)).isSameAs(jarFile); + } + + @Test + void cacheIfAbsentWhenUsingCachingAndAlreadyCachedLeavesCacheAndReturnsFalse() throws Exception { + given(this.factory.createJarFile(any(), any())).willCallRealMethod(); + JarFile jarFile1 = this.factory.createJarFile(this.url, null); + JarFile jarFile2 = this.factory.createJarFile(this.url, null); + assertThat(this.jarFiles.cacheIfAbsent(true, this.url, jarFile1)).isTrue(); + assertThat(this.jarFiles.cacheIfAbsent(true, this.url, jarFile2)).isFalse(); + assertThat(this.jarFiles.getCached(this.url)).isSameAs(jarFile1); + } + + @Test + void closeIfNotCachedWhenNotCachedClosesJarFile() throws Exception { + JarFile jarFile = mock(JarFile.class); + this.jarFiles.closeIfNotCached(this.url, jarFile); + then(jarFile).should().close(); + } + + @Test + void closeIfNotCachedWhenCachedDoesNotCloseJarFile() throws Exception { + JarFile jarFile = mock(JarFile.class); + this.jarFiles.cacheIfAbsent(true, this.url, jarFile); + this.jarFiles.closeIfNotCached(this.url, jarFile); + then(jarFile).should(never()).close(); + } + + @Test + void reconnectReconnectsAndAppliesUseCaches() throws Exception { + JarFile jarFile = mock(JarFile.class); + this.jarFiles.cacheIfAbsent(true, this.url, jarFile); + URLConnection existingConnection = mock(URLConnection.class); + given(existingConnection.getUseCaches()).willReturn(true); + URLConnection connection = this.jarFiles.reconnect(jarFile, existingConnection); + assertThat(connection).isNotSameAs(existingConnection); + assertThat(connection.getUseCaches()).isTrue(); + } + + @Test + void reconnectWhenExistingConnectionIsNullReconnects() throws Exception { + JarFile jarFile = mock(JarFile.class); + this.jarFiles.cacheIfAbsent(true, this.url, jarFile); + URLConnection connection = this.jarFiles.reconnect(jarFile, null); + assertThat(connection).isNotNull(); + assertThat(connection.getUseCaches()).isTrue(); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarManifestTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarManifestTests.java new file mode 100644 index 0000000000..be13846ab9 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlJarManifestTests.java @@ -0,0 +1,89 @@ +/* + * 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.net.protocol.jar; + +import java.io.IOException; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.jar.Manifest; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.loader.net.protocol.jar.UrlJarManifest.ManifestSupplier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; + +/** + * Tests for {@link UrlJarManifest}. + * + * @author Phillip Webb + */ +class UrlJarManifestTests { + + @Test + void getWhenSuppliedManifestIsNullReturnsNull() throws Exception { + UrlJarManifest urlJarManifest = new UrlJarManifest(() -> null); + assertThat(urlJarManifest.get()).isNull(); + } + + @Test + void getAlwaysReturnsDeepCopy() throws Exception { + Manifest manifest = new Manifest(); + UrlJarManifest urlJarManifest = new UrlJarManifest(() -> manifest); + manifest.getMainAttributes().putValue("test", "one"); + manifest.getEntries().put("spring", new Attributes()); + Manifest copy = urlJarManifest.get(); + assertThat(copy).isNotSameAs(manifest); + manifest.getMainAttributes().clear(); + manifest.getEntries().clear(); + assertThat(copy.getMainAttributes()).isNotEmpty(); + assertThat(copy.getAttributes("spring")).isNotNull(); + } + + @Test + void getEntrtyAttributesWhenSuppliedManifestIsNullReturnsNull() throws Exception { + UrlJarManifest urlJarManifest = new UrlJarManifest(() -> null); + assertThat(urlJarManifest.getEntryAttributes(new JarEntry("test"))).isNull(); + } + + @Test + void getEntryAttributesReturnsDeepCopy() throws Exception { + Manifest manifest = new Manifest(); + UrlJarManifest urlJarManifest = new UrlJarManifest(() -> manifest); + Attributes attributes = new Attributes(); + attributes.putValue("test", "test"); + manifest.getEntries().put("spring", attributes); + Attributes copy = urlJarManifest.getEntryAttributes(new JarEntry("spring")); + assertThat(copy).isNotSameAs(attributes); + attributes.clear(); + assertThat(copy.getValue("test")).isNotNull(); + + } + + @Test + void supplierIsOnlyCalledOnce() throws IOException { + ManifestSupplier supplier = mock(ManifestSupplier.class); + UrlJarManifest urlJarManifest = new UrlJarManifest(supplier); + urlJarManifest.get(); + urlJarManifest.get(); + then(supplier).should(times(1)).getManifest(); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlNestedJarFileTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlNestedJarFileTests.java new file mode 100644 index 0000000000..137caca278 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/jar/UrlNestedJarFileTests.java @@ -0,0 +1,87 @@ +/* + * 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.net.protocol.jar; + +import java.io.File; +import java.util.function.Consumer; +import java.util.jar.JarFile; +import java.util.jar.Manifest; + +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.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import org.springframework.boot.loader.testsupport.TestJar; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; + +/** + * Tests for {@link UrlNestedJarFile}. + * + * @author Phillip Webb + */ +class UrlNestedJarFileTests { + + @TempDir + File temp; + + private UrlNestedJarFile jarFile; + + @Mock + private Consumer closeAction; + + @BeforeEach + void setup() throws Exception { + MockitoAnnotations.openMocks(this); + File file = new File(this.temp, "test.jar"); + TestJar.create(file); + this.jarFile = new UrlNestedJarFile(file, "multi-release.jar", Runtime.version(), this.closeAction); + } + + @AfterEach + void cleanup() throws Exception { + this.jarFile.close(); + } + + @Test + void getEntryWhenNotfoundReturnsNull() { + assertThat(this.jarFile.getEntry("missing")).isNull(); + } + + @Test + void getEntryWhenFoundReturnsUrlJarEntry() { + assertThat(this.jarFile.getEntry("multi-release.dat")).isInstanceOf(UrlJarEntry.class); + } + + @Test + void getManifestReturnsNewCopy() throws Exception { + Manifest manifest1 = this.jarFile.getManifest(); + Manifest manifest2 = this.jarFile.getManifest(); + assertThat(manifest1).isNotSameAs(manifest2); + } + + @Test + void closeCallsCloseAction() throws Exception { + this.jarFile.close(); + then(this.closeAction).should().accept(this.jarFile); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/nested/HandlerTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/nested/HandlerTests.java new file mode 100644 index 0000000000..b6d7339447 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/nested/HandlerTests.java @@ -0,0 +1,77 @@ +/* + * 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.net.protocol.nested; + +import java.io.File; +import java.net.URL; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.net.protocol.Handlers; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatNoException; + +/** + * Tests for {@link Handler}. + * + * @author Phillip Webb + */ +class HandlerTests { + + @TempDir + File temp; + + @BeforeAll + static void registerHandlers() { + Handlers.register(); + } + + @Test + void openConnectionReturnsNestedUrlConnection() throws Exception { + URL url = new URL("nested:" + this.temp.getAbsolutePath() + "/!nested.jar"); + assertThat(url.openConnection()).isInstanceOf(NestedUrlConnection.class); + } + + @Test + void assertUrlIsNotMalformedWhenUrlIsNullThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> Handler.assertUrlIsNotMalformed(null)) + .withMessageContaining("'url' must not be null"); + } + + @Test + void assertUrlIsNotMalformedWhenUrlIsNotNestedThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> Handler.assertUrlIsNotMalformed("file:")) + .withMessageContaining("must use 'nested'"); + } + + @Test + void assertUrlIsNotMalformedWhenUrlIsMalformedThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> Handler.assertUrlIsNotMalformed("nested:bad")) + .withMessageContaining("'path' must contain '/!'"); + } + + @Test + void assertUrlIsNotMalformedWhenUrlIsValidDoesNotThrowException() { + String url = "nested:" + this.temp.getAbsolutePath() + "/!nested.jar"; + assertThatNoException().isThrownBy(() -> Handler.assertUrlIsNotMalformed(url)); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/nested/NestedLocationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/nested/NestedLocationTests.java new file mode 100644 index 0000000000..0ec9c1f66e --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/nested/NestedLocationTests.java @@ -0,0 +1,98 @@ +/* + * 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.net.protocol.nested; + +import java.io.File; +import java.net.URL; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.net.protocol.Handlers; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link NestedLocation}. + * + * @author Phillip Webb + */ +class NestedLocationTests { + + @TempDir + File temp; + + @BeforeAll + static void registerHandlers() { + Handlers.register(); + } + + @Test + void createWhenFileIsNullThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> new NestedLocation(null, "nested.jar")) + .withMessageContaining("'file' must not be null"); + } + + @Test + void createWhenNestedEntryNameIsNullThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> new NestedLocation(new File("test.jar"), null)) + .withMessageContaining("'nestedEntryName' must not be empty"); + } + + @Test + void createWhenNestedEntryNameIsEmptyThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> new NestedLocation(new File("test.jar"), null)) + .withMessageContaining("'nestedEntryName' must not be empty"); + } + + @Test + void fromUrlWhenUrlIsNullThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> NestedLocation.fromUrl(null)) + .withMessageContaining("'url' must not be null"); + } + + @Test + void fromUrlWhenNotNestedProtocolThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> NestedLocation.fromUrl(new URL("file://test.jar"))) + .withMessageContaining("must use 'nested' protocol"); + } + + @Test + void fromUrlWhenNoPathThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> NestedLocation.fromUrl(new URL("nested:"))) + .withMessageContaining("'path' must not be empty"); + } + + @Test + void fromUrlWhenNoSeparatorThrowsExceptiuon() { + assertThatIllegalArgumentException() + .isThrownBy(() -> NestedLocation.fromUrl(new URL("nested:test.jar!nested.jar"))) + .withMessageContaining("'path' must contain '/!'"); + } + + @Test + void fromUrlReturnsNestedLocation() throws Exception { + File file = new File(this.temp, "test.jar"); + NestedLocation location = NestedLocation + .fromUrl(new URL("nested:" + file.getAbsolutePath() + "/!lib/nested.jar")); + assertThat(location.file()).isEqualTo(file); + assertThat(location.nestedEntryName()).isEqualTo("lib/nested.jar"); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/nested/NestedUrlConnectionTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/nested/NestedUrlConnectionTests.java new file mode 100644 index 0000000000..7efcf2b257 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/protocol/nested/NestedUrlConnectionTests.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.net.protocol.nested; + +import java.io.File; +import java.io.FilePermission; +import java.io.InputStream; +import java.lang.ref.Cleaner.Cleanable; +import java.net.MalformedURLException; +import java.net.URL; +import java.security.Permission; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; + +import org.springframework.boot.loader.net.protocol.Handlers; +import org.springframework.boot.loader.ref.Cleaner; +import org.springframework.boot.loader.testsupport.TestJar; +import org.springframework.boot.loader.zip.AssertFileChannelDataBlocksClosed; +import org.springframework.boot.loader.zip.ZipContent; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link NestedUrlConnection}. + * + * @author Phillip Webb + */ +@AssertFileChannelDataBlocksClosed +class NestedUrlConnectionTests { + + @TempDir + File temp; + + private File jarFile; + + private URL url; + + @BeforeAll + static void registerHandlers() { + Handlers.register(); + } + + @BeforeEach + void setup() throws Exception { + this.jarFile = new File(this.temp, "test.jar"); + TestJar.create(this.jarFile); + this.url = new URL("nested:" + this.jarFile.getAbsolutePath() + "/!nested.jar"); + } + + @Test + void createWhenMalformedUrlThrowsException() throws Exception { + URL url = new URL("nested:bad.jar"); + assertThatExceptionOfType(MalformedURLException.class).isThrownBy(() -> new NestedUrlConnection(url)) + .withMessage("'path' must contain '/!'"); + } + + @Test + void getContentLengthWhenContentLengthMoreThanMaxIntReturnsMinusOne() { + NestedUrlConnection connection = mock(NestedUrlConnection.class); + given(connection.getContentLength()).willCallRealMethod(); + given(connection.getContentLengthLong()).willReturn((long) Integer.MAX_VALUE + 1); + assertThat(connection.getContentLength()).isEqualTo(-1); + } + + @Test + void getContentLengthGetsContentLength() throws Exception { + NestedUrlConnection connection = new NestedUrlConnection(this.url); + try (ZipContent zipContent = ZipContent.open(this.jarFile.toPath())) { + int expectedSize = zipContent.getEntry("nested.jar").getUncompressedSize(); + assertThat(connection.getContentLength()).isEqualTo(expectedSize); + } + } + + @Test + void getContentLengthLongReturnsContentLength() throws Exception { + NestedUrlConnection connection = new NestedUrlConnection(this.url); + try (ZipContent zipContent = ZipContent.open(this.jarFile.toPath())) { + int expectedSize = zipContent.getEntry("nested.jar").getUncompressedSize(); + assertThat(connection.getContentLengthLong()).isEqualTo(expectedSize); + } + } + + @Test + void getContentTypeReturnsJavaJar() throws Exception { + NestedUrlConnection connection = new NestedUrlConnection(this.url); + assertThat(connection.getContentType()).isEqualTo("x-java/jar"); + } + + @Test + void getLastModifiedReturnsFileLastModified() throws Exception { + NestedUrlConnection connection = new NestedUrlConnection(this.url); + assertThat(connection.getLastModified()).isEqualTo(this.jarFile.lastModified()); + } + + @Test + void getPermissionReturnsFilePermission() throws Exception { + NestedUrlConnection connection = new NestedUrlConnection(this.url); + Permission permission = connection.getPermission(); + assertThat(permission).isInstanceOf(FilePermission.class); + assertThat(permission.getName()).isEqualTo(this.jarFile.getCanonicalPath()); + } + + @Test + void getInputStreamReturnsContentOfNestedJar() throws Exception { + NestedUrlConnection connection = new NestedUrlConnection(this.url); + try (InputStream actual = connection.getInputStream()) { + try (ZipContent zipContent = ZipContent.open(this.jarFile.toPath())) { + try (InputStream expected = zipContent.getEntry("nested.jar").openContent().asInputStream()) { + assertThat(actual).hasSameContentAs(expected); + } + } + } + } + + @Test + void inputStreamCloseCleansResource() throws Exception { + Cleaner cleaner = mock(Cleaner.class); + Cleanable cleanable = mock(Cleanable.class); + given(cleaner.register(any(), any())).willReturn(cleanable); + NestedUrlConnection connection = new NestedUrlConnection(this.url, cleaner); + connection.getInputStream().close(); + then(cleanable).should().clean(); + ArgumentCaptor actionCaptor = ArgumentCaptor.forClass(Runnable.class); + then(cleaner).should().register(any(), actionCaptor.capture()); + actionCaptor.getValue().run(); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/util/UrlDecoderTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/util/UrlDecoderTests.java new file mode 100644 index 0000000000..84708a0ba5 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/net/util/UrlDecoderTests.java @@ -0,0 +1,50 @@ +/* + * 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.net.util; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link UrlDecoder}. + * + * @author Phillip Webb + */ +class UrlDecoderTests { + + @Test + void decodeWhenBasicString() { + assertThat(UrlDecoder.decode("a/b/C.class")).isEqualTo("a/b/C.class"); + } + + @Test + void decodeWhenHasSingleByteEncodedCharacters() { + assertThat(UrlDecoder.decode("%61/%62/%43.class")).isEqualTo("a/b/C.class"); + } + + @Test + void decodeWhenHasDoubleByteEncodedCharacters() { + assertThat(UrlDecoder.decode("%c3%a1/b/C.class")).isEqualTo("\u00e1/b/C.class"); + } + + @Test + void decodeWhenHasMixtureOfEncodedAndUnencodedDoubleByteCharacters() { + assertThat(UrlDecoder.decode("%c3%a1/b/\u00c7.class")).isEqualTo("\u00e1/b/\u00c7.class"); + } + +} 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/test/java/org/springframework/boot/loader/ref/DefaultCleanerTracking.java similarity index 62% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/jar/CentralDirectoryVisitor.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/ref/DefaultCleanerTracking.java index 22e04b329c..8049acd7d0 100644 --- 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/test/java/org/springframework/boot/loader/ref/DefaultCleanerTracking.java @@ -14,21 +14,23 @@ * limitations under the License. */ -package org.springframework.boot.loader.jar; +package org.springframework.boot.loader.ref; -import org.springframework.boot.loader.data.RandomAccessData; +import java.lang.ref.Cleaner.Cleanable; +import java.util.function.Consumer; /** - * Callback visitor triggered by {@link CentralDirectoryParser}. + * Utility that allows tests to set a tracker on {@link DefaultCleaner}. * * @author Phillip Webb */ -interface CentralDirectoryVisitor { +public final class DefaultCleanerTracking { - void visitStart(CentralDirectoryEndRecord endRecord, RandomAccessData centralDirectoryData); + private DefaultCleanerTracking() { + } - void visitFileHeader(CentralDirectoryFileHeader fileHeader, long dataOffset); - - void visitEnd(); + public static void set(Consumer tracker) { + DefaultCleaner.tracker = tracker; + } } 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/testsupport/TestJar.java similarity index 83% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/TestJarCreator.java rename to spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/testsupport/TestJar.java index c5c5fd3b95..292fd44b66 100644 --- 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/testsupport/TestJar.java @@ -14,12 +14,13 @@ * limitations under the License. */ -package org.springframework.boot.loader; +package org.springframework.boot.loader.testsupport; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; +import java.util.List; import java.util.jar.Attributes; import java.util.jar.JarEntry; import java.util.jar.JarOutputStream; @@ -28,11 +29,11 @@ import java.util.zip.CRC32; import java.util.zip.ZipEntry; /** - * Creates a simple test jar. + * Support class to create or get test jars. * * @author Phillip Webb */ -public abstract class TestJarCreator { +public abstract class TestJar { private static final int BASE_VERSION = 8; @@ -50,15 +51,22 @@ public abstract class TestJarCreator { RUNTIME_VERSION = version; } - public static void createTestJar(File file) throws Exception { - createTestJar(file, false); + public static void create(File file) throws Exception { + create(file, false); } - public static void createTestJar(File file, boolean unpackNested) throws Exception { + public static void create(File file, boolean unpackNested) throws Exception { + create(file, unpackNested, false); + } + + public static void create(File file, boolean unpackNested, boolean addSignatureFile) throws Exception { FileOutputStream fileOutputStream = new FileOutputStream(file); try (JarOutputStream jarOutputStream = new JarOutputStream(fileOutputStream)) { jarOutputStream.setComment("outer"); writeManifest(jarOutputStream, "j1"); + if (addSignatureFile) { + writeEntry(jarOutputStream, "META-INF/some.DSA", 0); + } writeEntry(jarOutputStream, "1.dat", 1); writeEntry(jarOutputStream, "2.dat", 2); writeDirEntry(jarOutputStream, "d/"); @@ -72,6 +80,11 @@ public abstract class TestJarCreator { } } + public static List expectedEntries() { + return List.of("META-INF/", "META-INF/MANIFEST.MF", "1.dat", "2.dat", "d/", "d/9.dat", "special/", + "special/\u00EB.dat", "nested.jar", "another-nested.jar", "space nested.jar", "multi-release.jar"); + } + private static void writeNestedEntry(String name, boolean unpackNested, JarOutputStream jarOutputStream) throws Exception { writeNestedEntry(name, unpackNested, jarOutputStream, false); @@ -148,4 +161,14 @@ public abstract class TestJarCreator { jarOutputStream.closeEntry(); } + public static File getSigned() { + 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; + } + } 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 deleted file mode 100644 index 802a762e79..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/util/SystemPropertyUtilsTests.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * 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/java/org/springframework/boot/loader/zip/AssertFileChannelDataBlocksClosed.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/AssertFileChannelDataBlocksClosed.java new file mode 100644 index 0000000000..75c208e585 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/AssertFileChannelDataBlocksClosed.java @@ -0,0 +1,39 @@ +/* + * 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.zip; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * Annotation that can be added to tests to assert that {@link FileChannelDataBlock} files + * are not left open. + * + * @author Phillip Webb + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@ExtendWith(AssertFileChannelDataBlocksClosedExtension.class) +public @interface AssertFileChannelDataBlocksClosed { + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/AssertFileChannelDataBlocksClosedExtension.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/AssertFileChannelDataBlocksClosedExtension.java new file mode 100644 index 0000000000..caa8d23e21 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/AssertFileChannelDataBlocksClosedExtension.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.zip; + +import java.lang.ref.Cleaner.Cleanable; +import java.nio.channels.FileChannel; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; + +import org.springframework.boot.loader.ref.DefaultCleanerTracking; +import org.springframework.boot.loader.zip.FileChannelDataBlock.Tracker; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Extension for {@link AssertFileChannelDataBlocksClosed @TrackFileChannelDataBlock}. + */ +class AssertFileChannelDataBlocksClosedExtension implements BeforeEachCallback, AfterEachCallback { + + private static OpenFilesTracker tracker = new OpenFilesTracker(); + + @Override + public void beforeEach(ExtensionContext context) throws Exception { + tracker.clear(); + FileChannelDataBlock.tracker = tracker; + DefaultCleanerTracking.set(tracker::addedCleanable); + } + + @Override + public void afterEach(ExtensionContext context) throws Exception { + tracker.assertAllClosed(); + FileChannelDataBlock.tracker = null; + } + + private static class OpenFilesTracker implements Tracker { + + private final Set paths = new LinkedHashSet<>(); + + private final List cleanup = new ArrayList<>(); + + @Override + public void openedFileChannel(Path path, FileChannel fileChannel) { + this.paths.add(path); + } + + @Override + public void closedFileChannel(Path path, FileChannel fileChannel) { + this.paths.remove(path); + } + + void clear() { + this.paths.clear(); + this.cleanup.clear(); + } + + void assertAllClosed() { + this.cleanup.forEach(Cleanable::clean); + assertThat(this.paths).as("open paths").isEmpty(); + } + + private void addedCleanable(Cleanable cleanable) { + this.cleanup.add(cleanable); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ByteArrayDataBlockTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ByteArrayDataBlockTests.java new file mode 100644 index 0000000000..7c78ec4276 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ByteArrayDataBlockTests.java @@ -0,0 +1,76 @@ +/* + * 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.zip; + +import java.nio.ByteBuffer; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ByteArrayDataBlock}. + * + * @author Phillip Webb + */ +class ByteArrayDataBlockTests { + + private final byte[] BYTES = { 0, 1, 2, 3, 4, 5, 6, 7 }; + + @Test + void sizeReturnsByteArrayLength() throws Exception { + ByteArrayDataBlock dataBlock = new ByteArrayDataBlock(this.BYTES); + assertThat(dataBlock.size()).isEqualTo(this.BYTES.length); + } + + @Test + void readPutsBytes() throws Exception { + ByteArrayDataBlock dataBlock = new ByteArrayDataBlock(this.BYTES); + ByteBuffer dst = ByteBuffer.allocate(8); + int result = dataBlock.read(dst, 0); + assertThat(result).isEqualTo(8); + assertThat(dst.array()).containsExactly(this.BYTES); + } + + @Test + void readWhenLessBytesThanRemainingInBufferPutsBytes() throws Exception { + ByteArrayDataBlock dataBlock = new ByteArrayDataBlock(this.BYTES); + ByteBuffer dst = ByteBuffer.allocate(9); + int result = dataBlock.read(dst, 0); + assertThat(result).isEqualTo(8); + assertThat(dst.array()).containsExactly(0, 1, 2, 3, 4, 5, 6, 7, 0); + } + + @Test + void readWhenLessRemainingInBufferThanLengthPutsBytes() throws Exception { + ByteArrayDataBlock dataBlock = new ByteArrayDataBlock(this.BYTES); + ByteBuffer dst = ByteBuffer.allocate(7); + int result = dataBlock.read(dst, 0); + assertThat(result).isEqualTo(7); + assertThat(dst.array()).containsExactly(0, 1, 2, 3, 4, 5, 6); + } + + @Test + void readWhenHasPosOffsetReadsBytes() throws Exception { + ByteArrayDataBlock dataBlock = new ByteArrayDataBlock(this.BYTES); + ByteBuffer dst = ByteBuffer.allocate(3); + int result = dataBlock.read(dst, 4); + assertThat(result).isEqualTo(3); + assertThat(dst.array()).containsExactly(4, 5, 6); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/DataBlockTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/DataBlockTests.java new file mode 100644 index 0000000000..f238059a34 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/DataBlockTests.java @@ -0,0 +1,76 @@ +/* + * 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.zip; + +import java.io.EOFException; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; +import org.mockito.stubbing.Answer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.withSettings; + +/** + * Tests for {@link DataBlock}. + * + * @author Phillip Webb + */ +class DataBlockTests { + + @Test + void readFullyReadsAllBytesByCallingReadMultipleTimes() throws IOException { + DataBlock dataBlock = mock(DataBlock.class, withSettings().defaultAnswer(CALLS_REAL_METHODS)); + given(dataBlock.read(any(), anyLong())) + .will(putBytes(new byte[] { 0, 1 }, new byte[] { 2 }, new byte[] { 3, 4, 5 })); + ByteBuffer dst = ByteBuffer.allocate(6); + dataBlock.readFully(dst, 0); + assertThat(dst.array()).containsExactly(0, 1, 2, 3, 4, 5); + } + + private Answer putBytes(byte[]... bytes) { + AtomicInteger count = new AtomicInteger(); + return (invocation) -> { + int index = count.getAndIncrement(); + invocation.getArgument(0, ByteBuffer.class).put(bytes[index]); + return bytes.length; + }; + } + + @Test + void readFullyWhenReadReturnsNegativeResultThrowsException() throws Exception { + DataBlock dataBlock = mock(DataBlock.class, withSettings().defaultAnswer(CALLS_REAL_METHODS)); + given(dataBlock.read(any(), anyLong())).willReturn(-1); + ByteBuffer dst = ByteBuffer.allocate(8); + assertThatExceptionOfType(EOFException.class).isThrownBy(() -> dataBlock.readFully(dst, 0)); + } + + @Test + void asInputStreamReturnsDataBlockInputStream() { + DataBlock dataBlock = mock(DataBlock.class, withSettings().defaultAnswer(CALLS_REAL_METHODS)); + assertThat(dataBlock.asInputStream()).isInstanceOf(DataBlockInputStream.class); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/FileChannelDataBlockTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/FileChannelDataBlockTests.java new file mode 100644 index 0000000000..9beb4aa314 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/FileChannelDataBlockTests.java @@ -0,0 +1,238 @@ +/* + * 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.zip; + +import java.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; + +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.zip.FileChannelDataBlock.Tracker; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link FileChannelDataBlock}. + * + * @author Phillip Webb + */ +class FileChannelDataBlockTests { + + private static final byte[] CONTENT = new byte[] { 0x00, 0x01, 0x02, 0x03, 0x04, 0x05 }; + + @TempDir + File tempDir; + + File tempFile; + + @BeforeEach + void writeTempFile() throws IOException { + this.tempFile = new File(this.tempDir, "content"); + Files.write(this.tempFile.toPath(), CONTENT); + } + + @AfterEach + void resetTracker() { + FileChannelDataBlock.tracker = null; + } + + @Test + void sizeReturnsFileSize() throws IOException { + try (FileChannelDataBlock block = createAndOpenBlock()) { + assertThat(block.size()).isEqualTo(CONTENT.length); + } + } + + @Test + void readReadsFile() throws IOException { + try (FileChannelDataBlock block = createAndOpenBlock()) { + ByteBuffer buffer = ByteBuffer.allocate(CONTENT.length); + assertThat(block.read(buffer, 0)).isEqualTo(6); + assertThat(buffer.array()).containsExactly(CONTENT); + } + } + + @Test + void readDoesNotReadPastEndOfFile() throws IOException { + try (FileChannelDataBlock block = createAndOpenBlock()) { + ByteBuffer buffer = ByteBuffer.allocate(CONTENT.length); + assertThat(block.read(buffer, 2)).isEqualTo(4); + assertThat(buffer.array()).containsExactly(0x02, 0x03, 0x04, 0x05, 0x0, 0x0); + } + } + + @Test + void readWhenPosAtSizeReturnsMinusOne() throws IOException { + try (FileChannelDataBlock block = createAndOpenBlock()) { + ByteBuffer buffer = ByteBuffer.allocate(CONTENT.length); + assertThat(block.read(buffer, 6)).isEqualTo(-1); + } + } + + @Test + void readWhenPosOverSizeReturnsMinusOne() throws IOException { + try (FileChannelDataBlock block = createAndOpenBlock()) { + ByteBuffer buffer = ByteBuffer.allocate(CONTENT.length); + assertThat(block.read(buffer, 7)).isEqualTo(-1); + } + } + + @Test + void readWhenPosIsNegativeThrowsException() throws IOException { + try (FileChannelDataBlock block = createAndOpenBlock()) { + ByteBuffer buffer = ByteBuffer.allocate(CONTENT.length); + assertThatIllegalArgumentException().isThrownBy(() -> block.read(buffer, -1)); + } + } + + @Test + void sliceWhenOffsetIsNegativeThrowsException() throws IOException { + try (FileChannelDataBlock block = createAndOpenBlock()) { + assertThatIllegalArgumentException().isThrownBy(() -> block.slice(-1, 0)) + .withMessage("Offset must not be negative"); + } + } + + @Test + void sliceWhenSizeIsNegativeThrowsException() throws IOException { + try (FileChannelDataBlock block = createAndOpenBlock()) { + assertThatIllegalArgumentException().isThrownBy(() -> block.slice(0, -1)) + .withMessage("Size must not be negative and must be within bounds"); + } + } + + @Test + void sliceWhenSizeIsOutOfBoundsThrowsException() throws IOException { + try (FileChannelDataBlock block = createAndOpenBlock()) { + assertThatIllegalArgumentException().isThrownBy(() -> block.slice(2, 5)) + .withMessage("Size must not be negative and must be within bounds"); + } + } + + @Test + void sliceReturnsSlice() throws IOException { + try (FileChannelDataBlock slice = createAndOpenBlock().slice(1, 4)) { + assertThat(slice.size()).isEqualTo(4); + ByteBuffer buffer = ByteBuffer.allocate(4); + assertThat(slice.read(buffer, 0)).isEqualTo(4); + assertThat(buffer.array()).containsExactly(0x01, 0x02, 0x03, 0x04); + } + } + + @Test + void openAndCloseHandleReferenceCounting() throws IOException { + TestTracker tracker = new TestTracker(); + FileChannelDataBlock.tracker = tracker; + FileChannelDataBlock block = createBlock(); + assertThat(block).extracting("channel.referenceCount").isEqualTo(0); + tracker.assertOpenCloseCounts(0, 0); + block.open(); + assertThat(block).extracting("channel.referenceCount").isEqualTo(1); + tracker.assertOpenCloseCounts(1, 0); + block.open(); + assertThat(block).extracting("channel.referenceCount").isEqualTo(2); + tracker.assertOpenCloseCounts(1, 0); + block.close(); + assertThat(block).extracting("channel.referenceCount").isEqualTo(1); + tracker.assertOpenCloseCounts(1, 0); + block.close(); + assertThat(block).extracting("channel.referenceCount").isEqualTo(0); + tracker.assertOpenCloseCounts(1, 1); + block.open(); + assertThat(block).extracting("channel.referenceCount").isEqualTo(1); + tracker.assertOpenCloseCounts(2, 1); + block.close(); + assertThat(block).extracting("channel.referenceCount").isEqualTo(0); + tracker.assertOpenCloseCounts(2, 2); + } + + @Test + void openAndCloseSliceHandleReferenceCounting() throws IOException { + TestTracker tracker = new TestTracker(); + FileChannelDataBlock.tracker = tracker; + FileChannelDataBlock block = createBlock(); + FileChannelDataBlock slice = block.slice(1, 4); + assertThat(block).extracting("channel.referenceCount").isEqualTo(0); + tracker.assertOpenCloseCounts(0, 0); + block.open(); + assertThat(block).extracting("channel.referenceCount").isEqualTo(1); + tracker.assertOpenCloseCounts(1, 0); + slice.open(); + assertThat(slice).extracting("channel.referenceCount").isEqualTo(2); + tracker.assertOpenCloseCounts(1, 0); + slice.open(); + assertThat(slice).extracting("channel.referenceCount").isEqualTo(3); + tracker.assertOpenCloseCounts(1, 0); + slice.close(); + assertThat(slice).extracting("channel.referenceCount").isEqualTo(2); + tracker.assertOpenCloseCounts(1, 0); + slice.close(); + assertThat(slice).extracting("channel.referenceCount").isEqualTo(1); + tracker.assertOpenCloseCounts(1, 0); + block.close(); + assertThat(block).extracting("channel.referenceCount").isEqualTo(0); + tracker.assertOpenCloseCounts(1, 1); + slice.open(); + assertThat(slice).extracting("channel.referenceCount").isEqualTo(1); + tracker.assertOpenCloseCounts(2, 1); + slice.close(); + assertThat(slice).extracting("channel.referenceCount").isEqualTo(0); + tracker.assertOpenCloseCounts(2, 2); + } + + private FileChannelDataBlock createAndOpenBlock() throws IOException { + FileChannelDataBlock block = createBlock(); + block.open(); + return block; + } + + private FileChannelDataBlock createBlock() throws IOException { + return new FileChannelDataBlock(this.tempFile.toPath()); + } + + static class TestTracker implements Tracker { + + private int openCount; + + private int closeCount; + + @Override + public void openedFileChannel(Path path, FileChannel fileChannel) { + this.openCount++; + } + + @Override + public void closedFileChannel(Path path, FileChannel fileChannel) { + this.closeCount++; + } + + void assertOpenCloseCounts(int expectedOpenCount, int expectedCloseCount) { + assertThat(this.openCount).as("openCount").isEqualTo(expectedOpenCount); + assertThat(this.closeCount).as("closeCount").isEqualTo(expectedCloseCount); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/VirtualDataBlockTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/VirtualDataBlockTests.java new file mode 100644 index 0000000000..c2b8c83383 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/VirtualDataBlockTests.java @@ -0,0 +1,82 @@ +/* + * 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.zip; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link VirtualDataBlock}. + * + * @author Phillip Webb + */ +class VirtualDataBlockTests { + + private VirtualDataBlock virtualDataBlock; + + @BeforeEach + void setup() throws IOException { + List subsections = new ArrayList<>(); + subsections.add(new ByteArrayDataBlock("abc".getBytes(StandardCharsets.UTF_8))); + subsections.add(new ByteArrayDataBlock("defg".getBytes(StandardCharsets.UTF_8))); + subsections.add(new ByteArrayDataBlock("h".getBytes(StandardCharsets.UTF_8))); + this.virtualDataBlock = new VirtualDataBlock(subsections); + } + + @Test + void sizeReturnsSize() throws IOException { + assertThat(this.virtualDataBlock.size()).isEqualTo(8); + } + + @Test + void readFullyReadsAllBlocks() throws IOException { + ByteBuffer dst = ByteBuffer.allocate((int) this.virtualDataBlock.size()); + this.virtualDataBlock.readFully(dst, 0); + assertThat(dst.array()).containsExactly("abcdefgh".getBytes(StandardCharsets.UTF_8)); + } + + @Test + void readWithShortBlock() throws IOException { + ByteBuffer dst = ByteBuffer.allocate(2); + assertThat(this.virtualDataBlock.read(dst, 1)).isEqualTo(2); + assertThat(dst.array()).containsExactly("bc".getBytes(StandardCharsets.UTF_8)); + } + + @Test + void readWithShortBlockAcrossSubsections() throws IOException { + ByteBuffer dst = ByteBuffer.allocate(3); + assertThat(this.virtualDataBlock.read(dst, 2)).isEqualTo(3); + assertThat(dst.array()).containsExactly("cde".getBytes(StandardCharsets.UTF_8)); + } + + @Test + void readWithBigBlock() throws IOException { + ByteBuffer dst = ByteBuffer.allocate(16); + assertThat(this.virtualDataBlock.read(dst, 1)).isEqualTo(7); + assertThat(dst.array()).startsWith("bcdefgh".getBytes(StandardCharsets.UTF_8)); + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/VirtualZipDataBlockTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/VirtualZipDataBlockTests.java new file mode 100644 index 0000000000..42ea979673 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/VirtualZipDataBlockTests.java @@ -0,0 +1,98 @@ +/* + * 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.zip; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.boot.loader.testsupport.TestJar; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Tests for {@link VirtualZipDataBlock}. + * + * @author Phillip Webb + */ +@AssertFileChannelDataBlocksClosed +class VirtualZipDataBlockTests { + + @TempDir + File tempDir; + + private File file; + + @BeforeEach + void setup() throws Exception { + this.file = new File(this.tempDir, "test.jar"); + TestJar.create(this.file); + } + + @Test + void createContainsValidZipContent() throws IOException { + FileChannelDataBlock data = new FileChannelDataBlock(this.file.toPath()); + data.open(); + List centralRecords = new ArrayList<>(); + List centralRecordPositions = new ArrayList<>(); + ZipEndOfCentralDirectoryRecord eocd = ZipEndOfCentralDirectoryRecord.load(data).endOfCentralDirectoryRecord(); + long pos = eocd.offsetToStartOfCentralDirectory(); + for (int i = 0; i < eocd.totalNumberOfCentralDirectoryEntries(); i++) { + ZipCentralDirectoryFileHeaderRecord centralRecord = ZipCentralDirectoryFileHeaderRecord.load(data, pos); + String name = ZipString.readString(data, pos + ZipCentralDirectoryFileHeaderRecord.FILE_NAME_OFFSET, + centralRecord.fileNameLength()); + if (name.endsWith(".jar")) { + centralRecords.add(centralRecord); + centralRecordPositions.add(pos); + } + pos += centralRecord.size(); + } + NameOffsetLookups nameOffsetLookups = new NameOffsetLookups(2, centralRecords.size()); + for (int i = 0; i < centralRecords.size(); i++) { + nameOffsetLookups.enable(i, true); + } + nameOffsetLookups.enable(0, true); + File outputFile = new File(this.tempDir, "out.jar"); + try (VirtualZipDataBlock block = new VirtualZipDataBlock(data, nameOffsetLookups, + centralRecords.toArray(ZipCentralDirectoryFileHeaderRecord[]::new), + centralRecordPositions.stream().mapToLong(Long::longValue).toArray())) { + try (FileOutputStream out = new FileOutputStream(outputFile)) { + block.asInputStream().transferTo(out); + } + } + try (FileSystem fileSystem = FileSystems.newFileSystem(outputFile.toPath())) { + assertThatExceptionOfType(NoSuchFileException.class) + .isThrownBy(() -> Files.size(fileSystem.getPath("nessted.jar"))); + assertThat(Files.size(fileSystem.getPath("sted.jar"))).isGreaterThan(0); + assertThat(Files.size(fileSystem.getPath("other-nested.jar"))).isGreaterThan(0); + assertThat(Files.size(fileSystem.getPath("ace nested.jar"))).isGreaterThan(0); + assertThat(Files.size(fileSystem.getPath("lti-release.jar"))).isGreaterThan(0); + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryLocatorTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryLocatorTests.java new file mode 100644 index 0000000000..78b5a00498 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryLocatorTests.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.zip; + +import java.io.IOException; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link Zip64EndOfCentralDirectoryLocator}. + * + * @author Phillip Webb + */ +class Zip64EndOfCentralDirectoryLocatorTests { + + @Test + void findReturnsRecord() throws Exception { + DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { // + 0x50, 0x4b, 0x06, 0x07, // + 0x01, 0x00, 0x00, 0x00, // + 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x03, 0x00, 0x00, 0x00 }); // + Zip64EndOfCentralDirectoryLocator eocd = Zip64EndOfCentralDirectoryLocator.find(dataBlock, 20); + assertThat(eocd.pos()).isEqualTo(0); + assertThat(eocd.numberOfThisDisk()).isEqualTo(1); + assertThat(eocd.offsetToZip64EndOfCentralDirectoryRecord()).isEqualTo(2); + assertThat(eocd.totalNumberOfDisks()).isEqualTo(3); + } + + @Test + void findWhenSignatureDoesNotMatchReturnsNull() throws IOException { + DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { // + 0x51, 0x4b, 0x06, 0x07, // + 0x01, 0x00, 0x00, 0x00, // + 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x03, 0x00, 0x00, 0x00 }); // + Zip64EndOfCentralDirectoryLocator eocd = Zip64EndOfCentralDirectoryLocator.find(dataBlock, 20); + assertThat(eocd).isNull(); + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryRecordTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryRecordTests.java new file mode 100644 index 0000000000..486d34970d --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/Zip64EndOfCentralDirectoryRecordTests.java @@ -0,0 +1,76 @@ +/* + * 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.zip; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIOException; + +/** + * Tests for {@link Zip64EndOfCentralDirectoryRecord}. + * + * @author Phillip Webb + */ +class Zip64EndOfCentralDirectoryRecordTests { + + @Test + void loadLoadsData() throws Exception { + DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { // + 0x50, 0x4b, 0x06, 0x06, // + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x02, 0x00, // + 0x03, 0x00, // + 0x04, 0x00, 0x00, 0x00, // + 0x05, 0x00, 0x00, 0x00, // + 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }); // + Zip64EndOfCentralDirectoryLocator locator = new Zip64EndOfCentralDirectoryLocator(56, 0, 0, 0); + Zip64EndOfCentralDirectoryRecord eocd = Zip64EndOfCentralDirectoryRecord.load(dataBlock, locator); + assertThat(eocd.size()).isEqualTo(56); + assertThat(eocd.sizeOfZip64EndOfCentralDirectoryRecord()).isEqualTo(1); + assertThat(eocd.versionMadeBy()).isEqualTo((short) 2); + assertThat(eocd.versionNeededToExtract()).isEqualTo((short) 3); + assertThat(eocd.numberOfThisDisk()).isEqualTo(4); + assertThat(eocd.diskWhereCentralDirectoryStarts()).isEqualTo(5); + assertThat(eocd.numberOfCentralDirectoryEntriesOnThisDisk()).isEqualTo(6); + assertThat(eocd.totalNumberOfCentralDirectoryEntries()).isEqualTo(7); + assertThat(eocd.sizeOfCentralDirectory()).isEqualTo(8); + assertThat(eocd.offsetToStartOfCentralDirectory()); + } + + @Test + void loadWhenSignatureDoesNotMatchThrowsException() { + DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { // + 0x51, 0x4b, 0x06, 0x06, // + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x02, 0x00, // + 0x03, 0x00, // + 0x04, 0x00, 0x00, 0x00, // + 0x05, 0x00, 0x00, 0x00, // + 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }); // + Zip64EndOfCentralDirectoryLocator locator = new Zip64EndOfCentralDirectoryLocator(56, 0, 0, 0); + assertThatIOException().isThrownBy(() -> Zip64EndOfCentralDirectoryRecord.load(dataBlock, locator)) + .withMessageContaining("Zip64 'End Of Central Directory Record' not found at position"); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipCentralDirectoryFileHeaderRecordTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipCentralDirectoryFileHeaderRecordTests.java new file mode 100644 index 0000000000..a0a8645e9c --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipCentralDirectoryFileHeaderRecordTests.java @@ -0,0 +1,213 @@ +/* + * 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.zip; + +import java.util.zip.ZipEntry; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIOException; + +/** + * Tests for {@link ZipCentralDirectoryFileHeaderRecord}. + * + * @author Phillip Webb + */ +class ZipCentralDirectoryFileHeaderRecordTests { + + @Test + void loadLoadsData() throws Exception { + DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { // + 0x50, 0x4b, 0x01, 0x02, // + 0x01, 0x00, // + 0x02, 0x00, // + 0x03, 0x00, // + 0x04, 0x00, // + 0x05, 0x00, // + 0x06, 0x00, // + 0x07, 0x00, 0x00, 0x00, // + 0x08, 0x00, 0x00, 0x00, // + 0x09, 0x00, 0x00, 0x00, // + 0x0A, 0x00, // + 0x0B, 0x00, // + 0x0C, 0x00, // + 0x0D, 0x00, // + 0x0E, 0x00, // + 0x0F, 0x00, 0x00, 0x00, // + 0x10, 0x00, 0x00, 0x00 }); // + ZipCentralDirectoryFileHeaderRecord record = ZipCentralDirectoryFileHeaderRecord.load(dataBlock, 0); + assertThat(record.versionMadeBy()).isEqualTo((short) 1); + assertThat(record.versionNeededToExtract()).isEqualTo((short) 2); + assertThat(record.generalPurposeBitFlag()).isEqualTo((short) 3); + assertThat(record.compressionMethod()).isEqualTo((short) 4); + assertThat(record.lastModFileTime()).isEqualTo((short) 5); + assertThat(record.lastModFileDate()).isEqualTo((short) 6); + assertThat(record.crc32()).isEqualTo(7); + assertThat(record.compressedSize()).isEqualTo(8); + assertThat(record.uncompressedSize()).isEqualTo(9); + assertThat(record.fileNameLength()).isEqualTo((short) 10); + assertThat(record.extraFieldLength()).isEqualTo((short) 11); + assertThat(record.fileCommentLength()).isEqualTo((short) 12); + assertThat(record.diskNumberStart()).isEqualTo((short) 13); + assertThat(record.internalFileAttributes()).isEqualTo((short) 14); + assertThat(record.externalFileAttributes()).isEqualTo(15); + assertThat(record.offsetToLocalHeader()).isEqualTo(16); + } + + @Test + void loadWhenSignatureDoesNotMatchThrowsException() { + DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { // + 0x51, 0x4b, 0x01, 0x02, // + 0x01, 0x00, // + 0x02, 0x00, // + 0x03, 0x00, // + 0x04, 0x00, // + 0x05, 0x00, // + 0x06, 0x00, // + 0x07, 0x00, 0x00, 0x00, // + 0x08, 0x00, 0x00, 0x00, // + 0x09, 0x00, 0x00, 0x00, // + 0x0A, 0x00, // + 0x0B, 0x00, // + 0x0C, 0x00, // + 0x0D, 0x00, // + 0x0E, 0x00, // + 0x0F, 0x00, 0x00, 0x00, // + 0x10, 0x00, 0x00, 0x00 }); // + assertThatIOException().isThrownBy(() -> ZipCentralDirectoryFileHeaderRecord.load(dataBlock, 0)) + .withMessageContaining("'Central Directory File Header Record' not found"); + } + + @Test + void sizeReturnsSize() { + ZipCentralDirectoryFileHeaderRecord record = new ZipCentralDirectoryFileHeaderRecord((short) 1, (short) 2, + (short) 3, (short) 4, (short) 5, (short) 6, 7, 8, 9, (short) 10, (short) 11, (short) 12, (short) 13, + (short) 14, 15, 16); + assertThat(record.size()).isEqualTo(79L); + } + + @Test + void copyToCopiesDataToZipEntry() throws Exception { + DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { // + 0x50, 0x4b, 0x01, 0x02, // + 0x00, 0x00, // + 0x00, 0x00, // + 0x00, 0x00, // + 0x08, 0x00, // + 0x23, 0x74, // + 0x58, 0x36, // + (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, // + 0x01, 0x00, 0x00, 0x00, // + 0x02, 0x00, 0x00, 0x00, // + 0x01, 0x00, // + 0x01, 0x00, // + 0x01, 0x00, // + 0x00, 0x00, // + 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, // + 0x61, // + 0x62, // + 0x63 }); // + ZipCentralDirectoryFileHeaderRecord record = ZipCentralDirectoryFileHeaderRecord.load(dataBlock, 0); + ZipEntry entry = new ZipEntry(""); + record.copyTo(dataBlock, 0, entry); + assertThat(entry.getMethod()).isEqualTo(ZipEntry.DEFLATED); + assertThat(entry.getTimeLocal()).hasYear(2007); + assertThat(entry.getTime()).isEqualTo(1172356386000L); + assertThat(entry.getCrc()).isEqualTo(0xFFFFFFFFL); + assertThat(entry.getCompressedSize()).isEqualTo(1); + assertThat(entry.getSize()).isEqualTo(2); + assertThat(entry.getExtra()).containsExactly(0x62); + assertThat(entry.getComment()).isEqualTo("c"); + } + + @Test + void withFileNameLengthReturnsUpdatedInstance() { + ZipCentralDirectoryFileHeaderRecord record = new ZipCentralDirectoryFileHeaderRecord((short) 1, (short) 2, + (short) 3, (short) 4, (short) 5, (short) 6, 7, 8, 9, (short) 10, (short) 11, (short) 12, (short) 13, + (short) 14, 15, 16) + .withFileNameLength((short) 100); + assertThat(record.versionMadeBy()).isEqualTo((short) 1); + assertThat(record.versionNeededToExtract()).isEqualTo((short) 2); + assertThat(record.generalPurposeBitFlag()).isEqualTo((short) 3); + assertThat(record.compressionMethod()).isEqualTo((short) 4); + assertThat(record.lastModFileTime()).isEqualTo((short) 5); + assertThat(record.lastModFileDate()).isEqualTo((short) 6); + assertThat(record.crc32()).isEqualTo(7); + assertThat(record.compressedSize()).isEqualTo(8); + assertThat(record.uncompressedSize()).isEqualTo(9); + assertThat(record.fileNameLength()).isEqualTo((short) 100); + assertThat(record.extraFieldLength()).isEqualTo((short) 11); + assertThat(record.fileCommentLength()).isEqualTo((short) 12); + assertThat(record.diskNumberStart()).isEqualTo((short) 13); + assertThat(record.internalFileAttributes()).isEqualTo((short) 14); + assertThat(record.externalFileAttributes()).isEqualTo(15); + assertThat(record.offsetToLocalHeader()).isEqualTo(16); + } + + @Test + void withOffsetToLocalHeaderReturnsUpdatedInstance() { + ZipCentralDirectoryFileHeaderRecord record = new ZipCentralDirectoryFileHeaderRecord((short) 1, (short) 2, + (short) 3, (short) 4, (short) 5, (short) 6, 7, 8, 9, (short) 10, (short) 11, (short) 12, (short) 13, + (short) 14, 15, 16) + .withOffsetToLocalHeader(100); + assertThat(record.versionMadeBy()).isEqualTo((short) 1); + assertThat(record.versionNeededToExtract()).isEqualTo((short) 2); + assertThat(record.generalPurposeBitFlag()).isEqualTo((short) 3); + assertThat(record.compressionMethod()).isEqualTo((short) 4); + assertThat(record.lastModFileTime()).isEqualTo((short) 5); + assertThat(record.lastModFileDate()).isEqualTo((short) 6); + assertThat(record.crc32()).isEqualTo(7); + assertThat(record.compressedSize()).isEqualTo(8); + assertThat(record.uncompressedSize()).isEqualTo(9); + assertThat(record.fileNameLength()).isEqualTo((short) 10); + assertThat(record.extraFieldLength()).isEqualTo((short) 11); + assertThat(record.fileCommentLength()).isEqualTo((short) 12); + assertThat(record.diskNumberStart()).isEqualTo((short) 13); + assertThat(record.internalFileAttributes()).isEqualTo((short) 14); + assertThat(record.externalFileAttributes()).isEqualTo(15); + assertThat(record.offsetToLocalHeader()).isEqualTo(100); + } + + @Test + void asByteArrayReturnsByteArray() throws Exception { + byte[] bytes = new byte[] { // + 0x50, 0x4b, 0x01, 0x02, // + 0x01, 0x00, // + 0x02, 0x00, // + 0x03, 0x00, // + 0x04, 0x00, // + 0x05, 0x00, // + 0x06, 0x00, // + 0x07, 0x00, 0x00, 0x00, // + 0x08, 0x00, 0x00, 0x00, // + 0x09, 0x00, 0x00, 0x00, // + 0x0A, 0x00, // + 0x0B, 0x00, // + 0x0C, 0x00, // + 0x0D, 0x00, // + 0x0E, 0x00, // + 0x0F, 0x00, 0x00, 0x00, // + 0x10, 0x00, 0x00, 0x00 }; + DataBlock dataBlock = new ByteArrayDataBlock(bytes); + ZipCentralDirectoryFileHeaderRecord record = ZipCentralDirectoryFileHeaderRecord.load(dataBlock, 0); + assertThat(record.asByteArray()).containsExactly(bytes); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipContentTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipContentTests.java new file mode 100644 index 0000000000..fe0e8bd542 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipContentTests.java @@ -0,0 +1,437 @@ +/* + * 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.zip; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +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.Iterator; +import java.util.Random; +import java.util.jar.Manifest; +import java.util.zip.CRC32; +import java.util.zip.Inflater; +import java.util.zip.InflaterInputStream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import java.util.zip.ZipOutputStream; + +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.io.TempDir; + +import org.springframework.boot.loader.testsupport.TestJar; +import org.springframework.boot.loader.zip.ZipContent.Entry; +import org.springframework.util.FileCopyUtils; +import org.springframework.util.StreamUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIOException; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Tests for {@link ZipContent}. + * + * @author Phillip Webb + * @author Martin Lau + * @author Andy Wilkinson + * @author Madhura Bhave + */ +class ZipContentTests { + + @TempDir + File tempDir; + + private File file; + + private ZipContent zipContent; + + @BeforeEach + void setup() throws Exception { + this.file = new File(this.tempDir, "test.jar"); + TestJar.create(this.file); + this.zipContent = ZipContent.open(this.file.toPath()); + } + + @AfterEach + void tearDown() throws Exception { + if (this.zipContent != null) { + try { + this.zipContent.close(); + } + catch (IllegalStateException ex) { + } + } + } + + @Test + void getCommentReturnsComment() { + assertThat(this.zipContent.getComment()).isEqualTo("outer"); + } + + @Test + void getCommentWhenClosedThrowsException() throws IOException { + this.zipContent.close(); + assertThatIllegalStateException().isThrownBy(() -> this.zipContent.getComment()) + .withMessage("Zip content closed"); + } + + @Test + void getEntryWhenPresentReturnsEntry() { + Entry entry = this.zipContent.getEntry("1.dat"); + assertThat(entry).isNotNull(); + assertThat(entry.getName()).isEqualTo("1.dat"); + } + + @Test + void getEntryWhenMissingReturnsNull() { + assertThat(this.zipContent.getEntry("missing.dat")).isNull(); + } + + @Test + void getEntryWithPrefixWhenPresentReturnsEntry() { + Entry entry = this.zipContent.getEntry("1", ".dat"); + assertThat(entry).isNotNull(); + assertThat(entry.getName()).isEqualTo("1.dat"); + } + + @Test + void getEntryWithLongPrefixWhenNameIsShorterReturnsNull() { + Entry entry = this.zipContent.getEntry("iamaverylongprefixandiwontfindanything", "1.dat"); + assertThat(entry).isNull(); + } + + @Test + void getEntryWithPrefixWhenMissingReturnsNull() { + assertThat(this.zipContent.getEntry("miss", "ing.dat")).isNull(); + } + + @Test + void getEntryWhenUsingSlashesIsCompatibleWithZipFile() throws IOException { + try (ZipFile zipFile = new ZipFile(this.file)) { + assertThat(zipFile.getEntry("META-INF").getName()).isEqualTo("META-INF/"); + assertThat(this.zipContent.getEntry("META-INF").getName()).isEqualTo("META-INF/"); + assertThat(zipFile.getEntry("META-INF/").getName()).isEqualTo("META-INF/"); + assertThat(this.zipContent.getEntry("META-INF/").getName()).isEqualTo("META-INF/"); + assertThat(zipFile.getEntry("d/9.dat").getName()).isEqualTo("d/9.dat"); + assertThat(this.zipContent.getEntry("d/9.dat").getName()).isEqualTo("d/9.dat"); + assertThat(zipFile.getEntry("d/9.dat/")).isNull(); + assertThat(this.zipContent.getEntry("d/9.dat/")).isNull(); + } + } + + @Test + void getManifestEntry() throws Exception { + Entry entry = this.zipContent.getEntry("META-INF/MANIFEST.MF"); + try (CloseableDataBlock dataBlock = entry.openContent()) { + Manifest manifest = new Manifest(asInflaterInputStream(dataBlock)); + assertThat(manifest.getMainAttributes().getValue("Built-By")).isEqualTo("j1"); + } + } + + @Test + void getEntryAsCreatesCompatibleEntries() throws IOException { + try (ZipFile zipFile = new ZipFile(this.file)) { + Iterator expected = zipFile.entries().asIterator(); + int i = 0; + while (expected.hasNext()) { + Entry actual = this.zipContent.getEntry(i++); + assertThatFieldsAreEqual(actual.as(ZipEntry::new), expected.next()); + } + } + } + + private void assertThatFieldsAreEqual(ZipEntry actual, ZipEntry expected) { + assertThat(actual.getName()).isEqualTo(expected.getName()); + assertThat(actual.getTime()).isEqualTo(expected.getTime()); + assertThat(actual.getLastModifiedTime()).isEqualTo(expected.getLastModifiedTime()); + assertThat(actual.getLastAccessTime()).isEqualTo(expected.getLastAccessTime()); + assertThat(actual.getCreationTime()).isEqualTo(expected.getCreationTime()); + assertThat(actual.getSize()).isEqualTo(expected.getSize()); + assertThat(actual.getCompressedSize()).isEqualTo(expected.getCompressedSize()); + assertThat(actual.getCrc()).isEqualTo(expected.getCrc()); + assertThat(actual.getMethod()).isEqualTo(expected.getMethod()); + assertThat(actual.getExtra()).isEqualTo(expected.getExtra()); + assertThat(actual.getComment()).isEqualTo(expected.getComment()); + } + + @Test + void sizeReturnsNumberOfEntries() { + assertThat(this.zipContent.size()).isEqualTo(12); + } + + @Test + void nestedJarFileReturnsNestedJar() throws IOException { + try (ZipContent nested = ZipContent.open(this.file.toPath(), "nested.jar")) { + assertThat(nested.size()).isEqualTo(5); + assertThat(nested.getComment()).isEqualTo("nested"); + assertThat(nested.size()).isEqualTo(5); + assertThat(nested.getEntry(0).getName()).isEqualTo("META-INF/"); + assertThat(nested.getEntry(1).getName()).isEqualTo("META-INF/MANIFEST.MF"); + assertThat(nested.getEntry(2).getName()).isEqualTo("3.dat"); + assertThat(nested.getEntry(3).getName()).isEqualTo("4.dat"); + assertThat(nested.getEntry(4).getName()).isEqualTo("\u00E4.dat"); + } + } + + @Test + void nestedJarFileWhenNameEndsInSlashThrowsException() { + assertThatIOException().isThrownBy(() -> ZipContent.open(this.file.toPath(), "nested.jar/")) + .withMessageStartingWith("Nested entry 'nested.jar/' not found in container zip"); + } + + @Test + void nestedDirectoryReturnsNestedJar() throws IOException { + try (ZipContent nested = ZipContent.open(this.file.toPath(), "d/")) { + assertThat(nested.size()).isEqualTo(3); + assertThat(nested.getEntry("9.dat")).isNotNull(); + assertThat(nested.getEntry(0).getName()).isEqualTo("META-INF/"); + assertThat(nested.getEntry(1).getName()).isEqualTo("META-INF/MANIFEST.MF"); + assertThat(nested.getEntry(2).getName()).isEqualTo("9.dat"); + } + } + + @Test + void nestedDirectoryWhenNotEndingInSlashThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> ZipContent.open(this.file.toPath(), "d")) + .withMessage("Nested entry name must end with '/'"); + } + + @Test + void getDataWhenNestedDirectoryReturnsVirtualZipDataBlock() throws IOException { + try (ZipContent nested = ZipContent.open(this.file.toPath(), "d/")) { + File file = new File(this.tempDir, "included.zip"); + write(file, nested.openRawZipData()); + try (ZipFile loadedZipFile = new ZipFile(file)) { + assertThat(loadedZipFile.size()).isEqualTo(3); + assertThat(loadedZipFile.stream().map(ZipEntry::getName)).containsExactly("META-INF/", + "META-INF/MANIFEST.MF", "9.dat"); + assertThat(loadedZipFile.getEntry("9.dat")).isNotNull(); + try (InputStream in = loadedZipFile.getInputStream(loadedZipFile.getEntry("9.dat"))) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + in.transferTo(out); + assertThat(out.toByteArray()).containsExactly(0x09); + } + } + } + } + + @Test + void loadWhenHasFrontMatterOpensZip() throws IOException { + File fileWithFrontMatter = new File(this.tempDir, "withfrontmatter.jar"); + FileOutputStream outputStream = new FileOutputStream(fileWithFrontMatter); + StreamUtils.copy("#/bin/bash", Charset.defaultCharset(), outputStream); + FileCopyUtils.copy(new FileInputStream(this.file), outputStream); + try (ZipContent zip = ZipContent.open(fileWithFrontMatter.toPath())) { + assertThat(zip.size()).isEqualTo(12); + assertThat(zip.getEntry(0).getName()).isEqualTo("META-INF/"); + assertThat(zip.getEntry(1).getName()).isEqualTo("META-INF/MANIFEST.MF"); + assertThat(zip.getEntry(2).getName()).isEqualTo("1.dat"); + assertThat(zip.getEntry(3).getName()).isEqualTo("2.dat"); + assertThat(zip.getEntry(4).getName()).isEqualTo("d/"); + assertThat(zip.getEntry(5).getName()).isEqualTo("d/9.dat"); + assertThat(zip.getEntry(6).getName()).isEqualTo("special/"); + assertThat(zip.getEntry(7).getName()).isEqualTo("special/\u00EB.dat"); + assertThat(zip.getEntry(8).getName()).isEqualTo("nested.jar"); + assertThat(zip.getEntry(9).getName()).isEqualTo("another-nested.jar"); + assertThat(zip.getEntry(10).getName()).isEqualTo("space nested.jar"); + assertThat(zip.getEntry(11).getName()).isEqualTo("multi-release.jar"); + } + } + + @Test + void openWhenZip64ThatExceedsZipEntryLimitOpensZip() throws Exception { + File zip64File = new File(this.tempDir, "zip64.zip"); + FileCopyUtils.copy(zip64Bytes(), zip64File); + try (ZipContent zip64Content = ZipContent.open(zip64File.toPath())) { + assertThat(zip64Content.size()).isEqualTo(65537); + for (int i = 0; i < zip64Content.size(); i++) { + Entry entry = zip64Content.getEntry(i); + try (CloseableDataBlock dataBlock = entry.openContent()) { + assertThat(asInflaterInputStream(dataBlock)).hasContent("Entry " + (i + 1)); + } + } + } + } + + @Test + void openWhenZip64ThatExceedsZipSizeLimitOpensZip() throws Exception { + Assumptions.assumeTrue(this.tempDir.getFreeSpace() > 6 * 1024 * 1024 * 1024, "Insufficient disk space"); + File zip64File = new File(this.tempDir, "zip64.zip"); + File entryFile = new File(this.tempDir, "entry.dat"); + CRC32 crc32 = new CRC32(); + try (FileOutputStream entryOut = new FileOutputStream(entryFile)) { + byte[] data = new byte[1024 * 1024]; + new Random().nextBytes(data); + for (int i = 0; i < 1024; i++) { + entryOut.write(data); + crc32.update(data); + } + } + try (ZipOutputStream zipOutput = new ZipOutputStream(new FileOutputStream(zip64File))) { + for (int i = 0; i < 6; i++) { + ZipEntry storedEntry = new ZipEntry("huge-" + i); + storedEntry.setSize(entryFile.length()); + storedEntry.setCompressedSize(entryFile.length()); + storedEntry.setCrc(crc32.getValue()); + storedEntry.setMethod(ZipEntry.STORED); + zipOutput.putNextEntry(storedEntry); + try (FileInputStream entryIn = new FileInputStream(entryFile)) { + StreamUtils.copy(entryIn, zipOutput); + } + zipOutput.closeEntry(); + } + } + try (ZipContent zip64Content = ZipContent.open(zip64File.toPath())) { + assertThat(zip64Content.size()).isEqualTo(6); + } + } + + @Test + void nestedZip64CanBeRead() throws Exception { + File containerFile = new File(this.tempDir, "outer.zip"); + try (ZipOutputStream jarOutput = new ZipOutputStream(new FileOutputStream(containerFile))) { + ZipEntry nestedEntry = new ZipEntry("nested-zip64.zip"); + byte[] contents = zip64Bytes(); + 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 (ZipContent nestedZip = ZipContent.open(containerFile.toPath(), "nested-zip64.zip")) { + assertThat(nestedZip.size()).isEqualTo(65537); + for (int i = 0; i < nestedZip.size(); i++) { + Entry entry = nestedZip.getEntry(i); + try (CloseableDataBlock content = entry.openContent()) { + assertThat(asInflaterInputStream(content)).hasContent("Entry " + (i + 1)); + } + } + } + } + + private byte[] zip64Bytes() throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + ZipOutputStream zipOutput = new ZipOutputStream(bytes); + for (int i = 0; i < 65537; i++) { + zipOutput.putNextEntry(new ZipEntry(i + ".dat")); + zipOutput.write(("Entry " + (i + 1)).getBytes(StandardCharsets.UTF_8)); + zipOutput.closeEntry(); + } + zipOutput.close(); + return bytes.toByteArray(); + } + + @Test + void entryWithEpochTimeOfZeroShouldNotFail() throws Exception { + File file = createZipFileWithEpochTimeOfZero(); + try (ZipContent zip = ZipContent.open(file.toPath())) { + ZipEntry entry = zip.getEntry(0).as(ZipEntry::new); + assertThat(entry.getLastModifiedTime().toInstant()).isEqualTo(Instant.EPOCH); + assertThat(entry.getName()).isEqualTo("1.dat"); + } + } + + private File createZipFileWithEpochTimeOfZero() throws Exception { + File file = new File(this.tempDir, "temp.zip"); + String comment = "outer"; + try (ZipOutputStream zipOutput = new ZipOutputStream(new FileOutputStream(file))) { + zipOutput.setComment(comment); + ZipEntry entry = new ZipEntry("1.dat"); + entry.setLastModifiedTime(FileTime.from(Instant.EPOCH)); + zipOutput.putNextEntry(entry); + zipOutput.write(new byte[] { (byte) 1 }); + zipOutput.closeEntry(); + } + ByteBuffer data = ByteBuffer.wrap(Files.readAllBytes(file.toPath())); + data.order(ByteOrder.LITTLE_ENDIAN); + int endOfCentralDirectoryRecordPos = data.remaining() - ZipFile.ENDHDR - comment.getBytes().length; + data.position(endOfCentralDirectoryRecordPos + ZipFile.ENDOFF); + int startOfCentralDirectoryOffset = data.getInt(); + data.position(startOfCentralDirectoryOffset + ZipFile.CENOFF); + int localHeaderPosition = data.getInt(); + writeTimeBlock(data.array(), startOfCentralDirectoryOffset + ZipFile.CENTIM, 0); + writeTimeBlock(data.array(), localHeaderPosition + ZipFile.LOCTIM, 0); + File zerotimedFile = new File(this.tempDir, "zerotimed.zip"); + Files.write(zerotimedFile.toPath(), data.array()); + return zerotimedFile; + } + + @Test + void getInfoReturnsComputedInfo() { + ZipInfo info = this.zipContent.getInfo(ZipInfo.class, ZipInfo::get); + assertThat(info.size()).isEqualTo(12); + } + + 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); + } + + private InputStream asInflaterInputStream(DataBlock dataBlock) throws IOException { + ByteBuffer buffer = ByteBuffer.allocate((int) dataBlock.size() + 1); + buffer.limit(buffer.limit() - 1); + dataBlock.readFully(buffer, 0); + ByteArrayInputStream in = new ByteArrayInputStream(buffer.array()); + return new InflaterInputStream(in, new Inflater(true)); + } + + private void write(File file, CloseableDataBlock dataBlock) throws IOException { + ByteBuffer buffer = ByteBuffer.allocate((int) dataBlock.size()); + dataBlock.readFully(buffer, 0); + Files.write(file.toPath(), buffer.array()); + dataBlock.close(); + } + + private static class ZipInfo { + + private int size; + + ZipInfo(int size) { + this.size = size; + } + + int size() { + return this.size; + } + + static ZipInfo get(ZipContent content) { + return new ZipInfo(content.size()); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipEndOfCentralDirectoryRecordTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipEndOfCentralDirectoryRecordTests.java new file mode 100644 index 0000000000..4a52c0be9b --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipEndOfCentralDirectoryRecordTests.java @@ -0,0 +1,110 @@ +/* + * 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.zip; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIOException; + +/** + * Tests for {@link ZipEndOfCentralDirectoryRecord}. + * + * @author Phillip Webb + */ +class ZipEndOfCentralDirectoryRecordTests { + + @Test + void loadLocatesAndLoadsData() throws Exception { + DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { // + 0x50, 0x4b, 0x05, 0x06, // + 0x01, 0x00, // + 0x02, 0x00, // + 0x03, 0x00, // + 0x04, 0x00, // + 0x05, 0x00, 0x00, 0x00, // + 0x06, 0x00, 0x00, 0x00, // + 0x07, 0x00 }); // + ZipEndOfCentralDirectoryRecord.Located located = ZipEndOfCentralDirectoryRecord.load(dataBlock); + assertThat(located.pos()).isEqualTo(0L); + ZipEndOfCentralDirectoryRecord record = located.endOfCentralDirectoryRecord(); + assertThat(record.numberOfThisDisk()).isEqualTo((short) 1); + assertThat(record.diskWhereCentralDirectoryStarts()).isEqualTo((short) 2); + assertThat(record.numberOfCentralDirectoryEntriesOnThisDisk()).isEqualTo((short) 3); + assertThat(record.totalNumberOfCentralDirectoryEntries()).isEqualTo((short) 4); + assertThat(record.sizeOfCentralDirectory()).isEqualTo(5); + assertThat(record.offsetToStartOfCentralDirectory()).isEqualTo(6); + assertThat(record.commentLength()).isEqualTo((short) 7); + } + + @Test + void loadWhenMultipleBuffersBackLoadsData() throws Exception { + byte[] bytes = new byte[ZipEndOfCentralDirectoryRecord.BUFFER_SIZE * 4]; + byte[] data = new byte[] { // + 0x50, 0x4b, 0x05, 0x06, // + 0x01, 0x00, // + 0x02, 0x00, // + 0x03, 0x00, // + 0x04, 0x00, // + 0x05, 0x00, 0x00, 0x00, // + 0x06, 0x00, 0x00, 0x00, // + 0x07, 0x00 }; // + System.arraycopy(data, 0, bytes, 4, data.length); + ZipEndOfCentralDirectoryRecord.Located located = ZipEndOfCentralDirectoryRecord + .load(new ByteArrayDataBlock(bytes)); + assertThat(located.pos()).isEqualTo(4L); + } + + @Test + void loadWhenSignatureDoesNotMatchThrowsException() { + DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { // + 0x51, 0x4b, 0x05, 0x06, // + 0x01, 0x00, // + 0x02, 0x00, // + 0x03, 0x00, // + 0x04, 0x00, // + 0x05, 0x00, 0x00, 0x00, // + 0x06, 0x00, 0x00, 0x00, // + 0x07, 0x00 }); // + assertThatIOException().isThrownBy(() -> ZipEndOfCentralDirectoryRecord.load(dataBlock)) + .withMessageContaining("'End Of Central Directory Record' not found"); + } + + @Test + void asByteArrayReturnsByteArray() throws Exception { + byte[] bytes = new byte[] { // + 0x50, 0x4b, 0x05, 0x06, // + 0x01, 0x00, // + 0x02, 0x00, // + 0x03, 0x00, // + 0x04, 0x00, // + 0x05, 0x00, 0x00, 0x00, // + 0x06, 0x00, 0x00, 0x00, // + 0x07, 0x00 }; // + ZipEndOfCentralDirectoryRecord.Located located = ZipEndOfCentralDirectoryRecord + .load(new ByteArrayDataBlock(bytes)); + assertThat(located.endOfCentralDirectoryRecord().asByteArray()).isEqualTo(bytes); + } + + @Test + void sizeReturnsSize() { + ZipEndOfCentralDirectoryRecord record = new ZipEndOfCentralDirectoryRecord((short) 1, (short) 2, (short) 3, + (short) 4, 5, 6, (short) 7); + assertThat(record.size()).isEqualTo(29L); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipLocalFileHeaderRecordTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipLocalFileHeaderRecordTests.java new file mode 100644 index 0000000000..02cc96fca2 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipLocalFileHeaderRecordTests.java @@ -0,0 +1,117 @@ +/* + * 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.zip; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIOException; + +/** + * Tests for {@link ZipLocalFileHeaderRecord}. + * + * @author Phillip Webb + */ +class ZipLocalFileHeaderRecordTests { + + @Test + void loadLoadsData() throws Exception { + DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { // + 0x50, 0x4b, 0x03, 0x04, // + 0x01, 0x00, // + 0x02, 0x00, // + 0x03, 0x00, // + 0x04, 0x00, // + 0x05, 0x00, // + 0x06, 0x00, 0x00, 0x00, // + 0x07, 0x00, 0x00, 0x00, // + 0x08, 0x00, 0x00, 0x00, // + 0x09, 0x00, // + 0x0A, 0x00 }); // + ZipLocalFileHeaderRecord record = ZipLocalFileHeaderRecord.load(dataBlock, 0); + assertThat(record.versionNeededToExtract()).isEqualTo((short) 1); + assertThat(record.generalPurposeBitFlag()).isEqualTo((short) 2); + assertThat(record.compressionMethod()).isEqualTo((short) 3); + assertThat(record.lastModFileTime()).isEqualTo((short) 4); + assertThat(record.lastModFileDate()).isEqualTo((short) 5); + assertThat(record.crc32()).isEqualTo(6); + assertThat(record.compressedSize()).isEqualTo(7); + assertThat(record.uncompressedSize()).isEqualTo(8); + assertThat(record.fileNameLength()).isEqualTo((short) 9); + assertThat(record.extraFieldLength()).isEqualTo((short) 10); + } + + @Test + void loadWhenSignatureDoesNotMatchThrowsException() { + DataBlock dataBlock = new ByteArrayDataBlock(new byte[] { // + 0x51, 0x4b, 0x03, 0x04, // + 0x01, 0x00, // + 0x02, 0x00, // + 0x03, 0x00, // + 0x04, 0x00, // + 0x05, 0x00, // + 0x06, 0x00, 0x00, 0x00, // + 0x07, 0x00, 0x00, 0x00, // + 0x08, 0x00, 0x00, 0x00, // + 0x09, 0x00, // + 0x0A, 0x00 }); // + assertThatIOException().isThrownBy(() -> ZipLocalFileHeaderRecord.load(dataBlock, 0)) + .withMessageContaining("'Local File Header Record' not found"); + } + + @Test + void sizeReturnsSize() { + ZipLocalFileHeaderRecord record = new ZipLocalFileHeaderRecord((short) 1, (short) 2, (short) 3, (short) 4, + (short) 5, 6, 7, 8, (short) 9, (short) 10); + assertThat(record.size()).isEqualTo(49L); + } + + @Test + void withExtraFieldLengthReturnsUpdatedInstance() { + ZipLocalFileHeaderRecord record = new ZipLocalFileHeaderRecord((short) 1, (short) 2, (short) 3, (short) 4, + (short) 5, 6, 7, 8, (short) 9, (short) 10) + .withExtraFieldLength((short) 100); + assertThat(record.extraFieldLength()).isEqualTo((short) 100); + } + + @Test + void withFileNameLengthReturnsUpdatedInstance() { + ZipLocalFileHeaderRecord record = new ZipLocalFileHeaderRecord((short) 1, (short) 2, (short) 3, (short) 4, + (short) 5, 6, 7, 8, (short) 9, (short) 10) + .withFileNameLength((short) 100); + assertThat(record.fileNameLength()).isEqualTo((short) 100); + } + + @Test + void asByteArrayReturnsByteArray() throws Exception { + byte[] bytes = new byte[] { // + 0x50, 0x4b, 0x03, 0x04, // + 0x01, 0x00, // + 0x02, 0x00, // + 0x03, 0x00, // + 0x04, 0x00, // + 0x05, 0x00, // + 0x06, 0x00, 0x00, 0x00, // + 0x07, 0x00, 0x00, 0x00, // + 0x08, 0x00, 0x00, 0x00, // + 0x09, 0x00, // + 0x0A, 0x00 }; // + ZipLocalFileHeaderRecord record = ZipLocalFileHeaderRecord.load(new ByteArrayDataBlock(bytes), 0); + assertThat(record.asByteArray()).isEqualTo(bytes); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipStringTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipStringTests.java new file mode 100644 index 0000000000..d421c25145 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/java/org/springframework/boot/loader/zip/ZipStringTests.java @@ -0,0 +1,194 @@ +/* + * 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.zip; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.assertj.core.api.AbstractBooleanAssert; +import org.assertj.core.api.AbstractIntegerAssert; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ZipString}. + * + * @author Phillip Webb + * @author Andy Wilkinson + */ +class ZipStringTests { + + @ParameterizedTest + @EnumSource + void hashGeneratesCorrectHashCode(HashSourceType sourceType) throws Exception { + testHash(sourceType, true, "abcABC123xyz!"); + testHash(sourceType, false, "abcABC123xyz!"); + } + + @ParameterizedTest + @EnumSource + void hashWhenHasSpecialCharsGeneratesCorrectHashCode(HashSourceType sourceType) throws Exception { + testHash(sourceType, true, "special/\u00EB.dat"); + } + + @ParameterizedTest + @EnumSource + void hashWhenHasCyrillicCharsGeneratesCorrectHashCode(HashSourceType sourceType) throws Exception { + testHash(sourceType, true, "\u0432\u0435\u0441\u043D\u0430"); + } + + @ParameterizedTest + @EnumSource + void hashWhenHasEmojiGeneratesCorrectHashCode(HashSourceType sourceType) throws Exception { + testHash(sourceType, true, "\ud83d\udca9"); + } + + @ParameterizedTest + @EnumSource + void hashWhenOnlyDifferenceIsEndSlashGeneratesSameHashCode(HashSourceType sourceType) throws Exception { + testHash(sourceType, "", true, "/".hashCode()); + testHash(sourceType, "/", true, "/".hashCode()); + testHash(sourceType, "a/b", true, "a/b/".hashCode()); + testHash(sourceType, "a/b/", true, "a/b/".hashCode()); + } + + void testHash(HashSourceType sourceType, boolean addSlash, String source) throws Exception { + String expected = (addSlash && !source.endsWith("/")) ? source + "/" : source; + testHash(sourceType, source, addSlash, expected.hashCode()); + } + + void testHash(HashSourceType sourceType, String source, boolean addEndSlash, int expected) throws Exception { + switch (sourceType) { + case STRING -> { + assertThat(ZipString.hash(source, addEndSlash)).isEqualTo(expected); + } + case CHAR_SEQUENCE -> { + CharSequence charSequence = new StringBuilder(source); + assertThat(ZipString.hash(charSequence, addEndSlash)).isEqualTo(expected); + } + case DATA_BLOCK -> { + ByteArrayDataBlock dataBlock = new ByteArrayDataBlock(source.getBytes(StandardCharsets.UTF_8)); + assertThat(ZipString.hash(null, dataBlock, 0, (int) dataBlock.size(), addEndSlash)).isEqualTo(expected); + + } + } + } + + @Test + void matchesWhenExactMatchReturnsTrue() throws Exception { + assertMatches("one/two/three", "one/two/three", false).isTrue(); + } + + @Test + void matchesWhenNotMatchWithSameLengthReturnsFalse() throws Exception { + assertMatches("one/two/three", "one/too/three", false).isFalse(); + } + + @Test + void matchesWhenExactMatchWithSpecialCharsReturnsTrue() throws Exception { + assertMatches("special/\u00EB.dat", "special/\u00EB.dat", false).isTrue(); + } + + @Test + void matchesWhenExactMatchWithCyrillicCharsReturnsTrue() throws Exception { + assertMatches("\u0432\u0435\u0441\u043D\u0430", "\u0432\u0435\u0441\u043D\u0430", false).isTrue(); + } + + @Test + void matchesWhenNoMatchWithCyrillicCharsReturnsFalse() throws Exception { + assertMatches("\u0432\u0435\u0441\u043D\u0430", "\u0432\u0435\u0441\u043D\u043D", false).isFalse(); + } + + @Test + void matchesWhenExactMatchWithEmojiCharsReturnsTrue() throws Exception { + assertMatches("\ud83d\udca9", "\ud83d\udca9", false).isTrue(); + } + + @Test + void matchesWithAddSlash() throws Exception { + assertMatches("META-INF/MANFIFEST.MF", "META-INF/MANFIFEST.MF", true).isTrue(); + assertMatches("one/two/three/", "one/two/three", true).isTrue(); + assertMatches("one/two/three", "one/two/three/", true).isFalse(); + assertMatches("one/two/three/", "one/too/three", true).isFalse(); + assertMatches("one/two/three", "one/too/three/", true).isFalse(); + assertMatches("one/two/three//", "one/two/three", true).isFalse(); + assertMatches("one/two/three", "one/two/three//", true).isFalse(); + } + + @Test + void matchesWhenDataBlockShorterThenCharSequenceReturnsFalse() throws Exception { + assertMatches("one/two/thre", "one/two/three", false).isFalse(); + } + + @Test + void matchesWhenCharSequenceShorterThanDataBlockReturnsFalse() throws Exception { + assertMatches("one/two/three", "one/two/thre", false).isFalse(); + } + + @Test + void startsWithWhenStartsWith() throws Exception { + assertStartsWith("one/two", "one/").isEqualTo(4); + } + + @Test + void startsWithWhenExact() throws Exception { + assertStartsWith("one/", "one/").isEqualTo(4); + } + + @Test + void startsWithWhenTooShort() throws Exception { + assertStartsWith("one/two", "one/two/three/").isEqualTo(-1); + } + + @Test + void startsWithWhenDoesNotStartWith() throws Exception { + assertStartsWith("one/three/", "one/two/").isEqualTo(-1); + } + + @Test + void zipStringWhenMultiCodePointAtBufferBoundary() throws Exception { + StringBuilder source = new StringBuilder(); + for (int i = 0; i < ZipString.BUFFER_SIZE - 1; i++) { + source.append("A"); + } + source.append("\u1EFF"); + String charSequence = source.toString(); + source.append("suffix"); + assertStartsWith(source.toString(), charSequence); + } + + private AbstractBooleanAssert assertMatches(String source, CharSequence charSequence, boolean addSlash) + throws Exception { + ByteArrayDataBlock dataBlock = new ByteArrayDataBlock(source.getBytes(StandardCharsets.UTF_8)); + return assertThat(ZipString.matches(null, dataBlock, 0, (int) dataBlock.size(), charSequence, addSlash)); + } + + private AbstractIntegerAssert assertStartsWith(String source, CharSequence charSequence) throws IOException { + ByteArrayDataBlock dataBlock = new ByteArrayDataBlock(source.getBytes(StandardCharsets.UTF_8)); + return assertThat(ZipString.startsWith(null, dataBlock, 0, (int) dataBlock.size(), charSequence)); + } + + enum HashSourceType { + + STRING, CHAR_SEQUENCE, DATA_BLOCK + + } + +} 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/launch/classpath-index-file.idx similarity index 100% rename from spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/org/springframework/boot/loader/classpath-index-file.idx rename to spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/org/springframework/boot/loader/launch/classpath-index-file.idx 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 deleted file mode 100644 index 8b13789179..0000000000 --- a/spring-boot-project/spring-boot-tools/spring-boot-loader/src/test/resources/root/META-INF/MANIFEST.MF +++ /dev/null @@ -1 +0,0 @@ -