From 962ca361261a443d61bae0d70a6983647d9da753 Mon Sep 17 00:00:00 2001 From: Hyeongjun Cho Date: Fri, 28 Aug 2026 19:30:44 +0900 Subject: [PATCH] Add jarmode tools command to print the SBOM Add an 'sbom' command to the tools jar mode which prints the SBOM packaged in an uber jar or war. The SBOM is located using the Sbom-Location manifest attribute and its bytes are copied verbatim to the console, or to the file given by --destination. See gh-51505 Signed-off-by: Hyeongjun Cho --- .../antora/modules/how-to/pages/build.adoc | 2 + .../container-images/dockerfiles.adoc | 1 + .../boot/jarmode/tools/SbomCommand.java | 119 +++++++++++++ .../boot/jarmode/tools/ToolsJarMode.java | 2 +- .../boot/jarmode/tools/SbomCommandTests.java | 156 ++++++++++++++++++ .../boot/jarmode/tools/ToolsJarModeTests.java | 6 + .../jar-contents/application.cdx.json | 19 +++ .../tools-error-command-unknown-output.txt | 1 + .../boot/jarmode/tools/tools-help-output.txt | 1 + .../jarmode/tools/tools-help-sbom-output.txt | 7 + .../tools-help-unknown-command-output.txt | 1 + 11 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 loader/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/SbomCommand.java create mode 100644 loader/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/SbomCommandTests.java create mode 100644 loader/spring-boot-jarmode-tools/src/test/resources/jar-contents/application.cdx.json create mode 100644 loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-help-sbom-output.txt diff --git a/documentation/spring-boot-docs/src/docs/antora/modules/how-to/pages/build.adoc b/documentation/spring-boot-docs/src/docs/antora/modules/how-to/pages/build.adoc index 1dfb1b307b9..b0d66ce1fa8 100644 --- a/documentation/spring-boot-docs/src/docs/antora/modules/how-to/pages/build.adoc +++ b/documentation/spring-boot-docs/src/docs/antora/modules/how-to/pages/build.adoc @@ -116,6 +116,8 @@ plugins { } ---- +TIP: To print the SBOM packaged in an uber jar or war, run `java -Djarmode=tools -jar my-app.jar sbom`. + [[howto.build.customize-dependency-versions]] diff --git a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/packaging/container-images/dockerfiles.adoc b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/packaging/container-images/dockerfiles.adoc index e188df664c6..8a276833da3 100644 --- a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/packaging/container-images/dockerfiles.adoc +++ b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/packaging/container-images/dockerfiles.adoc @@ -22,6 +22,7 @@ Usage: Available commands: extract Extract the contents from the jar list-layers List layers from the jar that can be extracted + sbom Print the SBOM from the jar help Help about any command ---- diff --git a/loader/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/SbomCommand.java b/loader/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/SbomCommand.java new file mode 100644 index 00000000000..8f6e55beea7 --- /dev/null +++ b/loader/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/SbomCommand.java @@ -0,0 +1,119 @@ +/* + * 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.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PrintStream; +import java.io.UncheckedIOException; +import java.util.List; +import java.util.Map; +import java.util.jar.JarFile; +import java.util.jar.Manifest; +import java.util.zip.ZipEntry; + +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.loader.jarmode.JarModeErrorException; +import org.springframework.util.StreamUtils; + +/** + * The {@code 'sbom'} tools command. + * + * @author Hyeongjun Cho + */ +class SbomCommand extends Command { + + private static final Option DESTINATION_OPTION = Option.of("destination", "string", + "File to write the SBOM to. Defaults to printing the SBOM to the console"); + + private static final String SBOM_LOCATION_ATTRIBUTE = "Sbom-Location"; + + private final Context context; + + SbomCommand(Context context) { + super("sbom", "Print the SBOM from the jar", Options.of(DESTINATION_OPTION), Parameters.none()); + this.context = context; + } + + @Override + void run(PrintStream out, Map options, List parameters) { + try (JarFile jarFile = new JarFile(this.context.getArchiveFile())) { + String location = getSbomLocation(jarFile); + ZipEntry entry = jarFile.getEntry(location); + if (entry == null || entry.isDirectory()) { + throw new JarModeErrorException( + "SBOM '%s' declared in the manifest was not found in the jar".formatted(location)); + } + try (InputStream in = jarFile.getInputStream(entry)) { + writeSbom(in, out, options); + } + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + private String getSbomLocation(JarFile jarFile) throws IOException { + Manifest manifest = jarFile.getManifest(); + if (manifest != null) { + String location = manifest.getMainAttributes().getValue(SBOM_LOCATION_ATTRIBUTE); + if (location != null) { + return location; + } + } + throw new JarModeErrorException( + "No SBOM found in the jar; the manifest has no '%s' attribute".formatted(SBOM_LOCATION_ATTRIBUTE)); + } + + private void writeSbom(InputStream in, PrintStream out, Map options) throws IOException { + String destination = options.get(DESTINATION_OPTION); + if (destination == null) { + StreamUtils.copy(in, out); + if (out.checkError()) { + throw new JarModeErrorException("Failed to write the SBOM to the console"); + } + return; + } + File file = getDestinationFile(destination); + if (file.isDirectory()) { + throw new JarModeErrorException(file.getAbsoluteFile() + " already exists and is a directory"); + } + mkdirs(file.getParentFile()); + try (OutputStream fileOut = new FileOutputStream(file)) { + StreamUtils.copy(in, fileOut); + } + } + + private File getDestinationFile(String destination) { + File file = new File(destination); + if (file.isAbsolute()) { + return file; + } + return new File(this.context.getWorkingDir(), file.getPath()); + } + + private static void mkdirs(@Nullable File file) throws IOException { + if (file != null && !file.exists() && !file.mkdirs()) { + throw new IOException("Unable to create directory " + file); + } + } + +} diff --git a/loader/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/ToolsJarMode.java b/loader/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/ToolsJarMode.java index c9cfe51fcca..85f0f35c26f 100644 --- a/loader/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/ToolsJarMode.java +++ b/loader/spring-boot-jarmode-tools/src/main/java/org/springframework/boot/jarmode/tools/ToolsJarMode.java @@ -65,7 +65,7 @@ public class ToolsJarMode implements JarMode { } static List getCommands(Context context) { - return List.of(new ExtractCommand(context), new ListLayersCommand(context)); + return List.of(new ExtractCommand(context), new ListLayersCommand(context), new SbomCommand(context)); } } diff --git a/loader/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/SbomCommandTests.java b/loader/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/SbomCommandTests.java new file mode 100644 index 00000000000..acb019072e2 --- /dev/null +++ b/loader/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/SbomCommandTests.java @@ -0,0 +1,156 @@ +/* + * 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.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PrintStream; +import java.nio.file.Files; +import java.util.ArrayDeque; +import java.util.jar.Manifest; + +import org.junit.jupiter.api.Test; + +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 SbomCommand}. + * + * @author Hyeongjun Cho + */ +class SbomCommandTests extends AbstractJarModeTests { + + private static final String SBOM_LOCATION = "META-INF/sbom/application.cdx.json"; + + private static final String SBOM_RESOURCE = "/jar-contents/application.cdx.json"; + + @Test + void shouldPrintSbom() throws IOException { + TestPrintStream out = run(createDefaultArchive()); + assertThat(out).hasSameContentAsResource(SBOM_RESOURCE); + } + + @Test + void shouldPrintSbomWhenLocationIsUnderWebInfClasses() throws IOException { + Manifest manifest = createManifest("Sbom-Location: WEB-INF/classes/META-INF/sbom/application.cdx.json"); + File archive = createArchive(manifest, "WEB-INF/classes/META-INF/sbom/application.cdx.json", SBOM_RESOURCE); + TestPrintStream out = run(archive); + assertThat(out).hasSameContentAsResource(SBOM_RESOURCE); + } + + @Test + void shouldFailWhenSbomLocationIsMissing() { + assertThatExceptionOfType(JarModeErrorException.class).isThrownBy(() -> run(createArchive())) + .withMessage("No SBOM found in the jar; the manifest has no 'Sbom-Location' attribute"); + } + + @Test + void shouldFailWhenSbomEntryIsMissing() throws IOException { + Manifest manifest = createManifest("Sbom-Location: " + SBOM_LOCATION); + File archive = createArchive(manifest); + assertThatExceptionOfType(JarModeErrorException.class).isThrownBy(() -> run(archive)) + .withMessage("SBOM 'META-INF/sbom/application.cdx.json' declared in the manifest was not found in the jar"); + } + + @Test + void shouldFailWhenSbomLocationIsADirectory() throws IOException { + Manifest manifest = createManifest("Sbom-Location: META-INF/sbom"); + File archive = createArchive(manifest, "META-INF/sbom/", "/jar-contents/empty-file"); + assertThatExceptionOfType(JarModeErrorException.class).isThrownBy(() -> run(archive)) + .withMessage("SBOM 'META-INF/sbom' declared in the manifest was not found in the jar"); + } + + @Test + void shouldFailWhenConsoleWriteFails() throws IOException { + SbomCommand command = new SbomCommand(new Context(createDefaultArchive(), this.tempDir)); + try (PrintStream out = new PrintStream(new FailingOutputStream())) { + assertThatExceptionOfType(JarModeErrorException.class) + .isThrownBy(() -> command.run(out, new ArrayDeque<>())) + .withMessage("Failed to write the SBOM to the console"); + } + } + + @Test + void shouldWriteSbomToDestination() throws IOException { + TestPrintStream out = run(createDefaultArchive(), "--destination", "application.cdx.json"); + File destination = new File(this.tempDir, "application.cdx.json"); + assertThat(destination).hasBinaryContent(getResourceContent(SBOM_RESOURCE)); + assertThat(out.toString()).isEmpty(); + } + + @Test + void shouldWriteSbomToAbsoluteDestination() throws IOException { + File destination = new File(this.tempDir, "absolute.cdx.json"); + run(createDefaultArchive(), "--destination", destination.getAbsolutePath()); + assertThat(destination).hasBinaryContent(getResourceContent(SBOM_RESOURCE)); + } + + @Test + void shouldCreateMissingDestinationParentDirectories() throws IOException { + run(createDefaultArchive(), "--destination", "reports/sbom/application.cdx.json"); + File destination = new File(this.tempDir, "reports/sbom/application.cdx.json"); + assertThat(destination).hasBinaryContent(getResourceContent(SBOM_RESOURCE)); + } + + @Test + void shouldOverwriteExistingDestination() throws IOException { + File destination = new File(this.tempDir, "application.cdx.json"); + Files.writeString(destination.toPath(), "stale content"); + run(createDefaultArchive(), "--destination", "application.cdx.json"); + assertThat(destination).hasBinaryContent(getResourceContent(SBOM_RESOURCE)); + } + + @Test + void shouldFailWhenDestinationIsADirectory() throws IOException { + File archive = createDefaultArchive(); + File destination = new File(this.tempDir, "output"); + assertThat(destination.mkdirs()).isTrue(); + assertThatExceptionOfType(JarModeErrorException.class).isThrownBy(() -> run(archive, "--destination", "output")) + .withMessage(destination.getAbsoluteFile() + " already exists and is a directory"); + } + + private File createDefaultArchive() throws IOException { + Manifest manifest = createManifest("Sbom-Location: " + SBOM_LOCATION); + return createArchive(manifest, SBOM_LOCATION, SBOM_RESOURCE); + } + + private byte[] getResourceContent(String resource) throws IOException { + try (InputStream stream = getClass().getResourceAsStream(resource)) { + assertThat(stream).as("Resource '%s'", resource).isNotNull(); + return stream.readAllBytes(); + } + } + + private TestPrintStream run(File archive, String... arguments) { + return runCommand(SbomCommand::new, archive, arguments); + } + + static final class FailingOutputStream extends OutputStream { + + @Override + public void write(int b) throws IOException { + throw new IOException("Write failed"); + } + + } + +} diff --git a/loader/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/ToolsJarModeTests.java b/loader/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/ToolsJarModeTests.java index fc4c401fefe..31d8e00d8f0 100644 --- a/loader/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/ToolsJarModeTests.java +++ b/loader/spring-boot-jarmode-tools/src/test/java/org/springframework/boot/jarmode/tools/ToolsJarModeTests.java @@ -69,6 +69,12 @@ class ToolsJarModeTests extends AbstractJarModeTests { assertThat(this.out).hasSameContentAsResource("tools-help-list-layers-output.txt"); } + @Test + void helpForSbom() { + run("help", "sbom"); + assertThat(this.out).hasSameContentAsResource("tools-help-sbom-output.txt"); + } + @Test void helpForHelp() { run("help", "help"); diff --git a/loader/spring-boot-jarmode-tools/src/test/resources/jar-contents/application.cdx.json b/loader/spring-boot-jarmode-tools/src/test/resources/jar-contents/application.cdx.json new file mode 100644 index 00000000000..30b7f4cf5de --- /dev/null +++ b/loader/spring-boot-jarmode-tools/src/test/resources/jar-contents/application.cdx.json @@ -0,0 +1,19 @@ +{ + "bomFormat" : "CycloneDX", + "specVersion" : "1.6", + "serialNumber" : "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", + "version" : 1, + "metadata" : { + "component" : { + "type" : "application", + "name" : "test-application", + "version" : "1.0.0" + } + }, + "components" : [ { + "type" : "library", + "name" : "dependency-1", + "version" : "1.0.0", + "purl" : "pkg:maven/com.example/dependency-1@1.0.0" + } ] +} diff --git a/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-error-command-unknown-output.txt b/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-error-command-unknown-output.txt index 069368999c6..e1e824bb2b0 100644 --- a/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-error-command-unknown-output.txt +++ b/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-error-command-unknown-output.txt @@ -6,4 +6,5 @@ Usage: Available commands: extract Extract the contents from the jar list-layers List layers from the jar that can be extracted + sbom Print the SBOM from the jar help Help about any command diff --git a/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-help-output.txt b/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-help-output.txt index 8d29d30374b..3c942b60109 100644 --- a/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-help-output.txt +++ b/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-help-output.txt @@ -4,4 +4,5 @@ Usage: Available commands: extract Extract the contents from the jar list-layers List layers from the jar that can be extracted + sbom Print the SBOM from the jar help Help about any command diff --git a/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-help-sbom-output.txt b/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-help-sbom-output.txt new file mode 100644 index 00000000000..2d7922bd6cc --- /dev/null +++ b/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-help-sbom-output.txt @@ -0,0 +1,7 @@ +Print the SBOM from the jar + +Usage: + java -Djarmode=tools -jar test.jar sbom [options] + +Options: + --destination string File to write the SBOM to. Defaults to printing the SBOM to the console diff --git a/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-help-unknown-command-output.txt b/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-help-unknown-command-output.txt index f31247aea6c..08bd2f86ca2 100644 --- a/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-help-unknown-command-output.txt +++ b/loader/spring-boot-jarmode-tools/src/test/resources/org/springframework/boot/jarmode/tools/tools-help-unknown-command-output.txt @@ -6,4 +6,5 @@ Usage: Available commands: extract Extract the contents from the jar list-layers List layers from the jar that can be extracted + sbom Print the SBOM from the jar help Help about any command