Merge pull request #51505 from xxxxxxjun

Closes gh-51505

* pr/51505:
  Polish "Add jarmode tools command to print the SBOM"
  Add jarmode tools command to print the SBOM
This commit is contained in:
Moritz Halbritter
2026-09-02 16:54:57 +02:00
11 changed files with 334 additions and 1 deletions
@@ -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]]
@@ -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
----
@@ -0,0 +1,120 @@
/*
* 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<Option, @Nullable String> options, List<String> 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) {
throw new JarModeErrorException("No manifest found in the jar");
}
String location = manifest.getMainAttributes().getValue(SBOM_LOCATION_ATTRIBUTE);
if (location == null) {
throw new JarModeErrorException(
"No SBOM found in the jar; the manifest has no '%s' attribute".formatted(SBOM_LOCATION_ATTRIBUTE));
}
return location;
}
private void writeSbom(InputStream in, PrintStream out, Map<Option, @Nullable String> 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);
}
}
}
@@ -65,7 +65,7 @@ public class ToolsJarMode implements JarMode {
}
static List<Command> getCommands(Context context) {
return List.of(new ExtractCommand(context), new ListLayersCommand(context));
return List.of(new ExtractCommand(context), new ListLayersCommand(context), new SbomCommand(context));
}
}
@@ -0,0 +1,175 @@
/*
* 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.nio.file.Files;
import java.util.ArrayDeque;
import java.util.jar.Manifest;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
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 shouldFailWhenManifestIsMissing() throws IOException {
File archive = createArchiveWithoutManifest();
assertThatExceptionOfType(JarModeErrorException.class).isThrownBy(() -> run(archive))
.withMessage("No manifest found in the jar");
}
@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 File createArchiveWithoutManifest() throws IOException {
File file = new File(this.tempDir, "no-manifest.jar");
try (ZipOutputStream zip = new ZipOutputStream(new FileOutputStream(file))) {
zip.putNextEntry(new ZipEntry("some-file.txt"));
zip.closeEntry();
}
return file;
}
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");
}
}
}
@@ -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");
@@ -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"
} ]
}
@@ -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
@@ -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
@@ -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
@@ -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