mirror of
https://github.com/spring-projects/spring-boot.git
synced 2026-09-17 12:09:16 +00:00
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:
@@ -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]]
|
||||
|
||||
+1
@@ -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
|
||||
----
|
||||
|
||||
|
||||
+120
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+1
-1
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+175
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+6
@@ -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"
|
||||
} ]
|
||||
}
|
||||
+1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+7
@@ -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
|
||||
+1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user