Add jarmode tools command to print / export the SBOM
Project: spring-projects/spring-boot | Language: Java | Concepts: cli-design, error-handling, testing-strategy | Difficulty: mid
Link: https://github.com/spring-projects/spring-boot/pull/51505
The problem
Spring Boot packages a Software Bill of Materials (SBOM) inside its executable jars/wars, but there was no built-in way to extract or print the SBOM without running the application and calling the actuator endpoint. Users needed a CLI tool to print or export the packaged SBOM directly from the archive.
Why it's non-trivial
A naive solution might just look for a hardcoded SBOM path or assume the SBOM is always present and readable. In reality, the SBOM location is specified in the jar's manifest and can vary (especially for wars). The implementation must handle missing manifests, missing or directory entries, and provide clear error reporting for CLI use cases, including proper handling of output destinations and error codes.
The approach
1. Add the new sbom command to the CLI:
-static List<Command> getCommands(Context context) {
- return List.of(new ExtractCommand(context), new ListLayersCommand(context));
+static List<Command> getCommands(Context context) {
+ return List.of(new ExtractCommand(context), new ListLayersCommand(context), new SbomCommand(context));
}
This registers the new sbom command alongside existing commands, making it available in the CLI.
2. Implement the SbomCommand class:
+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";
+ // ...
+ @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);
+ }
+ }
+ // ...
+}
This is the core command logic: it opens the jar, reads the manifest for the Sbom-Location attribute, checks for the entry, and streams the SBOM to the requested output. If anything is missing or incorrect, it throws a descriptive error.
3. Handle output destination and errors:
+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);
+ }
+}
This code ensures that if a destination file is specified, the SBOM is written there, creating parent directories as needed. It also checks for error conditions, such as writing to a directory or failing to write to the console.
4. Add comprehensive tests for error and success cases:
+@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 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();
+}
These tests verify that the command fails with clear messages when the manifest or SBOM is missing, and correctly writes the SBOM to a specified file.
5. Update documentation and CLI help output:
+TIP: To print the SBOM packaged in an uber jar or war, run `java -Djarmode=tools -jar my-app.jar sbom`.
This change makes the new feature discoverable in the official documentation.
From the review thread
mhalbritter:
That would be very welcome. Please target4.0.xas the branch, we'll do that as a bugfix. Besides that, I did a review, and it looks good to merge. Please move the commit which fixes
JarModeErrorExceptioninto its own PR, then I can merge them both.
xxxxxxjun:
Done – split out as #51540 against4.0.x.
The test there useslist-layersrather thansbom, since that branch doesn't have the new command. This PR now only addssbom.
Takeaways
- When designing CLI tools, always read configuration (like file locations) from authoritative sources (e.g., manifest attributes) rather than hardcoding paths.
- Robust error handling is essential for CLI commands: check for missing files, directories, and failed writes, and provide clear, actionable error messages.
- Comprehensive tests should cover both success and all anticipated failure modes, including edge cases like missing manifests, missing entries, and file system errors.