diff --git a/build-plugin/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/AbstractBootArchiveTests.java b/build-plugin/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/AbstractBootArchiveTests.java index ee7a73ecf1e..e8c1e8b9f87 100644 --- a/build-plugin/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/AbstractBootArchiveTests.java +++ b/build-plugin/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/AbstractBootArchiveTests.java @@ -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 { private final String libPath; + private final @Nullable String libProvidedPath; + private final String classesPath; private final String indexPath; @@ -96,11 +99,12 @@ abstract class AbstractBootArchiveTests { private T task; - protected AbstractBootArchiveTests(Class taskClass, String launcherClass, String libPath, String classesPath, - String indexPath) { + protected AbstractBootArchiveTests(Class 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 { 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()); diff --git a/build-plugin/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootJarTests.java b/build-plugin/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootJarTests.java index b24cc53c91a..e6a49a4ed56 100644 --- a/build-plugin/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootJarTests.java +++ b/build-plugin/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootJarTests.java @@ -41,8 +41,8 @@ import static org.assertj.core.api.Assertions.assertThat; class BootJarTests extends AbstractBootArchiveTests { 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 diff --git a/build-plugin/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootWarTests.java b/build-plugin/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootWarTests.java index b140a3d00cd..a3eaa1b7abf 100644 --- a/build-plugin/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootWarTests.java +++ b/build-plugin/spring-boot-gradle-plugin/src/test/java/org/springframework/boot/gradle/tasks/bundling/BootWarTests.java @@ -39,8 +39,8 @@ import static org.assertj.core.api.Assertions.assertThat; class BootWarTests extends AbstractBootArchiveTests { 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 diff --git a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/packaging/efficient.adoc b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/packaging/efficient.adoc index 6fb6ade576d..c0cfc2f4bca 100644 --- a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/packaging/efficient.adoc +++ b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/packaging/efficient.adoc @@ -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. diff --git a/loader/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/ArchiveType.java b/loader/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/ArchiveType.java new file mode 100644 index 00000000000..25d803a62b8 --- /dev/null +++ b/loader/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/ArchiveType.java @@ -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; + } + +} diff --git a/loader/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/ExtractCommand.java b/loader/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/ExtractCommand.java index 75695e8bf54..3aab1ddc992 100644 --- a/loader/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/ExtractCommand.java +++ b/loader/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/ExtractCommand.java @@ -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 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 allowedTypes = EnumSet.of(Type.APPLICATION_CLASS_OR_RESOURCE, Type.META_INF); - Set writtenEntries = new HashSet<>(); + EnumSet 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 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 { diff --git a/loader/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/IndexedJarStructure.java b/loader/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/IndexedJarStructure.java index 3943c7a8bf8..fe2bc4ceb2f 100644 --- a/loader/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/IndexedJarStructure.java +++ b/loader/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/IndexedJarStructure.java @@ -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; @@ -54,6 +58,10 @@ class IndexedJarStructure implements JarStructure { private static final Set 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; @@ -64,12 +72,42 @@ class IndexedJarStructure implements JarStructure { private final Set classpathEntries; - IndexedJarStructure(Manifest originalManifest, String indexFile) { + private final Set 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 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 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) { @@ -121,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 @@ -141,13 +196,19 @@ class IndexedJarStructure implements JarStructure { } private String toStructureDependency(String libEntryName) { + 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()); } - throw new IllegalStateException("Invalid library location " + libEntryName); + return null; } private static String getMandatoryAttribute(Manifest manifest, String attribute) { @@ -164,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; diff --git a/loader/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/JarStructure.java b/loader/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/JarStructure.java index 1a9455059d6..8e672e3a319 100644 --- a/loader/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/JarStructure.java +++ b/loader/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/JarStructure.java @@ -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 } diff --git a/loader/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/AbstractJarModeTests.java b/loader/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/AbstractJarModeTests.java index abb15769a4e..5c8e222cdaa 100644 --- a/loader/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/AbstractJarModeTests.java +++ b/loader/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/AbstractJarModeTests.java @@ -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); diff --git a/loader/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/ExtractCommandTests.java b/loader/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/ExtractCommandTests.java index 911318f9e69..24c17616e28 100644 --- a/loader/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/ExtractCommandTests.java +++ b/loader/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/ExtractCommandTests.java @@ -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 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 { diff --git a/loader/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/IndexedJarStructureTests.java b/loader/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/IndexedJarStructureTests.java index 7f48232280d..86d881b1483 100644 --- a/loader/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/IndexedJarStructureTests.java +++ b/loader/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/IndexedJarStructureTests.java @@ -33,9 +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.assertThatIllegalStateException; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; /** * Tests for {@link IndexedJarStructure}. @@ -44,6 +45,23 @@ import static org.assertj.core.api.Assertions.assertThatIllegalStateException; */ 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(); @@ -68,8 +86,42 @@ class IndexedJarStructureTests { 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"); + 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 @@ -132,66 +184,34 @@ 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 getAttributes(Manifest manifest) { Map result = new HashMap<>(); manifest.getMainAttributes().forEach((key, value) -> result.put(key.toString(), value.toString())); return result; } - @Test - void shouldFailToResolveProvidedLibraryWhenAttributeIsMissing() throws IOException { - // an archive whose index references lib-provided but whose manifest does not - // record the location has nothing to flatten the entry against - IndexedJarStructure structure = createWarStructureWithoutProvidedLibAttribute(); - assertThatIllegalStateException() - .isThrownBy(() -> structure.resolve("WEB-INF/lib-provided/tomcat-embed-core-10.1.19.jar")) - .withMessageContaining("Invalid library location"); - } - - @Test - void launcherManifestShouldNotContainProvidedLibAttribute() throws IOException { - IndexedJarStructure structure = createWarStructure(); - Manifest manifest = structure.createLauncherManifest(UnaryOperator.identity()); - assertThat(getAttributes(manifest)).doesNotContainKey("Spring-Boot-Lib-Provided"); - } - private IndexedJarStructure createStructure() throws IOException { - return new IndexedJarStructure(createManifest(), createIndexFile()); + return new IndexedJarStructure(createManifest(), createIndexFile(), ArchiveType.JAR); } private IndexedJarStructure createWarStructure() throws IOException { - Manifest manifest = new Manifest(new ByteArrayInputStream(""" - 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 - """.getBytes(StandardCharsets.UTF_8))); - String indexFile = """ - - "WEB-INF/lib/spring-webmvc-6.1.4.jar" - - "WEB-INF/lib-provided/tomcat-embed-core-10.1.19.jar" - """; - return new IndexedJarStructure(manifest, indexFile); + return createWarStructure(WAR_INDEX_FILE); } - private IndexedJarStructure createWarStructureWithoutProvidedLibAttribute() throws IOException { - Manifest manifest = new Manifest(new ByteArrayInputStream(""" - 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-Classpath-Index: WEB-INF/classpath.idx - """.getBytes(StandardCharsets.UTF_8))); - String indexFile = """ - - "WEB-INF/lib/spring-webmvc-6.1.4.jar" - - "WEB-INF/lib-provided/tomcat-embed-core-10.1.19.jar" - """; - return new IndexedJarStructure(manifest, indexFile); + 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() { diff --git a/loader/spring-boot-jarmode-tools/src/test/resources/jar-contents/war-classpath.idx b/loader/spring-boot-jarmode-tools/src/test/resources/jar-contents/war-classpath.idx new file mode 100644 index 00000000000..950bfa10a33 --- /dev/null +++ b/loader/spring-boot-jarmode-tools/src/test/resources/jar-contents/war-classpath.idx @@ -0,0 +1,2 @@ +- "WEB-INF/lib/dependency-1.jar" +- "WEB-INF/lib-provided/dependency-2.jar" diff --git a/loader/spring-boot-jarmode-tools/src/test/resources/jar-contents/war-duplicate-classpath.idx b/loader/spring-boot-jarmode-tools/src/test/resources/jar-contents/war-duplicate-classpath.idx new file mode 100644 index 00000000000..8c27ef2dd59 --- /dev/null +++ b/loader/spring-boot-jarmode-tools/src/test/resources/jar-contents/war-duplicate-classpath.idx @@ -0,0 +1,2 @@ +- "WEB-INF/lib/dependency-1.jar" +- "WEB-INF/lib-provided/dependency-1.jar" diff --git a/loader/spring-boot-jarmode-tools/src/test/resources/jar-contents/war-layers.idx b/loader/spring-boot-jarmode-tools/src/test/resources/jar-contents/war-layers.idx new file mode 100644 index 00000000000..5a35c668ac0 --- /dev/null +++ b/loader/spring-boot-jarmode-tools/src/test/resources/jar-contents/war-layers.idx @@ -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" diff --git a/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-error-option-missing-value-output.txt b/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-error-option-missing-value-output.txt index 50b8dce05ae..84428afe5b2 100644 --- a/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-error-option-missing-value-output.txt +++ b/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-error-option-missing-value-output.txt @@ -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 diff --git a/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-error-option-unknown-output.txt b/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-error-option-unknown-output.txt index 820fd12ec44..7cfde34bf65 100644 --- a/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-error-option-unknown-output.txt +++ b/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-error-option-unknown-output.txt @@ -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 diff --git a/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-help-extract-output.txt b/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-help-extract-output.txt index b8c0582cee0..84b8222c377 100644 --- a/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-help-extract-output.txt +++ b/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-help-extract-output.txt @@ -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 diff --git a/loader/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Packager.java b/loader/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Packager.java index a1489cce1cd..9f72c7a861d 100644 --- a/loader/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Packager.java +++ b/loader/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/Packager.java @@ -416,9 +416,9 @@ public abstract class Packager { else { attributes.putValue(BOOT_CLASSES_ATTRIBUTE, layout.getClassesLocation()); } - String libraryLocation = getLayout().getLibraryLocation("", LibraryScope.COMPILE); + String libraryLocation = layout.getLibraryLocation("", LibraryScope.COMPILE); putIfHasLength(attributes, BOOT_LIB_ATTRIBUTE, libraryLocation); - String providedLibraryLocation = getLayout().getLibraryLocation("", LibraryScope.PROVIDED); + String providedLibraryLocation = layout.getLibraryLocation("", LibraryScope.PROVIDED); if (!ObjectUtils.nullSafeEquals(providedLibraryLocation, libraryLocation)) { putIfHasLength(attributes, BOOT_LIB_PROVIDED_ATTRIBUTE, providedLibraryLocation); } diff --git a/loader/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/AbstractPackagerTests.java b/loader/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/AbstractPackagerTests.java index 7a431f30909..997ade77dde 100644 --- a/loader/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/AbstractPackagerTests.java +++ b/loader/spring-boot-loader-tools/src/test/java/org/springframework/boot/loader/tools/AbstractPackagerTests.java @@ -396,6 +396,8 @@ abstract class AbstractPackagerTests

{ 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

{ 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/"); }