Merge pull request #51372 from skdas20

Closes gh-51372

* pr/51372:
  Polish "Support provided-scope libraries when extracting an executable war"
  Support provided-scope libraries when extracting an executable war
This commit is contained in:
Moritz Halbritter
2026-10-08 16:06:29 +02:00
22 changed files with 396 additions and 30 deletions
@@ -86,14 +86,17 @@ class BootArchiveSupport {
}
void configureManifest(Manifest manifest, String mainClass, String classes, String lib,
@Nullable String classPathIndex, @Nullable String layersIndex, String jdkVersion,
String implementationTitle, @Nullable Object implementationVersion) {
@Nullable String libProvided, @Nullable String classPathIndex, @Nullable String layersIndex,
String jdkVersion, String implementationTitle, @Nullable Object implementationVersion) {
Attributes attributes = manifest.getAttributes();
attributes.putIfAbsent("Main-Class", this.loaderMainClass);
attributes.putIfAbsent("Start-Class", mainClass);
attributes.computeIfAbsent("Spring-Boot-Version", (name) -> determineSpringBootVersion());
attributes.putIfAbsent("Spring-Boot-Classes", classes);
attributes.putIfAbsent("Spring-Boot-Lib", lib);
if (libProvided != null) {
attributes.putIfAbsent("Spring-Boot-Lib-Provided", libProvided);
}
if (classPathIndex != null) {
attributes.putIfAbsent("Spring-Boot-Classpath-Index", classPathIndex);
}
@@ -131,7 +131,7 @@ public abstract class BootJar extends Jar implements BootArchive {
@Override
public void copy() {
this.support.configureManifest(getManifest(), getMainClass().get(), CLASSES_DIRECTORY, LIB_DIRECTORY,
this.support.configureManifest(getManifest(), getMainClass().get(), CLASSES_DIRECTORY, LIB_DIRECTORY, null,
CLASSPATH_INDEX, (isLayeredDisabled()) ? null : LAYERS_INDEX,
this.getTargetJavaVersion().get().getMajorVersion(), this.projectName.get(), this.projectVersion.get());
super.copy();
@@ -106,7 +106,7 @@ public abstract class BootWar extends War implements BootArchive {
@Override
public void copy() {
this.support.configureManifest(getManifest(), getMainClass().get(), CLASSES_DIRECTORY, LIB_DIRECTORY,
CLASSPATH_INDEX, (isLayeredDisabled()) ? null : LAYERS_INDEX,
LIB_PROVIDED_DIRECTORY, CLASSPATH_INDEX, (isLayeredDisabled()) ? null : LAYERS_INDEX,
this.getTargetJavaVersion().get().getMajorVersion(), this.projectName.get(), this.projectVersion.get());
super.copy();
}
@@ -55,6 +55,7 @@ import org.gradle.api.internal.file.archive.ZipEntryConstants;
import org.gradle.api.tasks.bundling.AbstractArchiveTask;
import org.gradle.api.tasks.bundling.Jar;
import org.gradle.internal.component.external.model.ModuleComponentArtifactIdentifier;
import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -88,6 +89,8 @@ abstract class AbstractBootArchiveTests<T extends Jar & BootArchive> {
private final String libPath;
private final @Nullable String libProvidedPath;
private final String classesPath;
private final String indexPath;
@@ -96,11 +99,12 @@ abstract class AbstractBootArchiveTests<T extends Jar & BootArchive> {
private T task;
protected AbstractBootArchiveTests(Class<T> taskClass, String launcherClass, String libPath, String classesPath,
String indexPath) {
protected AbstractBootArchiveTests(Class<T> taskClass, String launcherClass, String libPath,
@Nullable String libProvidedPath, String classesPath, String indexPath) {
this.taskClass = taskClass;
this.launcherClass = launcherClass;
this.libPath = libPath;
this.libProvidedPath = libProvidedPath;
this.classesPath = classesPath;
this.indexPath = indexPath;
}
@@ -124,6 +128,8 @@ abstract class AbstractBootArchiveTests<T extends Jar & BootArchive> {
assertThat(jarFile.getManifest().getMainAttributes().getValue("Spring-Boot-Classes"))
.isEqualTo(this.classesPath);
assertThat(jarFile.getManifest().getMainAttributes().getValue("Spring-Boot-Lib")).isEqualTo(this.libPath);
assertThat(jarFile.getManifest().getMainAttributes().getValue("Spring-Boot-Lib-Provided"))
.isEqualTo(this.libProvidedPath);
assertThat(jarFile.getManifest().getMainAttributes().getValue("Spring-Boot-Version")).isNotNull();
assertThat(jarFile.getManifest().getMainAttributes().getValue("Implementation-Title"))
.isEqualTo(this.project.getName());
@@ -41,8 +41,8 @@ import static org.assertj.core.api.Assertions.assertThat;
class BootJarTests extends AbstractBootArchiveTests<BootJar> {
BootJarTests() {
super(BootJar.class, "org.springframework.boot.loader.launch.JarLauncher", "BOOT-INF/lib/", "BOOT-INF/classes/",
"BOOT-INF/");
super(BootJar.class, "org.springframework.boot.loader.launch.JarLauncher", "BOOT-INF/lib/", null,
"BOOT-INF/classes/", "BOOT-INF/");
}
@BeforeEach
@@ -39,8 +39,8 @@ import static org.assertj.core.api.Assertions.assertThat;
class BootWarTests extends AbstractBootArchiveTests<BootWar> {
BootWarTests() {
super(BootWar.class, "org.springframework.boot.loader.launch.WarLauncher", "WEB-INF/lib/", "WEB-INF/classes/",
"WEB-INF/");
super(BootWar.class, "org.springframework.boot.loader.launch.WarLauncher", "WEB-INF/lib/",
"WEB-INF/lib-provided/", "WEB-INF/classes/", "WEB-INF/");
}
@BeforeEach
@@ -33,6 +33,10 @@ $ java -jar my-app/my-app.jar
After startup, you should not expect any differences in execution time between running an executable jar and running an extracted jar.
NOTE: An executable war can be extracted in the same way.
The result is a jar, for example `my-app/my-app.jar`, that is meant to be run with `java -jar`.
Content from the root of the war, such as static files and JSPs from `src/main/webapp`, is moved to `META-INF/resources/` in the extracted jar.
TIP: Run `java -Djarmode=tools -jar my-app.jar help extract` to see all possible options.
@@ -0,0 +1,45 @@
/*
* Copyright 2012-present 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.jarmode.tools;
import java.io.File;
import java.util.Locale;
/**
* The type of archive that is being processed.
*
* @author Moritz Halbritter
*/
enum ArchiveType {
/**
* A jar archive.
*/
JAR,
/**
* A war archive.
*/
WAR;
private static final String WAR_EXTENSION = ".war";
static ArchiveType of(File file) {
return (file.getName().toLowerCase(Locale.ROOT).endsWith(WAR_EXTENSION)) ? WAR : JAR;
}
}
@@ -87,7 +87,9 @@ class ExtractCommand extends Command {
"Name of the libraries directory. Only applicable when not using --launcher. Defaults to lib/");
private static final Option APPLICATION_FILENAME_OPTION = Option.of("application-filename", "string",
"Name of the application JAR file. Only applicable when not using --launcher. Defaults to the uber JAR filename");
"Name of the application JAR file. Only applicable when not using --launcher. Defaults to the uber JAR filename, with the extension replaced by .jar");
private static final String META_INF_DIRECTORY = "META-INF/";
private final Context context;
@@ -248,18 +250,25 @@ class ExtractCommand extends Command {
Manifest manifest = jarStructure.createLauncherManifest((library) -> librariesDirectory + library);
mkdirs(file.getParentFile());
try (JarOutputStream output = new JarOutputStream(new FileOutputStream(file))) {
Set<String> writtenEntries = new HashSet<>();
ManifestWriter manifestWriter = (sourceJarFile) -> {
JarEntry entry = createJarEntry(JarFile.MANIFEST_NAME,
sourceJarFile.getJarEntry(JarFile.MANIFEST_NAME));
output.putNextEntry(createJarEntry(META_INF_DIRECTORY, entry));
output.closeEntry();
writtenEntries.add(META_INF_DIRECTORY);
output.putNextEntry(entry);
manifest.write(new BufferedOutputStream(output));
output.closeEntry();
};
EnumSet<Type> allowedTypes = EnumSet.of(Type.APPLICATION_CLASS_OR_RESOURCE, Type.META_INF);
Set<String> writtenEntries = new HashSet<>();
EnumSet<Type> allowedTypes = EnumSet.of(Type.APPLICATION_CLASS_OR_RESOURCE, Type.META_INF,
Type.WEB_RESOURCE);
withJarEntries(this.context.getArchiveFile(), manifestWriter, ((stream, jarEntry) -> {
Entry entry = jarStructure.resolve(jarEntry);
if (entry != null && allowedTypes.contains(entry.type()) && StringUtils.hasLength(entry.location())) {
if (entry.type() == Type.WEB_RESOURCE) {
writeParentDirectories(output, writtenEntries, entry.location(), jarEntry);
}
JarEntry newJarEntry = createJarEntry(entry.location(), jarEntry);
if (writtenEntries.add(newJarEntry.getName())) {
output.putNextEntry(newJarEntry);
@@ -268,7 +277,7 @@ class ExtractCommand extends Command {
}
else {
if (!newJarEntry.isDirectory()) {
throw new IllegalStateException("Duplicate jar entry '%s' from original location '%s'"
throw new JarModeErrorException("Duplicate jar entry '%s' from original location '%s'"
.formatted(newJarEntry.getName(), entry.originalLocation()));
}
}
@@ -278,6 +287,19 @@ class ExtractCommand extends Command {
copyTimestamps(this.context.getArchiveFile(), file);
}
private void writeParentDirectories(JarOutputStream output, Set<String> writtenEntries, String location,
JarEntry originalEntry) throws IOException {
int index = location.indexOf('/');
while (index != -1 && index < location.length() - 1) {
String directory = location.substring(0, index + 1);
if (writtenEntries.add(directory)) {
output.putNextEntry(createJarEntry(directory, originalEntry));
output.closeEntry();
}
index = location.indexOf('/', index + 1);
}
}
private void copyTimestamps(File source, File destination) throws IOException {
BasicFileAttributes sourceAttributes = Files.getFileAttributeView(source.toPath(), BasicFileAttributeView.class)
.readAttributes();
@@ -291,7 +313,11 @@ class ExtractCommand extends Command {
if (value != null) {
return value;
}
return this.context.getArchiveFile().getName();
File archiveFile = this.context.getArchiveFile();
if (ArchiveType.of(archiveFile) == ArchiveType.WAR) {
return stripExtension(archiveFile.getName()) + ".jar";
}
return archiveFile.getName();
}
private static void extractEntry(InputStream stream, JarEntry entry, File file) throws IOException {
@@ -22,8 +22,10 @@ import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.NoSuchFileException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.UnaryOperator;
import java.util.jar.Attributes;
@@ -31,11 +33,13 @@ import java.util.jar.Attributes.Name;
import java.util.jar.JarFile;
import java.util.jar.Manifest;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import org.jspecify.annotations.Nullable;
import org.springframework.boot.jarmode.tools.JarStructure.Entry.Type;
import org.springframework.boot.loader.jarmode.JarModeErrorException;
import org.springframework.util.Assert;
import org.springframework.util.StreamUtils;
import org.springframework.util.StringUtils;
@@ -49,24 +53,61 @@ import org.springframework.util.StringUtils;
class IndexedJarStructure implements JarStructure {
private static final List<String> MANIFEST_DENY_LIST = List.of("Start-Class", "Spring-Boot-Classes",
"Spring-Boot-Lib", "Spring-Boot-Classpath-Index", "Spring-Boot-Layers-Index");
"Spring-Boot-Lib", "Spring-Boot-Lib-Provided", "Spring-Boot-Classpath-Index", "Spring-Boot-Layers-Index");
private static final Set<String> ENTRY_IGNORE_LIST = Set.of("META-INF/", "META-INF/MANIFEST.MF",
"META-INF/services/java.nio.file.spi.FileSystemProvider");
private static final String LOADER_LOCATION = "org/springframework/boot/loader/";
private static final String WEB_RESOURCES_LOCATION = "META-INF/resources/";
private final Manifest originalManifest;
private final String libLocation;
private final @Nullable String providedLibLocation;
private final String classesLocation;
private final Set<String> classpathEntries;
IndexedJarStructure(Manifest originalManifest, String indexFile) {
private final Set<String> indexLocations;
private final ArchiveType archiveType;
IndexedJarStructure(Manifest originalManifest, String indexFile, ArchiveType archiveType) {
this.originalManifest = originalManifest;
this.libLocation = getLocation(originalManifest, "Spring-Boot-Lib");
this.providedLibLocation = getOptionalLocation(originalManifest, "Spring-Boot-Lib-Provided");
this.classesLocation = getLocation(originalManifest, "Spring-Boot-Classes");
this.classpathEntries = readIndexFile(indexFile);
this.indexLocations = getIndexLocations(originalManifest);
this.archiveType = archiveType;
checkForDuplicateLibraryNames();
}
private void checkForDuplicateLibraryNames() {
Map<String, String> libraries = new HashMap<>();
for (String classpathEntry : this.classpathEntries) {
String name = stripLibraryLocation(classpathEntry);
if (name == null) {
continue;
}
String existing = libraries.putIfAbsent(name, classpathEntry);
if (existing != null) {
throw new JarModeErrorException(
"Library name '%s' is used by both '%s' and '%s'".formatted(name, existing, classpathEntry));
}
}
}
private static Set<String> getIndexLocations(Manifest manifest) {
Attributes attributes = manifest.getMainAttributes();
return Stream
.of(attributes.getValue("Spring-Boot-Classpath-Index"), attributes.getValue("Spring-Boot-Layers-Index"))
.filter(StringUtils::hasLength)
.collect(Collectors.toSet());
}
private static String getLocation(Manifest manifest, String attribute) {
@@ -74,6 +115,14 @@ class IndexedJarStructure implements JarStructure {
return (!location.endsWith("/")) ? location + "/" : location;
}
private static @Nullable String getOptionalLocation(Manifest manifest, String attribute) {
String location = manifest.getMainAttributes().getValue(attribute);
if (!StringUtils.hasLength(location)) {
return null;
}
return (!location.endsWith("/")) ? location + "/" : location;
}
private static Set<String> readIndexFile(String indexFile) {
String[] lines = Arrays.stream(indexFile.split("\n"))
.map((line) -> line.replace("\r", ""))
@@ -110,7 +159,24 @@ class IndexedJarStructure implements JarStructure {
if (name.startsWith("META-INF/")) {
return new Entry(name, name, Type.META_INF);
}
return null;
return resolveWebResource(name);
}
private @Nullable Entry resolveWebResource(String name) {
if (this.archiveType != ArchiveType.WAR || isWarPackagingEntry(name)) {
return null;
}
return new Entry(name, WEB_RESOURCES_LOCATION + name, Type.WEB_RESOURCE);
}
private boolean isWarPackagingEntry(String name) {
if (this.indexLocations.contains(name) || name.startsWith(this.libLocation)) {
return true;
}
if (this.providedLibLocation != null && name.startsWith(this.providedLibLocation)) {
return true;
}
return name.endsWith("/") && LOADER_LOCATION.startsWith(name);
}
@Override
@@ -130,8 +196,19 @@ class IndexedJarStructure implements JarStructure {
}
private String toStructureDependency(String libEntryName) {
Assert.state(libEntryName.startsWith(this.libLocation), () -> "Invalid library location " + libEntryName);
return libEntryName.substring(this.libLocation.length());
String name = stripLibraryLocation(libEntryName);
Assert.state(name != null, () -> "Invalid library location " + libEntryName);
return name;
}
private @Nullable String stripLibraryLocation(String libEntryName) {
if (libEntryName.startsWith(this.libLocation)) {
return libEntryName.substring(this.libLocation.length());
}
if (this.providedLibLocation != null && libEntryName.startsWith(this.providedLibLocation)) {
return libEntryName.substring(this.providedLibLocation.length());
}
return null;
}
private static String getMandatoryAttribute(Manifest manifest, String attribute) {
@@ -148,7 +225,7 @@ class IndexedJarStructure implements JarStructure {
ZipEntry entry = jarFile.getEntry(location);
if (entry != null) {
String indexFile = StreamUtils.copyToString(jarFile.getInputStream(entry), StandardCharsets.UTF_8);
return new IndexedJarStructure(manifest, indexFile);
return new IndexedJarStructure(manifest, indexFile, ArchiveType.of(file));
}
}
return null;
@@ -73,7 +73,7 @@ interface JarStructure {
enum Type {
LIBRARY, APPLICATION_CLASS_OR_RESOURCE, LOADER, META_INF
LIBRARY, APPLICATION_CLASS_OR_RESOURCE, LOADER, META_INF, WEB_RESOURCE
}
@@ -79,8 +79,14 @@ abstract class AbstractJarModeTests {
File createArchive(Manifest manifest, @Nullable Instant creationTime, @Nullable Instant lastModifiedTime,
@Nullable Instant lastAccessTime, String... entries) throws IOException {
return createArchive("test.jar", manifest, creationTime, lastModifiedTime, lastAccessTime, entries);
}
File createArchive(String filename, Manifest manifest, @Nullable Instant creationTime,
@Nullable Instant lastModifiedTime, @Nullable Instant lastAccessTime, String... entries)
throws IOException {
Assert.state(entries.length % 2 == 0, "Entries must be key value pairs");
File file = new File(this.tempDir, "test.jar");
File file = new File(this.tempDir, filename);
try (JarOutputStream jar = new JarOutputStream(new FileOutputStream(file))) {
ZipEntry manifestEntry = createEntry(JarFile.MANIFEST_NAME, creationTime, lastModifiedTime, lastAccessTime);
jar.putNextEntry(manifestEntry);
@@ -136,6 +136,12 @@ class ExtractCommandTests extends AbstractJarModeTests {
"Spring-Boot-Classpath-Index", "Spring-Boot-Layers-Index");
}
@Test
void shouldWriteMetaInfDirectoryBeforeManifest() throws IOException {
run(ExtractCommandTests.this.archive);
assertThat(getJarEntryNames(file("test/test.jar"))).startsWith("META-INF/", "META-INF/MANIFEST.MF");
}
@Test
void applicationContainsApplicationClassesAndResources() throws IOException {
run(ExtractCommandTests.this.archive);
@@ -260,7 +266,8 @@ class ExtractCommandTests extends AbstractJarModeTests {
run(file);
File application = file("test/test.jar");
List<String> entryNames = getJarEntryNames(application);
assertThat(entryNames).containsExactlyInAnyOrder("META-INF/native-image/", "META-INF/MANIFEST.MF");
assertThat(entryNames).containsExactlyInAnyOrder("META-INF/", "META-INF/native-image/",
"META-INF/MANIFEST.MF");
}
@Test
@@ -269,13 +276,86 @@ class ExtractCommandTests extends AbstractJarModeTests {
"/jar-contents/classpath.idx", "META-INF/native-image/native-image.properties",
"/jar-contents/empty-file", "BOOT-INF/classes/META-INF/native-image/native-image.properties",
"/jar-contents/empty-file");
assertThatIllegalStateException().isThrownBy(() -> run(file))
assertThatExceptionOfType(JarModeErrorException.class).isThrownBy(() -> run(file))
.withMessage(
"Duplicate jar entry 'META-INF/native-image/native-image.properties' from original location 'BOOT-INF/classes/META-INF/native-image/native-image.properties'");
}
}
@Nested
class ExtractWar {
private Manifest manifest;
private File war;
@BeforeEach
void setUp() throws IOException {
this.manifest = createManifest("Spring-Boot-Classpath-Index: WEB-INF/classpath.idx",
"Spring-Boot-Lib: WEB-INF/lib/", "Spring-Boot-Lib-Provided: WEB-INF/lib-provided/",
"Spring-Boot-Classes: WEB-INF/classes/", "Start-Class: org.example.Main",
"Spring-Boot-Layers-Index: WEB-INF/layers.idx");
this.war = createArchive("test.war", this.manifest, null, null, null, "WEB-INF/classpath.idx",
"/jar-contents/war-classpath.idx", "WEB-INF/layers.idx", "/jar-contents/war-layers.idx",
"WEB-INF/lib/dependency-1.jar", "/jar-contents/dependency-1",
"WEB-INF/lib-provided/dependency-2.jar", "/jar-contents/dependency-2",
"org/springframework/boot/loader/launch/WarLauncher.class", "/jar-contents/JarLauncher",
"WEB-INF/classes/application.properties", "/jar-contents/application.properties", "index.html",
"/jar-contents/empty-file", "WEB-INF/jsp/", "/jar-contents/empty-file", "WEB-INF/jsp/hello.jsp",
"/jar-contents/empty-file");
}
@Test
void shouldCreateApplicationJar() throws IOException {
run(this.war);
assertThat(listFilenames())
.contains("test/test.jar", "test/lib/dependency-1.jar", "test/lib/dependency-2.jar")
.doesNotContain("test/test.war");
}
@Test
void shouldMoveWebResourcesToMetaInfResources() throws IOException {
run(this.war);
assertThat(getJarEntryNames(file("test/test.jar"))).containsExactlyInAnyOrder("META-INF/MANIFEST.MF",
"application.properties", "META-INF/", "META-INF/resources/", "META-INF/resources/index.html",
"META-INF/resources/WEB-INF/", "META-INF/resources/WEB-INF/jsp/",
"META-INF/resources/WEB-INF/jsp/hello.jsp");
}
@Test
void shouldMoveWebResourcesToMetaInfResourcesWhenUsingLayers() throws IOException {
run(this.war, "--layers");
assertThat(listFilenames()).contains("test/dependencies/lib/dependency-1.jar",
"test/dependencies/lib/dependency-2.jar");
assertThat(getJarEntryNames(file("test/application/test.jar"))).contains("META-INF/resources/index.html",
"META-INF/resources/WEB-INF/jsp/hello.jsp");
}
@Test
void shouldFailWhenWebResourceCollidesWithClassesResource() throws IOException {
File war = createArchive("test.war", this.manifest, null, null, null, "WEB-INF/classpath.idx",
"/jar-contents/war-classpath.idx", "WEB-INF/lib/dependency-1.jar", "/jar-contents/dependency-1",
"index.html", "/jar-contents/empty-file", "WEB-INF/classes/META-INF/resources/index.html",
"/jar-contents/empty-file");
assertThatExceptionOfType(JarModeErrorException.class).isThrownBy(() -> run(war))
.withMessage("Duplicate jar entry 'META-INF/resources/index.html' from original location "
+ "'WEB-INF/classes/META-INF/resources/index.html'");
}
@Test
void shouldFailWhenLibraryAndProvidedLibraryHaveSameName() throws IOException {
File war = createArchive("test.war", this.manifest, null, null, null, "WEB-INF/classpath.idx",
"/jar-contents/war-duplicate-classpath.idx", "WEB-INF/lib/dependency-1.jar",
"/jar-contents/dependency-1", "WEB-INF/lib-provided/dependency-1.jar",
"/jar-contents/dependency-1");
assertThatExceptionOfType(JarModeErrorException.class).isThrownBy(() -> run(war))
.withMessage("Library name 'dependency-1.jar' is used by both 'WEB-INF/lib/dependency-1.jar' and "
+ "'WEB-INF/lib-provided/dependency-1.jar'");
}
}
@Nested
class ExtractWithLayers {
@@ -33,8 +33,10 @@ import org.junit.jupiter.api.io.TempDir;
import org.springframework.boot.jarmode.tools.JarStructure.Entry;
import org.springframework.boot.jarmode.tools.JarStructure.Entry.Type;
import org.springframework.boot.loader.jarmode.JarModeErrorException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
/**
* Tests for {@link IndexedJarStructure}.
@@ -43,6 +45,23 @@ import static org.assertj.core.api.Assertions.assertThat;
*/
class IndexedJarStructureTests {
private static final String WAR_MANIFEST = """
Manifest-Version: 1.0
Main-Class: org.springframework.boot.loader.launch.WarLauncher
Start-Class: org.springframework.boot.jarmode.tools.IndexedJarStructureTests
Spring-Boot-Version: 3.3.0-SNAPSHOT
Spring-Boot-Classes: WEB-INF/classes/
Spring-Boot-Lib: WEB-INF/lib/
Spring-Boot-Lib-Provided: WEB-INF/lib-provided/
Spring-Boot-Classpath-Index: WEB-INF/classpath.idx
Spring-Boot-Layers-Index: WEB-INF/layers.idx
""";
private static final String WAR_INDEX_FILE = """
- "WEB-INF/lib/spring-webmvc-6.1.4.jar"
- "WEB-INF/lib-provided/tomcat-embed-core-10.1.19.jar"
""";
@Test
void shouldResolveLibraryEntry() throws IOException {
IndexedJarStructure structure = createStructure();
@@ -53,6 +72,58 @@ class IndexedJarStructureTests {
assertThat(entry.type()).isEqualTo(Type.LIBRARY);
}
@Test
void shouldResolveLibraryEntryFromWarProvidedLocation() throws IOException {
IndexedJarStructure structure = createWarStructure();
Entry entry = structure.resolve("WEB-INF/lib-provided/tomcat-embed-core-10.1.19.jar");
assertThat(entry).isNotNull();
assertThat(entry.location()).isEqualTo("tomcat-embed-core-10.1.19.jar");
assertThat(entry.originalLocation()).isEqualTo("WEB-INF/lib-provided/tomcat-embed-core-10.1.19.jar");
assertThat(entry.type()).isEqualTo(Type.LIBRARY);
}
@Test
void shouldCreateLauncherManifestForWarWithProvidedLibraries() throws IOException {
IndexedJarStructure structure = createWarStructure();
Manifest manifest = structure.createLauncherManifest(UnaryOperator.identity());
assertThat(getAttributes(manifest))
.containsEntry("Class-Path", "spring-webmvc-6.1.4.jar tomcat-embed-core-10.1.19.jar")
.doesNotContainKey("Spring-Boot-Lib-Provided");
}
@Test
void shouldResolveWarRootEntryAsWebResource() throws IOException {
IndexedJarStructure structure = createWarStructure();
Entry entry = structure.resolve("WEB-INF/jsp/hello.jsp");
assertThat(entry).isNotNull();
assertThat(entry.location()).isEqualTo("META-INF/resources/WEB-INF/jsp/hello.jsp");
assertThat(entry.originalLocation()).isEqualTo("WEB-INF/jsp/hello.jsp");
assertThat(entry.type()).isEqualTo(Type.WEB_RESOURCE);
}
@Test
void shouldNotResolveWarIndexFilesAsWebResources() throws IOException {
IndexedJarStructure structure = createWarStructure();
assertThat(structure.resolve("WEB-INF/classpath.idx")).isNull();
assertThat(structure.resolve("WEB-INF/layers.idx")).isNull();
}
@Test
void shouldNotResolveWarLibraryOrLoaderDirectoriesAsWebResources() throws IOException {
IndexedJarStructure structure = createWarStructure();
assertThat(structure.resolve("WEB-INF/lib/")).isNull();
assertThat(structure.resolve("WEB-INF/lib/doesnt-exist.jar")).isNull();
assertThat(structure.resolve("WEB-INF/lib-provided/")).isNull();
assertThat(structure.resolve("org/")).isNull();
assertThat(structure.resolve("org/springframework/boot/")).isNull();
}
@Test
void shouldNotResolveJarRootEntry() throws IOException {
IndexedJarStructure structure = createStructure();
assertThat(structure.resolve("index.html")).isNull();
}
@Test
void shouldResolveApplicationEntry() throws IOException {
IndexedJarStructure structure = createStructure();
@@ -113,6 +184,17 @@ class IndexedJarStructureTests {
.isEqualTo(Type.APPLICATION_CLASS_OR_RESOURCE);
}
@Test
void shouldFailWhenLibraryAndProvidedLibraryHaveSameName() {
String indexFile = """
- "WEB-INF/lib/utils-1.0.jar"
- "WEB-INF/lib-provided/utils-1.0.jar"
""";
assertThatExceptionOfType(JarModeErrorException.class).isThrownBy(() -> createWarStructure(indexFile))
.withMessage("Library name 'utils-1.0.jar' is used by both 'WEB-INF/lib/utils-1.0.jar' and "
+ "'WEB-INF/lib-provided/utils-1.0.jar'");
}
private Map<String, String> getAttributes(Manifest manifest) {
Map<String, String> result = new HashMap<>();
manifest.getMainAttributes().forEach((key, value) -> result.put(key.toString(), value.toString()));
@@ -120,7 +202,16 @@ class IndexedJarStructureTests {
}
private IndexedJarStructure createStructure() throws IOException {
return new IndexedJarStructure(createManifest(), createIndexFile());
return new IndexedJarStructure(createManifest(), createIndexFile(), ArchiveType.JAR);
}
private IndexedJarStructure createWarStructure() throws IOException {
return createWarStructure(WAR_INDEX_FILE);
}
private IndexedJarStructure createWarStructure(String indexFile) throws IOException {
Manifest manifest = new Manifest(new ByteArrayInputStream(WAR_MANIFEST.getBytes(StandardCharsets.UTF_8)));
return new IndexedJarStructure(manifest, indexFile, ArchiveType.WAR);
}
private String createIndexFile() {
@@ -0,0 +1,2 @@
- "WEB-INF/lib/dependency-1.jar"
- "WEB-INF/lib-provided/dependency-2.jar"
@@ -0,0 +1,2 @@
- "WEB-INF/lib/dependency-1.jar"
- "WEB-INF/lib-provided/dependency-1.jar"
@@ -0,0 +1,12 @@
- "dependencies":
- "WEB-INF/lib/dependency-1.jar"
- "WEB-INF/lib-provided/"
- "spring-boot-loader":
- "org/"
- "application":
- "META-INF/"
- "WEB-INF/classes/"
- "WEB-INF/classpath.idx"
- "WEB-INF/jsp/"
- "WEB-INF/layers.idx"
- "index.html"
@@ -10,5 +10,5 @@ Options:
--layers string list Layers to extract
--destination string Directory to extract files to. Defaults to a directory named after the uber JAR (without the file extension)
--libraries string Name of the libraries directory. Only applicable when not using --launcher. Defaults to lib/
--application-filename string Name of the application JAR file. Only applicable when not using --launcher. Defaults to the uber JAR filename
--application-filename string Name of the application JAR file. Only applicable when not using --launcher. Defaults to the uber JAR filename, with the extension replaced by .jar
--force Whether to ignore non-empty directories, extract anyway
@@ -10,5 +10,5 @@ Options:
--layers string list Layers to extract
--destination string Directory to extract files to. Defaults to a directory named after the uber JAR (without the file extension)
--libraries string Name of the libraries directory. Only applicable when not using --launcher. Defaults to lib/
--application-filename string Name of the application JAR file. Only applicable when not using --launcher. Defaults to the uber JAR filename
--application-filename string Name of the application JAR file. Only applicable when not using --launcher. Defaults to the uber JAR filename, with the extension replaced by .jar
--force Whether to ignore non-empty directories, extract anyway
@@ -8,5 +8,5 @@ Options:
--layers string list Layers to extract
--destination string Directory to extract files to. Defaults to a directory named after the uber JAR (without the file extension)
--libraries string Name of the libraries directory. Only applicable when not using --launcher. Defaults to lib/
--application-filename string Name of the application JAR file. Only applicable when not using --launcher. Defaults to the uber JAR filename
--application-filename string Name of the application JAR file. Only applicable when not using --launcher. Defaults to the uber JAR filename, with the extension replaced by .jar
--force Whether to ignore non-empty directories, extract anyway
@@ -45,6 +45,7 @@ import org.springframework.boot.loader.tools.AbstractJarWriter.EntryTransformer;
import org.springframework.boot.loader.tools.AbstractJarWriter.UnpackHandler;
import org.springframework.core.io.support.SpringFactoriesLoader;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
/**
@@ -73,6 +74,8 @@ public abstract class Packager {
private static final String BOOT_LIB_ATTRIBUTE = "Spring-Boot-Lib";
private static final String BOOT_LIB_PROVIDED_ATTRIBUTE = "Spring-Boot-Lib-Provided";
private static final String BOOT_CLASSPATH_INDEX_ATTRIBUTE = "Spring-Boot-Classpath-Index";
private static final String BOOT_LAYERS_INDEX_ATTRIBUTE = "Spring-Boot-Layers-Index";
@@ -413,7 +416,12 @@ public abstract class Packager {
else {
attributes.putValue(BOOT_CLASSES_ATTRIBUTE, layout.getClassesLocation());
}
putIfHasLength(attributes, BOOT_LIB_ATTRIBUTE, getLayout().getLibraryLocation("", LibraryScope.COMPILE));
String libraryLocation = layout.getLibraryLocation("", LibraryScope.COMPILE);
putIfHasLength(attributes, BOOT_LIB_ATTRIBUTE, libraryLocation);
String providedLibraryLocation = layout.getLibraryLocation("", LibraryScope.PROVIDED);
if (!ObjectUtils.nullSafeEquals(providedLibraryLocation, libraryLocation)) {
putIfHasLength(attributes, BOOT_LIB_PROVIDED_ATTRIBUTE, providedLibraryLocation);
}
putIfHasLength(attributes, BOOT_CLASSPATH_INDEX_ATTRIBUTE, layout.getClasspathIndexFileLocation());
if (isLayered()) {
putIfHasLength(attributes, BOOT_LAYERS_INDEX_ATTRIBUTE, layout.getLayersIndexFileLocation());
@@ -396,6 +396,8 @@ abstract class AbstractPackagerTests<P extends Packager> {
assertThat(actualManifest).isNotNull();
assertThat(actualManifest.getMainAttributes()).containsEntry(new Attributes.Name("Spring-Boot-Lib"),
"BOOT-INF/lib/");
assertThat(actualManifest.getMainAttributes())
.doesNotContainKey(new Attributes.Name("Spring-Boot-Lib-Provided"));
assertThat(actualManifest.getMainAttributes()).containsEntry(new Attributes.Name("Spring-Boot-Classes"),
"BOOT-INF/classes/");
}
@@ -409,6 +411,8 @@ abstract class AbstractPackagerTests<P extends Packager> {
assertThat(actualManifest).isNotNull();
assertThat(actualManifest.getMainAttributes()).containsEntry(new Attributes.Name("Spring-Boot-Lib"),
"WEB-INF/lib/");
assertThat(actualManifest.getMainAttributes()).containsEntry(new Attributes.Name("Spring-Boot-Lib-Provided"),
"WEB-INF/lib-provided/");
assertThat(actualManifest.getMainAttributes()).containsEntry(new Attributes.Name("Spring-Boot-Classes"),
"WEB-INF/classes/");
}