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-classic/src/main/java/org/springframework/boot/loader/jar/JarFileEntries.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarFileEntries.java
new file mode 100644
index 0000000000..d151c8d80a
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarFileEntries.java
@@ -0,0 +1,491 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.jar;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.jar.Attributes;
+import java.util.jar.Attributes.Name;
+import java.util.jar.JarInputStream;
+import java.util.jar.Manifest;
+import java.util.zip.ZipEntry;
+
+import org.springframework.boot.loader.data.RandomAccessData;
+
+/**
+ * Provides access to entries from a {@link JarFile}. In order to reduce memory
+ * consumption entry details are stored using arrays. The {@code hashCodes} array stores
+ * the hash code of the entry name, the {@code centralDirectoryOffsets} provides the
+ * offset to the central directory record and {@code positions} provides the original
+ * order position of the entry. The arrays are stored in hashCode order so that a binary
+ * search can be used to find a name.
+ *
+ * A typical Spring Boot application will have somewhere in the region of 10,500 entries
+ * which should consume about 122K.
+ *
+ * @author Phillip Webb
+ * @author Andy Wilkinson
+ */
+class JarFileEntries implements CentralDirectoryVisitor, Iterable {
+
+ private static final Runnable NO_VALIDATION = () -> {
+ };
+
+ private static final String META_INF_PREFIX = "META-INF/";
+
+ private static final Name MULTI_RELEASE = new Name("Multi-Release");
+
+ private static final int BASE_VERSION = 8;
+
+ private static final int RUNTIME_VERSION = Runtime.version().feature();
+
+ private static final long LOCAL_FILE_HEADER_SIZE = 30;
+
+ private static final char SLASH = '/';
+
+ private static final char NO_SUFFIX = 0;
+
+ protected static final int ENTRY_CACHE_SIZE = 25;
+
+ private final JarFile jarFile;
+
+ private final JarEntryFilter filter;
+
+ private RandomAccessData centralDirectoryData;
+
+ private int size;
+
+ private int[] hashCodes;
+
+ private Offsets centralDirectoryOffsets;
+
+ private int[] positions;
+
+ private Boolean multiReleaseJar;
+
+ private JarEntryCertification[] certifications;
+
+ private final Map entriesCache = Collections
+ .synchronizedMap(new LinkedHashMap<>(16, 0.75f, true) {
+
+ @Override
+ protected boolean removeEldestEntry(Map.Entry eldest) {
+ return size() >= ENTRY_CACHE_SIZE;
+ }
+
+ });
+
+ JarFileEntries(JarFile jarFile, JarEntryFilter filter) {
+ this.jarFile = jarFile;
+ this.filter = filter;
+ }
+
+ @Override
+ public void visitStart(CentralDirectoryEndRecord endRecord, RandomAccessData centralDirectoryData) {
+ int maxSize = endRecord.getNumberOfRecords();
+ this.centralDirectoryData = centralDirectoryData;
+ this.hashCodes = new int[maxSize];
+ this.centralDirectoryOffsets = Offsets.from(endRecord);
+ this.positions = new int[maxSize];
+ }
+
+ @Override
+ public void visitFileHeader(CentralDirectoryFileHeader fileHeader, long dataOffset) {
+ AsciiBytes name = applyFilter(fileHeader.getName());
+ if (name != null) {
+ add(name, dataOffset);
+ }
+ }
+
+ private void add(AsciiBytes name, long dataOffset) {
+ this.hashCodes[this.size] = name.hashCode();
+ this.centralDirectoryOffsets.set(this.size, dataOffset);
+ this.positions[this.size] = this.size;
+ this.size++;
+ }
+
+ @Override
+ public void visitEnd() {
+ sort(0, this.size - 1);
+ int[] positions = this.positions;
+ this.positions = new int[positions.length];
+ for (int i = 0; i < this.size; i++) {
+ this.positions[positions[i]] = i;
+ }
+ }
+
+ int getSize() {
+ return this.size;
+ }
+
+ private void sort(int left, int right) {
+ // Quick sort algorithm, uses hashCodes as the source but sorts all arrays
+ if (left < right) {
+ int pivot = this.hashCodes[left + (right - left) / 2];
+ int i = left;
+ int j = right;
+ while (i <= j) {
+ while (this.hashCodes[i] < pivot) {
+ i++;
+ }
+ while (this.hashCodes[j] > pivot) {
+ j--;
+ }
+ if (i <= j) {
+ swap(i, j);
+ i++;
+ j--;
+ }
+ }
+ if (left < j) {
+ sort(left, j);
+ }
+ if (right > i) {
+ sort(i, right);
+ }
+ }
+ }
+
+ private void swap(int i, int j) {
+ swap(this.hashCodes, i, j);
+ this.centralDirectoryOffsets.swap(i, j);
+ swap(this.positions, i, j);
+ }
+
+ @Override
+ public Iterator iterator() {
+ return new EntryIterator(NO_VALIDATION);
+ }
+
+ Iterator iterator(Runnable validator) {
+ return new EntryIterator(validator);
+ }
+
+ boolean containsEntry(CharSequence name) {
+ return getEntry(name, FileHeader.class, true) != null;
+ }
+
+ JarEntry getEntry(CharSequence name) {
+ return getEntry(name, JarEntry.class, true);
+ }
+
+ InputStream getInputStream(String name) throws IOException {
+ FileHeader entry = getEntry(name, FileHeader.class, false);
+ return getInputStream(entry);
+ }
+
+ InputStream getInputStream(FileHeader entry) throws IOException {
+ if (entry == null) {
+ return null;
+ }
+ InputStream inputStream = getEntryData(entry).getInputStream();
+ if (entry.getMethod() == ZipEntry.DEFLATED) {
+ inputStream = new ZipInflaterInputStream(inputStream, (int) entry.getSize());
+ }
+ return inputStream;
+ }
+
+ RandomAccessData getEntryData(String name) throws IOException {
+ FileHeader entry = getEntry(name, FileHeader.class, false);
+ if (entry == null) {
+ return null;
+ }
+ return getEntryData(entry);
+ }
+
+ private RandomAccessData getEntryData(FileHeader entry) throws IOException {
+ // aspectjrt-1.7.4.jar has a different ext bytes length in the
+ // local directory to the central directory. We need to re-read
+ // here to skip them
+ RandomAccessData data = this.jarFile.getData();
+ byte[] localHeader = data.read(entry.getLocalHeaderOffset(), LOCAL_FILE_HEADER_SIZE);
+ long nameLength = Bytes.littleEndianValue(localHeader, 26, 2);
+ long extraLength = Bytes.littleEndianValue(localHeader, 28, 2);
+ return data.getSubsection(entry.getLocalHeaderOffset() + LOCAL_FILE_HEADER_SIZE + nameLength + extraLength,
+ entry.getCompressedSize());
+ }
+
+ private T getEntry(CharSequence name, Class type, boolean cacheEntry) {
+ T entry = doGetEntry(name, type, cacheEntry, null);
+ if (!isMetaInfEntry(name) && isMultiReleaseJar()) {
+ int version = RUNTIME_VERSION;
+ AsciiBytes nameAlias = (entry instanceof JarEntry jarEntry) ? jarEntry.getAsciiBytesName()
+ : new AsciiBytes(name.toString());
+ while (version > BASE_VERSION) {
+ T versionedEntry = doGetEntry("META-INF/versions/" + version + "/" + name, type, cacheEntry, nameAlias);
+ if (versionedEntry != null) {
+ return versionedEntry;
+ }
+ version--;
+ }
+ }
+ return entry;
+ }
+
+ private boolean isMetaInfEntry(CharSequence name) {
+ return name.toString().startsWith(META_INF_PREFIX);
+ }
+
+ private boolean isMultiReleaseJar() {
+ Boolean multiRelease = this.multiReleaseJar;
+ if (multiRelease != null) {
+ return multiRelease;
+ }
+ try {
+ Manifest manifest = this.jarFile.getManifest();
+ if (manifest == null) {
+ multiRelease = false;
+ }
+ else {
+ Attributes attributes = manifest.getMainAttributes();
+ multiRelease = attributes.containsKey(MULTI_RELEASE);
+ }
+ }
+ catch (IOException ex) {
+ multiRelease = false;
+ }
+ this.multiReleaseJar = multiRelease;
+ return multiRelease;
+ }
+
+ private T doGetEntry(CharSequence name, Class type, boolean cacheEntry,
+ AsciiBytes nameAlias) {
+ int hashCode = AsciiBytes.hashCode(name);
+ T entry = getEntry(hashCode, name, NO_SUFFIX, type, cacheEntry, nameAlias);
+ if (entry == null) {
+ hashCode = AsciiBytes.hashCode(hashCode, SLASH);
+ entry = getEntry(hashCode, name, SLASH, type, cacheEntry, nameAlias);
+ }
+ return entry;
+ }
+
+ private T getEntry(int hashCode, CharSequence name, char suffix, Class type,
+ boolean cacheEntry, AsciiBytes nameAlias) {
+ int index = getFirstIndex(hashCode);
+ while (index >= 0 && index < this.size && this.hashCodes[index] == hashCode) {
+ T entry = getEntry(index, type, cacheEntry, nameAlias);
+ if (entry.hasName(name, suffix)) {
+ return entry;
+ }
+ index++;
+ }
+ return null;
+ }
+
+ @SuppressWarnings("unchecked")
+ private T getEntry(int index, Class type, boolean cacheEntry, AsciiBytes nameAlias) {
+ try {
+ long offset = this.centralDirectoryOffsets.get(index);
+ FileHeader cached = this.entriesCache.get(index);
+ FileHeader entry = (cached != null) ? cached
+ : CentralDirectoryFileHeader.fromRandomAccessData(this.centralDirectoryData, offset, this.filter);
+ if (CentralDirectoryFileHeader.class.equals(entry.getClass()) && type.equals(JarEntry.class)) {
+ entry = new JarEntry(this.jarFile, index, (CentralDirectoryFileHeader) entry, nameAlias);
+ }
+ if (cacheEntry && cached != entry) {
+ this.entriesCache.put(index, entry);
+ }
+ return (T) entry;
+ }
+ catch (IOException ex) {
+ throw new IllegalStateException(ex);
+ }
+ }
+
+ private int getFirstIndex(int hashCode) {
+ int index = Arrays.binarySearch(this.hashCodes, 0, this.size, hashCode);
+ if (index < 0) {
+ return -1;
+ }
+ while (index > 0 && this.hashCodes[index - 1] == hashCode) {
+ index--;
+ }
+ return index;
+ }
+
+ void clearCache() {
+ this.entriesCache.clear();
+ }
+
+ private AsciiBytes applyFilter(AsciiBytes name) {
+ return (this.filter != null) ? this.filter.apply(name) : name;
+ }
+
+ JarEntryCertification getCertification(JarEntry entry) throws IOException {
+ JarEntryCertification[] certifications = this.certifications;
+ if (certifications == null) {
+ certifications = new JarEntryCertification[this.size];
+ // We fall back to use JarInputStream to obtain the certs. This isn't that
+ // fast, but hopefully doesn't happen too often.
+ try (JarInputStream certifiedJarStream = new JarInputStream(this.jarFile.getData().getInputStream())) {
+ java.util.jar.JarEntry certifiedEntry;
+ while ((certifiedEntry = certifiedJarStream.getNextJarEntry()) != null) {
+ // Entry must be closed to trigger a read and set entry certificates
+ certifiedJarStream.closeEntry();
+ int index = getEntryIndex(certifiedEntry.getName());
+ if (index != -1) {
+ certifications[index] = JarEntryCertification.from(certifiedEntry);
+ }
+ }
+ }
+ this.certifications = certifications;
+ }
+ JarEntryCertification certification = certifications[entry.getIndex()];
+ return (certification != null) ? certification : JarEntryCertification.NONE;
+ }
+
+ private int getEntryIndex(CharSequence name) {
+ int hashCode = AsciiBytes.hashCode(name);
+ int index = getFirstIndex(hashCode);
+ while (index >= 0 && index < this.size && this.hashCodes[index] == hashCode) {
+ FileHeader candidate = getEntry(index, FileHeader.class, false, null);
+ if (candidate.hasName(name, NO_SUFFIX)) {
+ return index;
+ }
+ index++;
+ }
+ return -1;
+ }
+
+ private static void swap(int[] array, int i, int j) {
+ int temp = array[i];
+ array[i] = array[j];
+ array[j] = temp;
+ }
+
+ private static void swap(long[] array, int i, int j) {
+ long temp = array[i];
+ array[i] = array[j];
+ array[j] = temp;
+ }
+
+ /**
+ * Iterator for contained entries.
+ */
+ private final class EntryIterator implements Iterator {
+
+ private final Runnable validator;
+
+ private int index = 0;
+
+ private EntryIterator(Runnable validator) {
+ this.validator = validator;
+ validator.run();
+ }
+
+ @Override
+ public boolean hasNext() {
+ this.validator.run();
+ return this.index < JarFileEntries.this.size;
+ }
+
+ @Override
+ public JarEntry next() {
+ this.validator.run();
+ if (!hasNext()) {
+ throw new NoSuchElementException();
+ }
+ int entryIndex = JarFileEntries.this.positions[this.index];
+ this.index++;
+ return getEntry(entryIndex, JarEntry.class, false, null);
+ }
+
+ }
+
+ /**
+ * Interface to manage offsets to central directory records. Regular zip files are
+ * backed by an {@code int[]} based implementation, Zip64 files are backed by a
+ * {@code long[]} and will consume more memory.
+ */
+ private interface Offsets {
+
+ void set(int index, long value);
+
+ long get(int index);
+
+ void swap(int i, int j);
+
+ static Offsets from(CentralDirectoryEndRecord endRecord) {
+ int size = endRecord.getNumberOfRecords();
+ return endRecord.isZip64() ? new Zip64Offsets(size) : new ZipOffsets(size);
+ }
+
+ }
+
+ /**
+ * {@link Offsets} implementation for regular zip files.
+ */
+ private static final class ZipOffsets implements Offsets {
+
+ private final int[] offsets;
+
+ private ZipOffsets(int size) {
+ this.offsets = new int[size];
+ }
+
+ @Override
+ public void swap(int i, int j) {
+ JarFileEntries.swap(this.offsets, i, j);
+ }
+
+ @Override
+ public void set(int index, long value) {
+ this.offsets[index] = (int) value;
+ }
+
+ @Override
+ public long get(int index) {
+ return this.offsets[index];
+ }
+
+ }
+
+ /**
+ * {@link Offsets} implementation for zip64 files.
+ */
+ private static final class Zip64Offsets implements Offsets {
+
+ private final long[] offsets;
+
+ private Zip64Offsets(int size) {
+ this.offsets = new long[size];
+ }
+
+ @Override
+ public void swap(int i, int j) {
+ JarFileEntries.swap(this.offsets, i, j);
+ }
+
+ @Override
+ public void set(int index, long value) {
+ this.offsets[index] = value;
+ }
+
+ @Override
+ public long get(int index) {
+ return this.offsets[index];
+ }
+
+ }
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarFileWrapper.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarFileWrapper.java
new file mode 100644
index 0000000000..b65358947a
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarFileWrapper.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.jar;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.security.Permission;
+import java.util.Enumeration;
+import java.util.jar.JarEntry;
+import java.util.jar.Manifest;
+import java.util.stream.Stream;
+import java.util.zip.ZipEntry;
+
+/**
+ * A wrapper used to create a copy of a {@link JarFile} so that it can be safely closed
+ * without closing the original.
+ *
+ * @author Phillip Webb
+ */
+class JarFileWrapper extends AbstractJarFile {
+
+ private final JarFile parent;
+
+ JarFileWrapper(JarFile parent) throws IOException {
+ super(parent.getRootJarFile().getFile());
+ this.parent = parent;
+ super.close();
+ }
+
+ @Override
+ URL getUrl() throws MalformedURLException {
+ return this.parent.getUrl();
+ }
+
+ @Override
+ JarFileType getType() {
+ return this.parent.getType();
+ }
+
+ @Override
+ Permission getPermission() {
+ return this.parent.getPermission();
+ }
+
+ @Override
+ public Manifest getManifest() throws IOException {
+ return this.parent.getManifest();
+ }
+
+ @Override
+ public Enumeration entries() {
+ return this.parent.entries();
+ }
+
+ @Override
+ public Stream stream() {
+ return this.parent.stream();
+ }
+
+ @Override
+ public JarEntry getJarEntry(String name) {
+ return this.parent.getJarEntry(name);
+ }
+
+ @Override
+ public ZipEntry getEntry(String name) {
+ return this.parent.getEntry(name);
+ }
+
+ @Override
+ InputStream getInputStream() throws IOException {
+ return this.parent.getInputStream();
+ }
+
+ @Override
+ public synchronized InputStream getInputStream(ZipEntry ze) throws IOException {
+ return this.parent.getInputStream(ze);
+ }
+
+ @Override
+ public String getComment() {
+ return this.parent.getComment();
+ }
+
+ @Override
+ public int size() {
+ return this.parent.size();
+ }
+
+ @Override
+ public String toString() {
+ return this.parent.toString();
+ }
+
+ @Override
+ public String getName() {
+ return this.parent.getName();
+ }
+
+ static JarFile unwrap(java.util.jar.JarFile jarFile) {
+ if (jarFile instanceof JarFile file) {
+ return file;
+ }
+ if (jarFile instanceof JarFileWrapper wrapper) {
+ return unwrap(wrapper.parent);
+ }
+ throw new IllegalStateException("Not a JarFile or Wrapper");
+ }
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarURLConnection.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarURLConnection.java
new file mode 100644
index 0000000000..859ae88ab0
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/JarURLConnection.java
@@ -0,0 +1,393 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.jar;
+
+import java.io.ByteArrayOutputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UnsupportedEncodingException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLConnection;
+import java.net.URLEncoder;
+import java.net.URLStreamHandler;
+import java.security.Permission;
+
+/**
+ * {@link java.net.JarURLConnection} used to support {@link JarFile#getUrl()}.
+ *
+ * @author Phillip Webb
+ * @author Andy Wilkinson
+ * @author Rostyslav Dudka
+ */
+final class JarURLConnection extends java.net.JarURLConnection {
+
+ private static final ThreadLocal useFastExceptions = new ThreadLocal<>();
+
+ private static final FileNotFoundException FILE_NOT_FOUND_EXCEPTION = new FileNotFoundException(
+ "Jar file or entry not found");
+
+ private static final IllegalStateException NOT_FOUND_CONNECTION_EXCEPTION = new IllegalStateException(
+ FILE_NOT_FOUND_EXCEPTION);
+
+ private static final String SEPARATOR = "!/";
+
+ private static final URL EMPTY_JAR_URL;
+
+ static {
+ try {
+ EMPTY_JAR_URL = new URL("jar:", null, 0, "file:!/", new URLStreamHandler() {
+ @Override
+ protected URLConnection openConnection(URL u) throws IOException {
+ // Stub URLStreamHandler to prevent the wrong JAR Handler from being
+ // Instantiated and cached.
+ return null;
+ }
+ });
+ }
+ catch (MalformedURLException ex) {
+ throw new IllegalStateException(ex);
+ }
+ }
+
+ private static final JarEntryName EMPTY_JAR_ENTRY_NAME = new JarEntryName(new StringSequence(""));
+
+ private static final JarURLConnection NOT_FOUND_CONNECTION = JarURLConnection.notFound();
+
+ private final AbstractJarFile jarFile;
+
+ private Permission permission;
+
+ private URL jarFileUrl;
+
+ private final JarEntryName jarEntryName;
+
+ private java.util.jar.JarEntry jarEntry;
+
+ private JarURLConnection(URL url, AbstractJarFile jarFile, JarEntryName jarEntryName) throws IOException {
+ // What we pass to super is ultimately ignored
+ super(EMPTY_JAR_URL);
+ this.url = url;
+ this.jarFile = jarFile;
+ this.jarEntryName = jarEntryName;
+ }
+
+ @Override
+ public void connect() throws IOException {
+ if (this.jarFile == null) {
+ throw FILE_NOT_FOUND_EXCEPTION;
+ }
+ if (!this.jarEntryName.isEmpty() && this.jarEntry == null) {
+ this.jarEntry = this.jarFile.getJarEntry(getEntryName());
+ if (this.jarEntry == null) {
+ throwFileNotFound(this.jarEntryName, this.jarFile);
+ }
+ }
+ this.connected = true;
+ }
+
+ @Override
+ public java.util.jar.JarFile getJarFile() throws IOException {
+ connect();
+ return this.jarFile;
+ }
+
+ @Override
+ public URL getJarFileURL() {
+ if (this.jarFile == null) {
+ throw NOT_FOUND_CONNECTION_EXCEPTION;
+ }
+ if (this.jarFileUrl == null) {
+ this.jarFileUrl = buildJarFileUrl();
+ }
+ return this.jarFileUrl;
+ }
+
+ private URL buildJarFileUrl() {
+ try {
+ String spec = this.jarFile.getUrl().getFile();
+ if (spec.endsWith(SEPARATOR)) {
+ spec = spec.substring(0, spec.length() - SEPARATOR.length());
+ }
+ if (!spec.contains(SEPARATOR)) {
+ return new URL(spec);
+ }
+ return new URL("jar:" + spec);
+ }
+ catch (MalformedURLException ex) {
+ throw new IllegalStateException(ex);
+ }
+ }
+
+ @Override
+ public java.util.jar.JarEntry getJarEntry() throws IOException {
+ if (this.jarEntryName == null || this.jarEntryName.isEmpty()) {
+ return null;
+ }
+ connect();
+ return this.jarEntry;
+ }
+
+ @Override
+ public String getEntryName() {
+ if (this.jarFile == null) {
+ throw NOT_FOUND_CONNECTION_EXCEPTION;
+ }
+ return this.jarEntryName.toString();
+ }
+
+ @Override
+ public InputStream getInputStream() throws IOException {
+ if (this.jarFile == null) {
+ throw FILE_NOT_FOUND_EXCEPTION;
+ }
+ if (this.jarEntryName.isEmpty() && this.jarFile.getType() == JarFile.JarFileType.DIRECT) {
+ throw new IOException("no entry name specified");
+ }
+ connect();
+ InputStream inputStream = (this.jarEntryName.isEmpty() ? this.jarFile.getInputStream()
+ : this.jarFile.getInputStream(this.jarEntry));
+ if (inputStream == null) {
+ throwFileNotFound(this.jarEntryName, this.jarFile);
+ }
+ return inputStream;
+ }
+
+ private void throwFileNotFound(Object entry, AbstractJarFile jarFile) throws FileNotFoundException {
+ if (Boolean.TRUE.equals(useFastExceptions.get())) {
+ throw FILE_NOT_FOUND_EXCEPTION;
+ }
+ throw new FileNotFoundException("JAR entry " + entry + " not found in " + jarFile.getName());
+ }
+
+ @Override
+ public int getContentLength() {
+ long length = getContentLengthLong();
+ if (length > Integer.MAX_VALUE) {
+ return -1;
+ }
+ return (int) length;
+ }
+
+ @Override
+ public long getContentLengthLong() {
+ if (this.jarFile == null) {
+ return -1;
+ }
+ try {
+ if (this.jarEntryName.isEmpty()) {
+ return this.jarFile.size();
+ }
+ java.util.jar.JarEntry entry = getJarEntry();
+ return (entry != null) ? (int) entry.getSize() : -1;
+ }
+ catch (IOException ex) {
+ return -1;
+ }
+ }
+
+ @Override
+ public Object getContent() throws IOException {
+ connect();
+ return this.jarEntryName.isEmpty() ? this.jarFile : super.getContent();
+ }
+
+ @Override
+ public String getContentType() {
+ return (this.jarEntryName != null) ? this.jarEntryName.getContentType() : null;
+ }
+
+ @Override
+ public Permission getPermission() throws IOException {
+ if (this.jarFile == null) {
+ throw FILE_NOT_FOUND_EXCEPTION;
+ }
+ if (this.permission == null) {
+ this.permission = this.jarFile.getPermission();
+ }
+ return this.permission;
+ }
+
+ @Override
+ public long getLastModified() {
+ if (this.jarFile == null || this.jarEntryName.isEmpty()) {
+ return 0;
+ }
+ try {
+ java.util.jar.JarEntry entry = getJarEntry();
+ return (entry != null) ? entry.getTime() : 0;
+ }
+ catch (IOException ex) {
+ return 0;
+ }
+ }
+
+ static void setUseFastExceptions(boolean useFastExceptions) {
+ JarURLConnection.useFastExceptions.set(useFastExceptions);
+ }
+
+ static JarURLConnection get(URL url, JarFile jarFile) throws IOException {
+ StringSequence spec = new StringSequence(url.getFile());
+ int index = indexOfRootSpec(spec, jarFile.getPathFromRoot());
+ if (index == -1) {
+ return (Boolean.TRUE.equals(useFastExceptions.get()) ? NOT_FOUND_CONNECTION
+ : new JarURLConnection(url, null, EMPTY_JAR_ENTRY_NAME));
+ }
+ int separator;
+ while ((separator = spec.indexOf(SEPARATOR, index)) > 0) {
+ JarEntryName entryName = JarEntryName.get(spec.subSequence(index, separator));
+ JarEntry jarEntry = jarFile.getJarEntry(entryName.toCharSequence());
+ if (jarEntry == null) {
+ return JarURLConnection.notFound(jarFile, entryName);
+ }
+ jarFile = jarFile.getNestedJarFile(jarEntry);
+ index = separator + SEPARATOR.length();
+ }
+ JarEntryName jarEntryName = JarEntryName.get(spec, index);
+ if (Boolean.TRUE.equals(useFastExceptions.get()) && !jarEntryName.isEmpty()
+ && !jarFile.containsEntry(jarEntryName.toString())) {
+ return NOT_FOUND_CONNECTION;
+ }
+ return new JarURLConnection(url, jarFile.getWrapper(), jarEntryName);
+ }
+
+ private static int indexOfRootSpec(StringSequence file, String pathFromRoot) {
+ int separatorIndex = file.indexOf(SEPARATOR);
+ if (separatorIndex < 0 || !file.startsWith(pathFromRoot, separatorIndex)) {
+ return -1;
+ }
+ return separatorIndex + SEPARATOR.length() + pathFromRoot.length();
+ }
+
+ private static JarURLConnection notFound() {
+ try {
+ return notFound(null, null);
+ }
+ catch (IOException ex) {
+ throw new IllegalStateException(ex);
+ }
+ }
+
+ private static JarURLConnection notFound(JarFile jarFile, JarEntryName jarEntryName) throws IOException {
+ if (Boolean.TRUE.equals(useFastExceptions.get())) {
+ return NOT_FOUND_CONNECTION;
+ }
+ return new JarURLConnection(null, jarFile, jarEntryName);
+ }
+
+ /**
+ * A JarEntryName parsed from a URL String.
+ */
+ static class JarEntryName {
+
+ private final StringSequence name;
+
+ private String contentType;
+
+ JarEntryName(StringSequence spec) {
+ this.name = decode(spec);
+ }
+
+ private StringSequence decode(StringSequence source) {
+ if (source.isEmpty() || (source.indexOf('%') < 0)) {
+ return source;
+ }
+ ByteArrayOutputStream bos = new ByteArrayOutputStream(source.length());
+ write(source.toString(), bos);
+ // AsciiBytes is what is used to store the JarEntries so make it symmetric
+ return new StringSequence(AsciiBytes.toString(bos.toByteArray()));
+ }
+
+ private void write(String source, ByteArrayOutputStream outputStream) {
+ int length = source.length();
+ for (int i = 0; i < length; i++) {
+ int c = source.charAt(i);
+ if (c > 127) {
+ try {
+ String encoded = URLEncoder.encode(String.valueOf((char) c), "UTF-8");
+ write(encoded, outputStream);
+ }
+ catch (UnsupportedEncodingException ex) {
+ throw new IllegalStateException(ex);
+ }
+ }
+ else {
+ if (c == '%') {
+ if ((i + 2) >= length) {
+ throw new IllegalArgumentException(
+ "Invalid encoded sequence \"" + source.substring(i) + "\"");
+ }
+ c = decodeEscapeSequence(source, i);
+ i += 2;
+ }
+ outputStream.write(c);
+ }
+ }
+ }
+
+ private char decodeEscapeSequence(String source, int i) {
+ int hi = Character.digit(source.charAt(i + 1), 16);
+ int lo = Character.digit(source.charAt(i + 2), 16);
+ if (hi == -1 || lo == -1) {
+ throw new IllegalArgumentException("Invalid encoded sequence \"" + source.substring(i) + "\"");
+ }
+ return ((char) ((hi << 4) + lo));
+ }
+
+ CharSequence toCharSequence() {
+ return this.name;
+ }
+
+ @Override
+ public String toString() {
+ return this.name.toString();
+ }
+
+ boolean isEmpty() {
+ return this.name.isEmpty();
+ }
+
+ String getContentType() {
+ if (this.contentType == null) {
+ this.contentType = deduceContentType();
+ }
+ return this.contentType;
+ }
+
+ private String deduceContentType() {
+ // Guess the content type, don't bother with streams as mark is not supported
+ String type = isEmpty() ? "x-java/jar" : null;
+ type = (type != null) ? type : guessContentTypeFromName(toString());
+ type = (type != null) ? type : "content/unknown";
+ return type;
+ }
+
+ static JarEntryName get(StringSequence spec) {
+ return get(spec, 0);
+ }
+
+ static JarEntryName get(StringSequence spec, int beginIndex) {
+ if (spec.length() <= beginIndex) {
+ return EMPTY_JAR_ENTRY_NAME;
+ }
+ return new JarEntryName(spec.subSequence(beginIndex));
+ }
+
+ }
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/StringSequence.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/StringSequence.java
new file mode 100644
index 0000000000..12850a4ebe
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/StringSequence.java
@@ -0,0 +1,157 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.jar;
+
+import java.util.Objects;
+
+/**
+ * A {@link CharSequence} backed by a single shared {@link String}. Unlike a regular
+ * {@link String}, {@link #subSequence(int, int)} operations will not copy the underlying
+ * character array.
+ *
+ * @author Phillip Webb
+ */
+final class StringSequence implements CharSequence {
+
+ private final String source;
+
+ private final int start;
+
+ private final int end;
+
+ private int hash;
+
+ StringSequence(String source) {
+ this(source, 0, (source != null) ? source.length() : -1);
+ }
+
+ StringSequence(String source, int start, int end) {
+ Objects.requireNonNull(source, "Source must not be null");
+ if (start < 0) {
+ throw new StringIndexOutOfBoundsException(start);
+ }
+ if (end > source.length()) {
+ throw new StringIndexOutOfBoundsException(end);
+ }
+ this.source = source;
+ this.start = start;
+ this.end = end;
+ }
+
+ StringSequence subSequence(int start) {
+ return subSequence(start, length());
+ }
+
+ @Override
+ public StringSequence subSequence(int start, int end) {
+ int subSequenceStart = this.start + start;
+ int subSequenceEnd = this.start + end;
+ if (subSequenceStart > this.end) {
+ throw new StringIndexOutOfBoundsException(start);
+ }
+ if (subSequenceEnd > this.end) {
+ throw new StringIndexOutOfBoundsException(end);
+ }
+ if (start == 0 && subSequenceEnd == this.end) {
+ return this;
+ }
+ return new StringSequence(this.source, subSequenceStart, subSequenceEnd);
+ }
+
+ /**
+ * Returns {@code true} if the sequence is empty. Public to be compatible with JDK 15.
+ * @return {@code true} if {@link #length()} is {@code 0}, otherwise {@code false}
+ */
+ public boolean isEmpty() {
+ return length() == 0;
+ }
+
+ @Override
+ public int length() {
+ return this.end - this.start;
+ }
+
+ @Override
+ public char charAt(int index) {
+ return this.source.charAt(this.start + index);
+ }
+
+ int indexOf(char ch) {
+ return this.source.indexOf(ch, this.start) - this.start;
+ }
+
+ int indexOf(String str) {
+ return this.source.indexOf(str, this.start) - this.start;
+ }
+
+ int indexOf(String str, int fromIndex) {
+ return this.source.indexOf(str, this.start + fromIndex) - this.start;
+ }
+
+ boolean startsWith(String prefix) {
+ return startsWith(prefix, 0);
+ }
+
+ boolean startsWith(String prefix, int offset) {
+ int prefixLength = prefix.length();
+ int length = length();
+ if (length - prefixLength - offset < 0) {
+ return false;
+ }
+ return this.source.startsWith(prefix, this.start + offset);
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (!(obj instanceof CharSequence other)) {
+ return false;
+ }
+ int n = length();
+ if (n != other.length()) {
+ return false;
+ }
+ int i = 0;
+ while (n-- != 0) {
+ if (charAt(i) != other.charAt(i)) {
+ return false;
+ }
+ i++;
+ }
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ int hash = this.hash;
+ if (hash == 0 && length() > 0) {
+ for (int i = this.start; i < this.end; i++) {
+ hash = 31 * hash + this.source.charAt(i);
+ }
+ this.hash = hash;
+ }
+ return hash;
+ }
+
+ @Override
+ public String toString() {
+ return this.source.substring(this.start, this.end);
+ }
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/ZipInflaterInputStream.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/ZipInflaterInputStream.java
new file mode 100644
index 0000000000..67624460cc
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/ZipInflaterInputStream.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.jar;
+
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.zip.Inflater;
+import java.util.zip.InflaterInputStream;
+
+/**
+ * {@link InflaterInputStream} that supports the writing of an extra "dummy" byte (which
+ * is required with JDK 6) and returns accurate available() results.
+ *
+ * @author Phillip Webb
+ */
+class ZipInflaterInputStream extends InflaterInputStream {
+
+ private int available;
+
+ private boolean extraBytesWritten;
+
+ ZipInflaterInputStream(InputStream inputStream, int size) {
+ super(inputStream, new Inflater(true), getInflaterBufferSize(size));
+ this.available = size;
+ }
+
+ @Override
+ public int available() throws IOException {
+ if (this.available < 0) {
+ return super.available();
+ }
+ return this.available;
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) throws IOException {
+ int result = super.read(b, off, len);
+ if (result != -1) {
+ this.available -= result;
+ }
+ return result;
+ }
+
+ @Override
+ public void close() throws IOException {
+ super.close();
+ this.inf.end();
+ }
+
+ @Override
+ protected void fill() throws IOException {
+ try {
+ super.fill();
+ }
+ catch (EOFException ex) {
+ if (this.extraBytesWritten) {
+ throw ex;
+ }
+ this.len = 1;
+ this.buf[0] = 0x0;
+ this.extraBytesWritten = true;
+ this.inf.setInput(this.buf, 0, this.len);
+ }
+ }
+
+ private static int getInflaterBufferSize(long size) {
+ size += 2; // inflater likes some space
+ size = (size > 65536) ? 8192 : size;
+ size = (size <= 0) ? 4096 : size;
+ return (int) size;
+ }
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/package-info.java
new file mode 100644
index 0000000000..638afe45f4
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jar/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Support for loading and manipulating JAR/WAR files.
+ */
+package org.springframework.boot.loader.jar;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/JarMode.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/JarMode.java
new file mode 100644
index 0000000000..162e4a6a73
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/JarMode.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.jarmode;
+
+/**
+ * Interface registered in {@code spring.factories} to provides extended 'jarmode'
+ * support.
+ *
+ * @author Phillip Webb
+ * @since 2.3.0
+ */
+public interface JarMode {
+
+ /**
+ * Returns if this accepts and can run the given mode.
+ * @param mode the mode to check
+ * @return if this instance accepts the mode
+ */
+ boolean accepts(String mode);
+
+ /**
+ * Run the jar in the given mode.
+ * @param mode the mode to use
+ * @param args any program arguments
+ */
+ void run(String mode, String[] args);
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/JarModeLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/JarModeLauncher.java
new file mode 100644
index 0000000000..600266a241
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/JarModeLauncher.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.jarmode;
+
+import java.util.List;
+
+import org.springframework.core.io.support.SpringFactoriesLoader;
+import org.springframework.util.ClassUtils;
+
+/**
+ * Delegate class used to launch the uber jar in a specific mode.
+ *
+ * @author Phillip Webb
+ * @since 2.3.0
+ */
+public final class JarModeLauncher {
+
+ static final String DISABLE_SYSTEM_EXIT = JarModeLauncher.class.getName() + ".DISABLE_SYSTEM_EXIT";
+
+ private JarModeLauncher() {
+ }
+
+ public static void main(String[] args) {
+ String mode = System.getProperty("jarmode");
+ List candidates = SpringFactoriesLoader.loadFactories(JarMode.class,
+ ClassUtils.getDefaultClassLoader());
+ for (JarMode candidate : candidates) {
+ if (candidate.accepts(mode)) {
+ candidate.run(mode, args);
+ return;
+ }
+ }
+ System.err.println("Unsupported jarmode '" + mode + "'");
+ if (!Boolean.getBoolean(DISABLE_SYSTEM_EXIT)) {
+ System.exit(1);
+ }
+ }
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/TestJarMode.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/TestJarMode.java
new file mode 100644
index 0000000000..2e17175690
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/TestJarMode.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.jarmode;
+
+import java.util.Arrays;
+
+/**
+ * {@link JarMode} for testing.
+ *
+ * @author Phillip Webb
+ */
+class TestJarMode implements JarMode {
+
+ @Override
+ public boolean accepts(String mode) {
+ return "test".equals(mode);
+ }
+
+ @Override
+ public void run(String mode, String[] args) {
+ System.out.println("running in " + mode + " jar mode " + Arrays.asList(args));
+ }
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/package-info.java
new file mode 100644
index 0000000000..2f3b5a74e8
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/jarmode/package-info.java
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Support for launching the JAR using jarmode.
+ *
+ * @see org.springframework.boot.loader.jarmode.JarModeLauncher
+ */
+package org.springframework.boot.loader.jarmode;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/JarLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/JarLauncher.java
new file mode 100644
index 0000000000..5beb8d1096
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/JarLauncher.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.launch;
+
+/**
+ * Repackaged {@link org.springframework.boot.loader.JarLauncher}.
+ *
+ * @author Phillip Webb
+ * @since 3.2.0
+ */
+public final class JarLauncher {
+
+ private JarLauncher() {
+ }
+
+ public static void main(String[] args) throws Exception {
+ org.springframework.boot.loader.JarLauncher.main(args);
+ }
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/PropertiesLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/PropertiesLauncher.java
new file mode 100644
index 0000000000..d80fb0bb71
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/PropertiesLauncher.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.launch;
+
+/**
+ * Repackaged {@link org.springframework.boot.loader.PropertiesLauncher}.
+ *
+ * @author Phillip Webb
+ * @since 3.2.0
+ */
+public final class PropertiesLauncher {
+
+ private PropertiesLauncher() {
+ }
+
+ public static void main(String[] args) throws Exception {
+ org.springframework.boot.loader.PropertiesLauncher.main(args);
+ }
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/WarLauncher.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/WarLauncher.java
new file mode 100644
index 0000000000..9392d3bf2b
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/WarLauncher.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.launch;
+
+/**
+ * Repackaged {@link org.springframework.boot.loader.WarLauncher}.
+ *
+ * @author Phillip Webb
+ * @since 3.2.0
+ */
+public final class WarLauncher {
+
+ private WarLauncher() {
+ }
+
+ public static void main(String[] args) throws Exception {
+ org.springframework.boot.loader.WarLauncher.main(args);
+ }
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/package-info.java
new file mode 100644
index 0000000000..7968d509a2
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/launch/package-info.java
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Repackaged launcher classes.
+ *
+ * @see org.springframework.boot.loader.launch.JarLauncher
+ * @see org.springframework.boot.loader.launch.WarLauncher
+ */
+package org.springframework.boot.loader.launch;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/package-info.java
new file mode 100644
index 0000000000..4b32f644f5
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/package-info.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * System that allows self-contained JAR/WAR archives to be launched using
+ * {@code java -jar}. Archives can include nested packaged dependency JARs (there is no
+ * need to create shade style jars) and are executed without unpacking. The only
+ * constraint is that nested JARs must be stored in the archive uncompressed.
+ *
+ * @see org.springframework.boot.loader.JarLauncher
+ * @see org.springframework.boot.loader.WarLauncher
+ */
+package org.springframework.boot.loader;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/util/SystemPropertyUtils.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/util/SystemPropertyUtils.java
new file mode 100644
index 0000000000..df00705e9e
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/util/SystemPropertyUtils.java
@@ -0,0 +1,232 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader.util;
+
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Properties;
+import java.util.Set;
+
+/**
+ * Helper class for resolving placeholders in texts. Usually applied to file paths.
+ *
+ * A text may contain {@code $ ...} placeholders, to be resolved as system properties:
+ * e.g. {@code $ user.dir}. Default values can be supplied using the ":" separator between
+ * key and value.
+ *
+ * Adapted from Spring.
+ *
+ * @author Juergen Hoeller
+ * @author Rob Harrop
+ * @author Dave Syer
+ * @since 1.0.0
+ * @see System#getProperty(String)
+ */
+public abstract class SystemPropertyUtils {
+
+ /**
+ * Prefix for system property placeholders: "${".
+ */
+ public static final String PLACEHOLDER_PREFIX = "${";
+
+ /**
+ * Suffix for system property placeholders: "}".
+ */
+ public static final String PLACEHOLDER_SUFFIX = "}";
+
+ /**
+ * Value separator for system property placeholders: ":".
+ */
+ public static final String VALUE_SEPARATOR = ":";
+
+ private static final String SIMPLE_PREFIX = PLACEHOLDER_PREFIX.substring(1);
+
+ /**
+ * Resolve ${...} placeholders in the given text, replacing them with corresponding
+ * system property values.
+ * @param text the String to resolve
+ * @return the resolved String
+ * @throws IllegalArgumentException if there is an unresolvable placeholder
+ * @see #PLACEHOLDER_PREFIX
+ * @see #PLACEHOLDER_SUFFIX
+ */
+ public static String resolvePlaceholders(String text) {
+ if (text == null) {
+ return text;
+ }
+ return parseStringValue(null, text, text, new HashSet<>());
+ }
+
+ /**
+ * Resolve ${...} placeholders in the given text, replacing them with corresponding
+ * system property values.
+ * @param properties a properties instance to use in addition to System
+ * @param text the String to resolve
+ * @return the resolved String
+ * @throws IllegalArgumentException if there is an unresolvable placeholder
+ * @see #PLACEHOLDER_PREFIX
+ * @see #PLACEHOLDER_SUFFIX
+ */
+ public static String resolvePlaceholders(Properties properties, String text) {
+ if (text == null) {
+ return text;
+ }
+ return parseStringValue(properties, text, text, new HashSet<>());
+ }
+
+ private static String parseStringValue(Properties properties, String value, String current,
+ Set visitedPlaceholders) {
+
+ StringBuilder buf = new StringBuilder(current);
+
+ int startIndex = current.indexOf(PLACEHOLDER_PREFIX);
+ while (startIndex != -1) {
+ int endIndex = findPlaceholderEndIndex(buf, startIndex);
+ if (endIndex != -1) {
+ String placeholder = buf.substring(startIndex + PLACEHOLDER_PREFIX.length(), endIndex);
+ String originalPlaceholder = placeholder;
+ if (!visitedPlaceholders.add(originalPlaceholder)) {
+ throw new IllegalArgumentException(
+ "Circular placeholder reference '" + originalPlaceholder + "' in property definitions");
+ }
+ // Recursive invocation, parsing placeholders contained in the
+ // placeholder
+ // key.
+ placeholder = parseStringValue(properties, value, placeholder, visitedPlaceholders);
+ // Now obtain the value for the fully resolved key...
+ String propVal = resolvePlaceholder(properties, value, placeholder);
+ if (propVal == null) {
+ int separatorIndex = placeholder.indexOf(VALUE_SEPARATOR);
+ if (separatorIndex != -1) {
+ String actualPlaceholder = placeholder.substring(0, separatorIndex);
+ String defaultValue = placeholder.substring(separatorIndex + VALUE_SEPARATOR.length());
+ propVal = resolvePlaceholder(properties, value, actualPlaceholder);
+ if (propVal == null) {
+ propVal = defaultValue;
+ }
+ }
+ }
+ if (propVal != null) {
+ // Recursive invocation, parsing placeholders contained in the
+ // previously resolved placeholder value.
+ propVal = parseStringValue(properties, value, propVal, visitedPlaceholders);
+ buf.replace(startIndex, endIndex + PLACEHOLDER_SUFFIX.length(), propVal);
+ startIndex = buf.indexOf(PLACEHOLDER_PREFIX, startIndex + propVal.length());
+ }
+ else {
+ // Proceed with unprocessed value.
+ startIndex = buf.indexOf(PLACEHOLDER_PREFIX, endIndex + PLACEHOLDER_SUFFIX.length());
+ }
+ visitedPlaceholders.remove(originalPlaceholder);
+ }
+ else {
+ startIndex = -1;
+ }
+ }
+
+ return buf.toString();
+ }
+
+ private static String resolvePlaceholder(Properties properties, String text, String placeholderName) {
+ String propVal = getProperty(placeholderName, null, text);
+ if (propVal != null) {
+ return propVal;
+ }
+ return (properties != null) ? properties.getProperty(placeholderName) : null;
+ }
+
+ public static String getProperty(String key) {
+ return getProperty(key, null, "");
+ }
+
+ public static String getProperty(String key, String defaultValue) {
+ return getProperty(key, defaultValue, "");
+ }
+
+ /**
+ * Search the System properties and environment variables for a value with the
+ * provided key. Environment variables in {@code UPPER_CASE} style are allowed where
+ * System properties would normally be {@code lower.case}.
+ * @param key the key to resolve
+ * @param defaultValue the default value
+ * @param text optional extra context for an error message if the key resolution fails
+ * (e.g. if System properties are not accessible)
+ * @return a static property value or null of not found
+ */
+ public static String getProperty(String key, String defaultValue, String text) {
+ try {
+ String propVal = System.getProperty(key);
+ if (propVal == null) {
+ // Fall back to searching the system environment.
+ propVal = System.getenv(key);
+ }
+ if (propVal == null) {
+ // Try with underscores.
+ String name = key.replace('.', '_');
+ propVal = System.getenv(name);
+ }
+ if (propVal == null) {
+ // Try uppercase with underscores as well.
+ String name = key.toUpperCase(Locale.ENGLISH).replace('.', '_');
+ propVal = System.getenv(name);
+ }
+ if (propVal != null) {
+ return propVal;
+ }
+ }
+ catch (Throwable ex) {
+ System.err.println("Could not resolve key '" + key + "' in '" + text
+ + "' as system property or in environment: " + ex);
+ }
+ return defaultValue;
+ }
+
+ private static int findPlaceholderEndIndex(CharSequence buf, int startIndex) {
+ int index = startIndex + PLACEHOLDER_PREFIX.length();
+ int withinNestedPlaceholder = 0;
+ while (index < buf.length()) {
+ if (substringMatch(buf, index, PLACEHOLDER_SUFFIX)) {
+ if (withinNestedPlaceholder > 0) {
+ withinNestedPlaceholder--;
+ index = index + PLACEHOLDER_SUFFIX.length();
+ }
+ else {
+ return index;
+ }
+ }
+ else if (substringMatch(buf, index, SIMPLE_PREFIX)) {
+ withinNestedPlaceholder++;
+ index = index + SIMPLE_PREFIX.length();
+ }
+ else {
+ index++;
+ }
+ }
+ return -1;
+ }
+
+ private static boolean substringMatch(CharSequence str, int index, CharSequence substring) {
+ for (int j = 0; j < substring.length(); j++) {
+ int i = index + j;
+ if (i >= str.length() || str.charAt(i) != substring.charAt(j)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/util/package-info.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/util/package-info.java
new file mode 100644
index 0000000000..d3d7eef2d9
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/main/java/org/springframework/boot/loader/util/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Utilities used by Spring Boot's JAR loading.
+ */
+package org.springframework.boot.loader.util;
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/AbstractExecutableArchiveLauncherTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/AbstractExecutableArchiveLauncherTests.java
new file mode 100644
index 0000000000..60e3cb2765
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/AbstractExecutableArchiveLauncherTests.java
@@ -0,0 +1,149 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import java.util.jar.JarOutputStream;
+import java.util.jar.Manifest;
+import java.util.zip.CRC32;
+import java.util.zip.ZipEntry;
+
+import org.junit.jupiter.api.io.TempDir;
+
+import org.springframework.boot.loader.archive.Archive;
+import org.springframework.util.FileCopyUtils;
+
+/**
+ * Base class for testing {@link ExecutableArchiveLauncher} implementations.
+ *
+ * @author Andy Wilkinson
+ * @author Madhura Bhave
+ * @author Scott Frederick
+ */
+public abstract class AbstractExecutableArchiveLauncherTests {
+
+ @TempDir
+ File tempDir;
+
+ protected File createJarArchive(String name, String entryPrefix) throws IOException {
+ return createJarArchive(name, entryPrefix, false, Collections.emptyList());
+ }
+
+ @SuppressWarnings("resource")
+ protected File createJarArchive(String name, String entryPrefix, boolean indexed, List extraLibs)
+ throws IOException {
+ return createJarArchive(name, null, entryPrefix, indexed, extraLibs);
+ }
+
+ @SuppressWarnings("resource")
+ protected File createJarArchive(String name, Manifest manifest, String entryPrefix, boolean indexed,
+ List extraLibs) throws IOException {
+ File archive = new File(this.tempDir, name);
+ JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(archive));
+ if (manifest != null) {
+ jarOutputStream.putNextEntry(new JarEntry("META-INF/"));
+ jarOutputStream.putNextEntry(new JarEntry("META-INF/MANIFEST.MF"));
+ manifest.write(jarOutputStream);
+ jarOutputStream.closeEntry();
+ }
+ jarOutputStream.putNextEntry(new JarEntry(entryPrefix + "/"));
+ jarOutputStream.putNextEntry(new JarEntry(entryPrefix + "/classes/"));
+ jarOutputStream.putNextEntry(new JarEntry(entryPrefix + "/lib/"));
+ if (indexed) {
+ jarOutputStream.putNextEntry(new JarEntry(entryPrefix + "/classpath.idx"));
+ Writer writer = new OutputStreamWriter(jarOutputStream, StandardCharsets.UTF_8);
+ writer.write("- \"" + entryPrefix + "/lib/foo.jar\"\n");
+ writer.write("- \"" + entryPrefix + "/lib/bar.jar\"\n");
+ writer.write("- \"" + entryPrefix + "/lib/baz.jar\"\n");
+ writer.flush();
+ jarOutputStream.closeEntry();
+ }
+ addNestedJars(entryPrefix, "/lib/foo.jar", jarOutputStream);
+ addNestedJars(entryPrefix, "/lib/bar.jar", jarOutputStream);
+ addNestedJars(entryPrefix, "/lib/baz.jar", jarOutputStream);
+ for (String lib : extraLibs) {
+ addNestedJars(entryPrefix, "/lib/" + lib, jarOutputStream);
+ }
+ jarOutputStream.close();
+ return archive;
+ }
+
+ private void addNestedJars(String entryPrefix, String lib, JarOutputStream jarOutputStream) throws IOException {
+ JarEntry libFoo = new JarEntry(entryPrefix + lib);
+ libFoo.setMethod(ZipEntry.STORED);
+ ByteArrayOutputStream fooJarStream = new ByteArrayOutputStream();
+ new JarOutputStream(fooJarStream).close();
+ libFoo.setSize(fooJarStream.size());
+ CRC32 crc32 = new CRC32();
+ crc32.update(fooJarStream.toByteArray());
+ libFoo.setCrc(crc32.getValue());
+ jarOutputStream.putNextEntry(libFoo);
+ jarOutputStream.write(fooJarStream.toByteArray());
+ }
+
+ protected File explode(File archive) throws IOException {
+ File exploded = new File(this.tempDir, "exploded");
+ exploded.mkdirs();
+ JarFile jarFile = new JarFile(archive);
+ Enumeration entries = jarFile.entries();
+ while (entries.hasMoreElements()) {
+ JarEntry entry = entries.nextElement();
+ File entryFile = new File(exploded, entry.getName());
+ if (entry.isDirectory()) {
+ entryFile.mkdirs();
+ }
+ else {
+ FileCopyUtils.copy(jarFile.getInputStream(entry), new FileOutputStream(entryFile));
+ }
+ }
+ jarFile.close();
+ return exploded;
+ }
+
+ protected Set getUrls(List archives) throws MalformedURLException {
+ Set urls = new LinkedHashSet<>(archives.size());
+ for (Archive archive : archives) {
+ urls.add(archive.getUrl());
+ }
+ return urls;
+ }
+
+ protected final URL toUrl(File file) {
+ try {
+ return file.toURI().toURL();
+ }
+ catch (MalformedURLException ex) {
+ throw new IllegalStateException(ex);
+ }
+ }
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/ClassPathIndexFileTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/ClassPathIndexFileTests.java
new file mode 100644
index 0000000000..4cd1b4e8d2
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/ClassPathIndexFileTests.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link ClassPathIndexFile}.
+ *
+ * @author Madhura Bhave
+ * @author Phillip Webb
+ */
+class ClassPathIndexFileTests {
+
+ @TempDir
+ File temp;
+
+ @Test
+ void loadIfPossibleWhenRootIsNotFileReturnsNull() {
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> ClassPathIndexFile.loadIfPossible(new URL("https://example.com/file"), "test.idx"))
+ .withMessage("URL does not reference a file");
+ }
+
+ @Test
+ void loadIfPossibleWhenRootDoesNotExistReturnsNull() throws Exception {
+ File root = new File(this.temp, "missing");
+ assertThat(ClassPathIndexFile.loadIfPossible(root.toURI().toURL(), "test.idx")).isNull();
+ }
+
+ @Test
+ void loadIfPossibleWhenRootIsDirectoryThrowsException() throws Exception {
+ File root = new File(this.temp, "directory");
+ root.mkdirs();
+ assertThat(ClassPathIndexFile.loadIfPossible(root.toURI().toURL(), "test.idx")).isNull();
+ }
+
+ @Test
+ void loadIfPossibleReturnsInstance() throws Exception {
+ ClassPathIndexFile indexFile = copyAndLoadTestIndexFile();
+ assertThat(indexFile).isNotNull();
+ }
+
+ @Test
+ void sizeReturnsNumberOfLines() throws Exception {
+ ClassPathIndexFile indexFile = copyAndLoadTestIndexFile();
+ assertThat(indexFile.size()).isEqualTo(5);
+ }
+
+ @Test
+ void getUrlsReturnsUrls() throws Exception {
+ ClassPathIndexFile indexFile = copyAndLoadTestIndexFile();
+ List urls = indexFile.getUrls();
+ List expected = new ArrayList<>();
+ expected.add(new File(this.temp, "BOOT-INF/layers/one/lib/a.jar"));
+ expected.add(new File(this.temp, "BOOT-INF/layers/one/lib/b.jar"));
+ expected.add(new File(this.temp, "BOOT-INF/layers/one/lib/c.jar"));
+ expected.add(new File(this.temp, "BOOT-INF/layers/two/lib/d.jar"));
+ expected.add(new File(this.temp, "BOOT-INF/layers/two/lib/e.jar"));
+ assertThat(urls).containsExactly(expected.stream().map(this::toUrl).toArray(URL[]::new));
+ }
+
+ private URL toUrl(File file) {
+ try {
+ return file.toURI().toURL();
+ }
+ catch (MalformedURLException ex) {
+ throw new IllegalStateException(ex);
+ }
+ }
+
+ private ClassPathIndexFile copyAndLoadTestIndexFile() throws IOException {
+ copyTestIndexFile();
+ ClassPathIndexFile indexFile = ClassPathIndexFile.loadIfPossible(this.temp.toURI().toURL(), "test.idx");
+ return indexFile;
+ }
+
+ private void copyTestIndexFile() throws IOException {
+ Files.copy(getClass().getResourceAsStream("classpath-index-file.idx"),
+ new File(this.temp, "test.idx").toPath());
+ }
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/JarLauncherTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/JarLauncherTests.java
new file mode 100644
index 0000000000..afa32a7c4f
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/JarLauncherTests.java
@@ -0,0 +1,154 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.jar.Attributes;
+import java.util.jar.Attributes.Name;
+import java.util.jar.Manifest;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.loader.archive.Archive;
+import org.springframework.boot.loader.archive.ExplodedArchive;
+import org.springframework.boot.loader.archive.JarFileArchive;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.core.test.tools.SourceFile;
+import org.springframework.core.test.tools.TestCompiler;
+import org.springframework.util.FileCopyUtils;
+import org.springframework.util.function.ThrowingConsumer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link JarLauncher}.
+ *
+ * @author Andy Wilkinson
+ * @author Madhura Bhave
+ */
+class JarLauncherTests extends AbstractExecutableArchiveLauncherTests {
+
+ @Test
+ void explodedJarHasOnlyBootInfClassesAndContentsOfBootInfLibOnClasspath() throws Exception {
+ File explodedRoot = explode(createJarArchive("archive.jar", "BOOT-INF"));
+ JarLauncher launcher = new JarLauncher(new ExplodedArchive(explodedRoot, true));
+ List archives = new ArrayList<>();
+ launcher.getClassPathArchivesIterator().forEachRemaining(archives::add);
+ assertThat(getUrls(archives)).containsExactlyInAnyOrder(getExpectedFileUrls(explodedRoot));
+ for (Archive archive : archives) {
+ archive.close();
+ }
+ }
+
+ @Test
+ void archivedJarHasOnlyBootInfClassesAndContentsOfBootInfLibOnClasspath() throws Exception {
+ File jarRoot = createJarArchive("archive.jar", "BOOT-INF");
+ try (JarFileArchive archive = new JarFileArchive(jarRoot)) {
+ JarLauncher launcher = new JarLauncher(archive);
+ List classPathArchives = new ArrayList<>();
+ launcher.getClassPathArchivesIterator().forEachRemaining(classPathArchives::add);
+ assertThat(classPathArchives).hasSize(4);
+ assertThat(getUrls(classPathArchives)).containsOnly(
+ new URL("jar:" + jarRoot.toURI().toURL() + "!/BOOT-INF/classes!/"),
+ new URL("jar:" + jarRoot.toURI().toURL() + "!/BOOT-INF/lib/foo.jar!/"),
+ new URL("jar:" + jarRoot.toURI().toURL() + "!/BOOT-INF/lib/bar.jar!/"),
+ new URL("jar:" + jarRoot.toURI().toURL() + "!/BOOT-INF/lib/baz.jar!/"));
+ for (Archive classPathArchive : classPathArchives) {
+ classPathArchive.close();
+ }
+ }
+ }
+
+ @Test
+ void explodedJarShouldPreserveClasspathOrderWhenIndexPresent() throws Exception {
+ File explodedRoot = explode(createJarArchive("archive.jar", "BOOT-INF", true, Collections.emptyList()));
+ JarLauncher launcher = new JarLauncher(new ExplodedArchive(explodedRoot, true));
+ Iterator archives = launcher.getClassPathArchivesIterator();
+ URLClassLoader classLoader = (URLClassLoader) launcher.createClassLoader(archives);
+ URL[] urls = classLoader.getURLs();
+ assertThat(urls).containsExactly(getExpectedFileUrls(explodedRoot));
+ }
+
+ @Test
+ void jarFilesPresentInBootInfLibsAndNotInClasspathIndexShouldBeAddedAfterBootInfClasses() throws Exception {
+ ArrayList extraLibs = new ArrayList<>(Arrays.asList("extra-1.jar", "extra-2.jar"));
+ File explodedRoot = explode(createJarArchive("archive.jar", "BOOT-INF", true, extraLibs));
+ JarLauncher launcher = new JarLauncher(new ExplodedArchive(explodedRoot, true));
+ Iterator archives = launcher.getClassPathArchivesIterator();
+ URLClassLoader classLoader = (URLClassLoader) launcher.createClassLoader(archives);
+ URL[] urls = classLoader.getURLs();
+ List expectedFiles = getExpectedFilesWithExtraLibs(explodedRoot);
+ URL[] expectedFileUrls = expectedFiles.stream().map(this::toUrl).toArray(URL[]::new);
+ assertThat(urls).containsExactly(expectedFileUrls);
+ }
+
+ @Test
+ void explodedJarDefinedPackagesIncludeManifestAttributes() {
+ Manifest manifest = new Manifest();
+ Attributes attributes = manifest.getMainAttributes();
+ attributes.put(Name.MANIFEST_VERSION, "1.0");
+ attributes.put(Name.IMPLEMENTATION_TITLE, "test");
+ SourceFile sourceFile = SourceFile.of("explodedsample/ExampleClass.java",
+ new ClassPathResource("explodedsample/ExampleClass.txt"));
+ TestCompiler.forSystem().compile(sourceFile, ThrowingConsumer.of((compiled) -> {
+ File explodedRoot = explode(
+ createJarArchive("archive.jar", manifest, "BOOT-INF", true, Collections.emptyList()));
+ File target = new File(explodedRoot, "BOOT-INF/classes/explodedsample/ExampleClass.class");
+ target.getParentFile().mkdirs();
+ FileCopyUtils.copy(compiled.getClassLoader().getResourceAsStream("explodedsample/ExampleClass.class"),
+ new FileOutputStream(target));
+ JarLauncher launcher = new JarLauncher(new ExplodedArchive(explodedRoot, true));
+ Iterator archives = launcher.getClassPathArchivesIterator();
+ URLClassLoader classLoader = (URLClassLoader) launcher.createClassLoader(archives);
+ Class> loaded = classLoader.loadClass("explodedsample.ExampleClass");
+ assertThat(loaded.getPackage().getImplementationTitle()).isEqualTo("test");
+ }));
+ }
+
+ protected final URL[] getExpectedFileUrls(File explodedRoot) {
+ return getExpectedFiles(explodedRoot).stream().map(this::toUrl).toArray(URL[]::new);
+ }
+
+ protected final List getExpectedFiles(File parent) {
+ List expected = new ArrayList<>();
+ expected.add(new File(parent, "BOOT-INF/classes"));
+ expected.add(new File(parent, "BOOT-INF/lib/foo.jar"));
+ expected.add(new File(parent, "BOOT-INF/lib/bar.jar"));
+ expected.add(new File(parent, "BOOT-INF/lib/baz.jar"));
+ return expected;
+ }
+
+ protected final List getExpectedFilesWithExtraLibs(File parent) {
+ List expected = new ArrayList<>();
+ expected.add(new File(parent, "BOOT-INF/classes"));
+ expected.add(new File(parent, "BOOT-INF/lib/extra-1.jar"));
+ expected.add(new File(parent, "BOOT-INF/lib/extra-2.jar"));
+ expected.add(new File(parent, "BOOT-INF/lib/foo.jar"));
+ expected.add(new File(parent, "BOOT-INF/lib/bar.jar"));
+ expected.add(new File(parent, "BOOT-INF/lib/baz.jar"));
+ return expected;
+ }
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/LaunchedURLClassLoaderTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/LaunchedURLClassLoaderTests.java
new file mode 100644
index 0000000000..58084bba8a
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/LaunchedURLClassLoaderTests.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader;
+
+import java.io.File;
+import java.io.InputStream;
+import java.net.JarURLConnection;
+import java.net.URL;
+import java.net.URLConnection;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.springframework.boot.loader.jar.JarFile;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link LaunchedURLClassLoader}.
+ *
+ * @author Dave Syer
+ * @author Phillip Webb
+ * @author Andy Wilkinson
+ */
+@SuppressWarnings("resource")
+class LaunchedURLClassLoaderTests {
+
+ @TempDir
+ File tempDir;
+
+ @Test
+ void resolveResourceFromArchive() throws Exception {
+ LaunchedURLClassLoader loader = new LaunchedURLClassLoader(
+ new URL[] { new URL("jar:file:src/test/resources/jars/app.jar!/") }, getClass().getClassLoader());
+ assertThat(loader.getResource("demo/Application.java")).isNotNull();
+ }
+
+ @Test
+ void resolveResourcesFromArchive() throws Exception {
+ LaunchedURLClassLoader loader = new LaunchedURLClassLoader(
+ new URL[] { new URL("jar:file:src/test/resources/jars/app.jar!/") }, getClass().getClassLoader());
+ assertThat(loader.getResources("demo/Application.java").hasMoreElements()).isTrue();
+ }
+
+ @Test
+ void resolveRootPathFromArchive() throws Exception {
+ LaunchedURLClassLoader loader = new LaunchedURLClassLoader(
+ new URL[] { new URL("jar:file:src/test/resources/jars/app.jar!/") }, getClass().getClassLoader());
+ assertThat(loader.getResource("")).isNotNull();
+ }
+
+ @Test
+ void resolveRootResourcesFromArchive() throws Exception {
+ LaunchedURLClassLoader loader = new LaunchedURLClassLoader(
+ new URL[] { new URL("jar:file:src/test/resources/jars/app.jar!/") }, getClass().getClassLoader());
+ assertThat(loader.getResources("").hasMoreElements()).isTrue();
+ }
+
+ @Test
+ void resolveFromNested() throws Exception {
+ File file = new File(this.tempDir, "test.jar");
+ TestJarCreator.createTestJar(file);
+ try (JarFile jarFile = new JarFile(file)) {
+ URL url = jarFile.getUrl();
+ try (LaunchedURLClassLoader loader = new LaunchedURLClassLoader(new URL[] { url }, null)) {
+ URL resource = loader.getResource("nested.jar!/3.dat");
+ assertThat(resource).hasToString(url + "nested.jar!/3.dat");
+ try (InputStream input = resource.openConnection().getInputStream()) {
+ assertThat(input.read()).isEqualTo(3);
+ }
+ }
+ }
+ }
+
+ @Test
+ void resolveFromNestedWhileThreadIsInterrupted() throws Exception {
+ File file = new File(this.tempDir, "test.jar");
+ TestJarCreator.createTestJar(file);
+ try (JarFile jarFile = new JarFile(file)) {
+ URL url = jarFile.getUrl();
+ try (LaunchedURLClassLoader loader = new LaunchedURLClassLoader(new URL[] { url }, null)) {
+ Thread.currentThread().interrupt();
+ URL resource = loader.getResource("nested.jar!/3.dat");
+ assertThat(resource).hasToString(url + "nested.jar!/3.dat");
+ URLConnection connection = resource.openConnection();
+ try (InputStream input = connection.getInputStream()) {
+ assertThat(input.read()).isEqualTo(3);
+ }
+ ((JarURLConnection) connection).getJarFile().close();
+ }
+ finally {
+ Thread.interrupted();
+ }
+ }
+ }
+
+}
diff --git a/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/PropertiesLauncherTests.java b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/PropertiesLauncherTests.java
new file mode 100644
index 0000000000..ab7c296b38
--- /dev/null
+++ b/spring-boot-project/spring-boot-tools/spring-boot-loader-classic/src/test/java/org/springframework/boot/loader/PropertiesLauncherTests.java
@@ -0,0 +1,433 @@
+/*
+ * Copyright 2012-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.boot.loader;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.lang.ref.SoftReference;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.jar.Attributes;
+import java.util.jar.Manifest;
+
+import org.assertj.core.api.Condition;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.springframework.boot.loader.archive.Archive;
+import org.springframework.boot.loader.archive.ExplodedArchive;
+import org.springframework.boot.loader.archive.JarFileArchive;
+import org.springframework.boot.loader.jar.Handler;
+import org.springframework.boot.loader.jar.JarFile;
+import org.springframework.boot.testsupport.system.CapturedOutput;
+import org.springframework.boot.testsupport.system.OutputCaptureExtension;
+import org.springframework.core.io.FileSystemResource;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.util.FileCopyUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
+import static org.hamcrest.Matchers.containsString;
+
+/**
+ * Tests for {@link PropertiesLauncher}.
+ *
+ * @author Dave Syer
+ * @author Andy Wilkinson
+ */
+@ExtendWith(OutputCaptureExtension.class)
+class PropertiesLauncherTests {
+
+ @TempDir
+ File tempDir;
+
+ private PropertiesLauncher launcher;
+
+ private ClassLoader contextClassLoader;
+
+ private CapturedOutput output;
+
+ @BeforeEach
+ void setup(CapturedOutput capturedOutput) throws Exception {
+ this.contextClassLoader = Thread.currentThread().getContextClassLoader();
+ clearHandlerCache();
+ System.setProperty("loader.home", new File("src/test/resources").getAbsolutePath());
+ this.output = capturedOutput;
+ }
+
+ @AfterEach
+ void close() throws Exception {
+ Thread.currentThread().setContextClassLoader(this.contextClassLoader);
+ System.clearProperty("loader.home");
+ System.clearProperty("loader.path");
+ System.clearProperty("loader.main");
+ System.clearProperty("loader.config.name");
+ System.clearProperty("loader.config.location");
+ System.clearProperty("loader.system");
+ System.clearProperty("loader.classLoader");
+ clearHandlerCache();
+ if (this.launcher != null) {
+ this.launcher.close();
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private void clearHandlerCache() throws Exception {
+ Map rootFileCache = ((SoftReference