diff --git a/common/utils/src/main/java/org/graalvm/buildtools/utils/NativeImageUtils.java b/common/utils/src/main/java/org/graalvm/buildtools/utils/NativeImageUtils.java index 71d235e25..e6d2db8b3 100644 --- a/common/utils/src/main/java/org/graalvm/buildtools/utils/NativeImageUtils.java +++ b/common/utils/src/main/java/org/graalvm/buildtools/utils/NativeImageUtils.java @@ -56,6 +56,7 @@ import static org.graalvm.buildtools.utils.SharedConstants.GRAALVM_EXE_EXTENSION; public class NativeImageUtils { + public static final String ORACLE_GRAALVM_IDENTIFIER = "Oracle GraalVM"; private static final Pattern requiredVersionPattern = Pattern.compile("^([0-9]+)(?:\\.([0-9]+)?)?(?:\\.([0-9]+)?)?$"); diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8acbf4948..d69e721c1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,8 @@ groovy = "3.0.11" jetty = "11.0.11" plexusUtils = "4.0.0" plexusXml = "4.0.2" +cyclonedxMaven = "2.8.1" +pluginExecutorMaven = "2.4.0" [libraries] # Local projects @@ -61,3 +63,6 @@ jetty-server = { module = "org.eclipse.jetty:jetty-server", version.ref = "jetty plexus-utils = { module = "org.codehaus.plexus:plexus-utils", version.ref = "plexusUtils" } plexus-xml = { module = "org.codehaus.plexus:plexus-xml", version.ref = "plexusXml" } + +cyclonedx-maven-plugin = { module = "org.cyclonedx:cyclonedx-maven-plugin", version.ref="cyclonedxMaven" } +plugin-executor-maven = { module = "org.twdata.maven:mojo-executor", version.ref="pluginExecutorMaven" } \ No newline at end of file diff --git a/native-maven-plugin/build.gradle.kts b/native-maven-plugin/build.gradle.kts index dfe02041e..63cc0996e 100644 --- a/native-maven-plugin/build.gradle.kts +++ b/native-maven-plugin/build.gradle.kts @@ -65,6 +65,8 @@ dependencies { implementation(libs.jvmReachabilityMetadata) implementation(libs.plexus.utils) implementation(libs.plexus.xml) + implementation(libs.cyclonedx.maven.plugin) + implementation(libs.plugin.executor.maven) compileOnly(libs.maven.pluginApi) compileOnly(libs.maven.core) @@ -178,3 +180,4 @@ tasks.withType().configureEach { // generated code exclude("**/RuntimeMetadata*") } + diff --git a/native-maven-plugin/src/functionalTest/groovy/org/graalvm/buildtools/maven/SBOMFunctionalTest.groovy b/native-maven-plugin/src/functionalTest/groovy/org/graalvm/buildtools/maven/SBOMFunctionalTest.groovy new file mode 100644 index 000000000..0805b7930 --- /dev/null +++ b/native-maven-plugin/src/functionalTest/groovy/org/graalvm/buildtools/maven/SBOMFunctionalTest.groovy @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or + * data (collectively the "Software"), free of charge and under any and all + * copyright rights in the Software, and any and all patent rights owned or + * freely licensable by each licensor hereunder covering either (i) the + * unmodified Software as contributed to or provided by such licensor, or (ii) + * the Larger Works (as defined below), to deal in both + * + * (a) the Software, and + * + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * + * The above copyright notice and either this complete permission notice or at a + * minimum a reference to the UPL must be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.graalvm.buildtools.maven + +import com.fasterxml.jackson.databind.node.ObjectNode +import org.graalvm.buildtools.maven.sbom.SBOMGenerator +import org.graalvm.buildtools.utils.NativeImageUtils +import spock.lang.Requires +import com.fasterxml.jackson.databind.ObjectMapper + +class SBOMFunctionalTest extends AbstractGraalVMMavenFunctionalTest { + private static boolean EE() { + NativeCompileNoForkMojo.isOracleGraalVM(null) + } + + private static boolean CE() { + !EE() + } + + private static boolean jdkVersionSupportsAugmentedSBOM() { + NativeImageUtils.getMajorJDKVersion(NativeCompileNoForkMojo.getVersionInformation(null)) >= SBOMGenerator.requiredNativeImageVersion + } + + private static boolean unsupportedJDKVersion() { + !jdkVersionSupportsAugmentedSBOM() + } + + private static boolean supportedAugmentedSBOMVersion() { + EE() && jdkVersionSupportsAugmentedSBOM() + } + + @Requires({ supportedAugmentedSBOMVersion() }) + def "sbom is created when buildArg '--enable-sbom=export,embed' is used"() { + withSample 'java-application' + + when: + /* The 'native-sbom' profile sets the '--enable-sbom' argument. */ + mvn '-Pnative-sbom', '-DquickBuild', '-DskipTests', 'package', 'exec:exec@native' + + def sbom = file("target/example-app.sbom.json") + + then: + buildSucceeded + outputContainsPattern".*CycloneDX SBOM with \\d+ component\\(s\\) is embedded in binary \\(.*?\\) and exported as JSON \\(see build artifacts\\)\\." + outputDoesNotContain "Use '--enable-sbom' to assemble a Software Bill of Materials (SBOM)" + validateSbom sbom + !file(String.format("target/%s", SBOMGenerator.SBOM_FILENAME)).exists() + outputContains "Hello, native!" + } + + /** + * If user sets {@link NativeCompileNoForkMojo#AUGMENTED_SBOM_PARAM_NAME} to true then an SBOM should be generated + * with default SBOM arguments even if user did not explicitly specify '--enable-sbom' as a buildArg. + */ + @Requires({ supportedAugmentedSBOMVersion() }) + def "sbom is created when only the augmented sbom parameter is used (but not the '--enable-sbom' buildArg)"() { + withSample 'java-application' + + when: + mvn '-Pnative-augmentedSBOM-only', '-DquickBuild', '-DskipTests', 'package', 'exec:exec@native' + + def sbom = file("target/example-app.sbom.json") + + then: + buildSucceeded + outputContainsPattern".*CycloneDX SBOM with \\d+ component\\(s\\) is embedded in binary \\(.*?\\)." + outputDoesNotContain "Use '--enable-sbom' to assemble a Software Bill of Materials (SBOM)" + validateSbom sbom + !file(String.format("target/%s", SBOMGenerator.SBOM_FILENAME)).exists() + outputContains "Hello, native!" + } + + @Requires({ CE() }) + def "error is thrown when augmented sbom parameter is used with CE"() { + withSample 'java-application' + + when: + mvn '-Pnative-augmentedSBOM-only', '-DquickBuild', '-DskipTests', 'package' + + then: + buildFailed + } + + @Requires({ EE() && unsupportedJDKVersion() }) + def "error is thrown when augmented sbom parameter is used with EE but not with an unsupported JDK version"() { + withSample 'java-application' + + when: + mvn '-Pnative-augmentedSBOM-only', '-DquickBuild', '-DskipTests', 'package' + + then: + buildFailed + } + + /** + * Validates the SBOM produced from 'java-application'. + * @param sbom path to the SBOM. + * @return true if validation succeeded. + */ + private static boolean validateSbom(File sbom) { + try { + if (!sbom.exists()) { + println "SBOM not found: ${sbom}" + return false + } + + def mapper = new ObjectMapper() + def rootNode = mapper.readTree(sbom) + + // Check root fields + assert rootNode.has('bomFormat') + assert rootNode.get('bomFormat').asText() == 'CycloneDX' + assert rootNode.has('specVersion') + assert rootNode.has('serialNumber') + assert rootNode.has('version') + assert rootNode.has('metadata') + assert rootNode.has('components') + assert rootNode.has('dependencies') + + // Check metadata/component + def metadataComponent = rootNode.path('metadata').path('component') + assert metadataComponent.has('group') + assert metadataComponent.get('group').asText() == 'org.graalvm.buildtools.examples' + assert metadataComponent.has('name') + assert metadataComponent.get('name').asText() == 'maven' + + // Check that components and dependencies are non-empty + assert !rootNode.get('components').isEmpty() + assert !rootNode.get('dependencies').isEmpty() + + // Check that the main component has no dependencies + def mainComponentId = metadataComponent.get('bom-ref').asText() + def mainComponentDependency = rootNode.get('dependencies').find { it.get('ref').asText() == mainComponentId } as ObjectNode + assert mainComponentDependency.get('dependsOn').isEmpty() + + // Check that the main component is not found in "components" + assert !rootNode.get('components').any { it.get('bom-ref').asText() == mainComponentId } + + return true + } catch (AssertionError | Exception e) { + println "SBOM validation failed: ${e.message}" + return false + } + } +} diff --git a/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/AbstractNativeImageMojo.java b/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/AbstractNativeImageMojo.java index 35a09382b..e4ff2d8e0 100644 --- a/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/AbstractNativeImageMojo.java +++ b/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/AbstractNativeImageMojo.java @@ -49,37 +49,25 @@ import org.apache.maven.plugins.annotations.Component; import org.apache.maven.plugins.annotations.Parameter; import org.apache.maven.toolchain.ToolchainManager; +import org.codehaus.plexus.logging.Logger; import org.graalvm.buildtools.maven.config.ExcludeConfigConfiguration; import org.graalvm.buildtools.utils.NativeImageConfigurationUtils; import org.graalvm.buildtools.utils.NativeImageUtils; import org.graalvm.buildtools.utils.SharedConstants; import javax.inject.Inject; -import java.io.File; -import java.io.InputStream; -import java.io.BufferedReader; -import java.io.InputStreamReader; -import java.io.IOException; +import java.io.*; import java.net.URI; import java.nio.charset.StandardCharsets; import java.nio.file.FileSystem; -import java.nio.file.FileSystems; -import java.nio.file.FileSystemAlreadyExistsException; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.Files; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashSet; -import java.util.List; -import java.util.Collections; -import java.util.Map; -import java.util.Optional; -import java.util.Set; +import java.nio.file.*; +import java.util.*; import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; +import static org.graalvm.buildtools.utils.NativeImageUtils.ORACLE_GRAALVM_IDENTIFIER; + /** * @author Sebastien Deleuze */ @@ -87,6 +75,7 @@ public abstract class AbstractNativeImageMojo extends AbstractNativeMojo { protected static final String NATIVE_IMAGE_META_INF = "META-INF/native-image"; protected static final String NATIVE_IMAGE_PROPERTIES_FILENAME = "native-image.properties"; protected static final String NATIVE_IMAGE_DRY_RUN = "nativeDryRun"; + private static String nativeImageVersionInformation = null; @Parameter(defaultValue = "${plugin}", readonly = true) // Maven 3 only protected PluginDescriptor plugin; @@ -447,6 +436,24 @@ protected void checkRequiredVersionIfNeeded() throws MojoExecutionException { if (requiredVersion == null) { return; } + NativeImageUtils.checkVersion(requiredVersion, getVersionInformation(logger)); + } + + static protected boolean isOracleGraalVM(Logger logger) throws MojoExecutionException { + return getVersionInformation(logger).contains(ORACLE_GRAALVM_IDENTIFIER); + } + + /** + * Returns the output of calling "native-image --version". + * @param logger a logger, that may be null, to print warnings or useful information. + * @return the output as a string joined by "\n". + * @throws MojoExecutionException when any errors occurred. + */ + static protected String getVersionInformation(Logger logger) throws MojoExecutionException { + if (nativeImageVersionInformation != null) { + return nativeImageVersionInformation; + } + Path nativeImageExecutable = NativeImageConfigurationUtils.getNativeImage(logger); try { ProcessBuilder processBuilder = new ProcessBuilder(nativeImageExecutable.toString()); @@ -457,12 +464,11 @@ protected void checkRequiredVersionIfNeeded() throws MojoExecutionException { throw new MojoExecutionException("Execution of " + commandString + " returned non-zero result"); } InputStream inputStream = versionCheckProcess.getInputStream(); - String versionToCheck = new BufferedReader( + nativeImageVersionInformation = new BufferedReader( new InputStreamReader(inputStream, StandardCharsets.UTF_8)) .lines() .collect(Collectors.joining("\n")); - NativeImageUtils.checkVersion(requiredVersion, versionToCheck); - + return nativeImageVersionInformation; } catch (IOException | InterruptedException e) { throw new MojoExecutionException("Checking GraalVM version with " + nativeImageExecutable + " failed", e); } diff --git a/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/AbstractNativeMojo.java b/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/AbstractNativeMojo.java index 6ceb4736c..98f9d036e 100644 --- a/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/AbstractNativeMojo.java +++ b/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/AbstractNativeMojo.java @@ -44,6 +44,7 @@ import org.apache.maven.artifact.Artifact; import org.apache.maven.execution.MavenSession; import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.BuildPluginManager; import org.apache.maven.plugin.descriptor.PluginDescriptor; import org.apache.maven.plugins.annotations.Component; import org.apache.maven.plugins.annotations.Parameter; @@ -122,6 +123,12 @@ public abstract class AbstractNativeMojo extends AbstractMojo { @Component protected MavenSession mavenSession; + @Component + protected MavenProject mavenProject; + + @Component + protected BuildPluginManager pluginManager; + @Component protected RepositorySystem repositorySystem; diff --git a/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/NativeCompileNoForkMojo.java b/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/NativeCompileNoForkMojo.java index 507901a7e..d91117345 100644 --- a/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/NativeCompileNoForkMojo.java +++ b/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/NativeCompileNoForkMojo.java @@ -53,11 +53,14 @@ import org.apache.maven.plugins.annotations.ResolutionScope; import org.codehaus.plexus.component.configurator.expression.ExpressionEvaluationException; import org.codehaus.plexus.util.xml.Xpp3Dom; +import org.graalvm.buildtools.maven.sbom.SBOMGenerator; +import org.graalvm.buildtools.utils.NativeImageUtils; import java.util.Arrays; import java.util.List; import java.util.function.BiFunction; +import static org.graalvm.buildtools.utils.NativeImageUtils.ORACLE_GRAALVM_IDENTIFIER; /** * This goal runs native builds. It functions the same as the native:compile goal, but it @@ -74,6 +77,14 @@ public class NativeCompileNoForkMojo extends AbstractNativeImageMojo { @Parameter(property = "skipNativeBuildForPom", defaultValue = "false") private boolean skipNativeBuildForPom; + /** + * Used in {@link NativeCompileNoForkMojo#generateAugmentedSBOMIfNeeded} to determine if an augmented SBOM should + * be produced by {@link SBOMGenerator}. + */ + @Parameter + private Boolean augmentedSBOM; + public static final String AUGMENTED_SBOM_PARAM_NAME = "augmentedSBOM"; + private PluginParameterExpressionEvaluator evaluator; @Override @@ -101,9 +112,74 @@ public void execute() throws MojoExecutionException { maybeSetMainClassFromPlugin(this::consumeConfigurationNodeValue, "org.apache.maven.plugins:maven-assembly-plugin", "archive", "manifest", "mainClass"); maybeSetMainClassFromPlugin(this::consumeConfigurationNodeValue, "org.apache.maven.plugins:maven-jar-plugin", "archive", "manifest", "mainClass"); maybeAddGeneratedResourcesConfig(buildArgs); + + generateAugmentedSBOMIfNeeded(); + buildImage(); } + /** + * Generates an augmented SBOM using the {@link SBOMGenerator} based on specific conditions: + * + * 1. If {@link NativeCompileNoForkMojo#augmentedSBOM} is explicitly set to false: No SBOM is generated. + * 2. If {@link NativeCompileNoForkMojo#augmentedSBOM} is explicitly set to true: An augmented SBOM is + * generated if the required conditions are met. + * 3. If {@link NativeCompileNoForkMojo#augmentedSBOM} is not set: An augmented SBOM is generated only if + * SBOM generation is configured for Native Image via a build argument. + * + * Note: Augmented SBOMs are only supported in Oracle GraalVM for JDK {@link SBOMGenerator#requiredNativeImageVersion} + * or later. + * + * @throws IllegalArgumentException if {@link NativeCompileNoForkMojo#augmentedSBOM} is explicitly set to true + * but required conditions are not met (e.g., using community edition or JDK version 23 or earlier). + * @throws MojoExecutionException if augmented SBOM generation was attempted but failed. + */ + private void generateAugmentedSBOMIfNeeded() throws IllegalArgumentException, MojoExecutionException { + boolean optionWasSet = augmentedSBOM != null; + augmentedSBOM = optionWasSet ? augmentedSBOM : true; + + int detectedJDKVersion = NativeImageUtils.getMajorJDKVersion(getVersionInformation(logger)); + String sbomNativeImageFlag = "--enable-sbom"; + boolean sbomEnabledForNativeImage = getBuildArgs().stream().anyMatch(v -> v.contains(sbomNativeImageFlag)); + if (optionWasSet) { + if (!augmentedSBOM) { + /* User explicitly opted out. */ + return; + } + + if (!isOracleGraalVM(logger)) { + throw new IllegalArgumentException( + String.format("Configuration option %s is only supported in %s.", AUGMENTED_SBOM_PARAM_NAME, ORACLE_GRAALVM_IDENTIFIER)); + } + + SBOMGenerator.checkAugmentedSBOMSupportedByJDKVersion(detectedJDKVersion, true); + + if (!sbomEnabledForNativeImage) { + buildArgs.add(sbomNativeImageFlag); + logger.info(String.format("Automatically added build argument %s to Native Image because configuration option %s was set to true. " + + "An SBOM will be embedded in the image.", sbomNativeImageFlag, AUGMENTED_SBOM_PARAM_NAME)); + } + + /* Continue to generate augmented SBOM because parameter option explicitly set and all conditions are met. */ + } else { + if (!isOracleGraalVM(logger) || !sbomEnabledForNativeImage) { + return; + } + + if (!SBOMGenerator.checkAugmentedSBOMSupportedByJDKVersion(detectedJDKVersion, false)) { + return; + } + + /* + * Continue to generate augmented SBOM because although the parameter option was not set, SBOM is used for + * Native Image and all conditions are met. + */ + } + + var sbomGenerator = new SBOMGenerator(mavenProject, mavenSession, pluginManager, repositorySystem, mainClass, logger); + sbomGenerator.generate(); + } + private String consumeConfigurationNodeValue(String pluginKey, String... nodeNames) { Plugin selectedPlugin = project.getPlugin(pluginKey); if (selectedPlugin == null) { diff --git a/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/sbom/ArtifactAdapter.java b/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/sbom/ArtifactAdapter.java new file mode 100644 index 000000000..33f50326e --- /dev/null +++ b/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/sbom/ArtifactAdapter.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or + * data (collectively the "Software"), free of charge and under any and all + * copyright rights in the Software, and any and all patent rights owned or + * freely licensable by each licensor hereunder covering either (i) the + * unmodified Software as contributed to or provided by such licensor, or (ii) + * the Larger Works (as defined below), to deal in both + * + * (a) the Software, and + * + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * + * The above copyright notice and either this complete permission notice or at a + * minimum a reference to the UPL must be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.graalvm.buildtools.maven.sbom; + +import java.net.URI; +import java.util.HashSet; +import java.util.Set; + +/** + * Data container that: (I) is an adapter between {@link org.apache.maven.artifact.Artifact} and + * {@link org.eclipse.aether.artifact.Artifact}; and (II) adds fields for the added component fields. + */ +final class ArtifactAdapter { + final String groupId; + final String artifactId; + final String version; + URI jarPath; + Set packageNames; + boolean prunable = true; + + ArtifactAdapter(String groupId, String artifactId, String version) { + this.groupId = groupId; + this.artifactId = artifactId; + this.version = version; + this.packageNames = new HashSet<>(); + } + + static ArtifactAdapter fromMavenArtifact(org.apache.maven.artifact.Artifact artifact) { + return new ArtifactAdapter(artifact.getGroupId(), artifact.getArtifactId(), artifact.getVersion()); + } + + static ArtifactAdapter fromEclipseArtifact(org.eclipse.aether.artifact.Artifact artifact) { + return new ArtifactAdapter(artifact.getGroupId(), artifact.getArtifactId(), artifact.getVersion()); + } + + void setJarPath(URI jarPath) { + this.jarPath = jarPath; + } + + void setPackageNames(Set packageNames) { + this.packageNames = packageNames; + } + + boolean equals(org.apache.maven.artifact.Artifact otherArtifact) { + return otherArtifact.getGroupId().equals(groupId) && otherArtifact.getArtifactId().equals(artifactId) && + otherArtifact.getVersion().equals(version); + } +} diff --git a/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/sbom/ArtifactAdapterResolver.java b/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/sbom/ArtifactAdapterResolver.java new file mode 100644 index 000000000..414cc0f08 --- /dev/null +++ b/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/sbom/ArtifactAdapterResolver.java @@ -0,0 +1,506 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or + * data (collectively the "Software"), free of charge and under any and all + * copyright rights in the Software, and any and all patent rights owned or + * freely licensable by each licensor hereunder covering either (i) the + * unmodified Software as contributed to or provided by such licensor, or (ii) + * the Larger Works (as defined below), to deal in both + * + * (a) the Software, and + * + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * + * The above copyright notice and either this complete permission notice or at a + * minimum a reference to the UPL must be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.graalvm.buildtools.maven.sbom; + +import org.apache.maven.model.Plugin; +import org.apache.maven.project.MavenProject; +import org.apache.maven.shared.utils.xml.Xpp3Dom; + +import java.io.*; +import java.nio.file.FileSystem; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +/** + * Class that tries to resolve the additional fields of {@link ArtifactAdapter}, including the pacakge names, jar path, + * and if it is prunable. + */ +final class ArtifactAdapterResolver { + private final MavenProject mavenProject; + /** + * The shade plugin for this {@link ArtifactAdapterResolver#mavenProject} if used, otherwise null. + */ + private final Plugin shadePlugin; + /** + * Set of possible directory paths containing class files in a jar file system. Examples of the keys are: + * "org/json" and "org/apache/commons/collections/map". + */ + private final Set pathToClassFilesDirectories; + private final Set visitedPathToClassFileDirectories; + private final String mainClass; + private static final String mavenShadePluginName = "maven-shade-plugin"; + + ArtifactAdapterResolver(MavenProject mavenProject, String mainClass) { + this.mavenProject = mavenProject; + this.shadePlugin = getShadePluginIfUsed(mavenProject); + this.pathToClassFilesDirectories = new HashSet<>(); + this.visitedPathToClassFileDirectories = new HashSet<>(); + this.mainClass = mainClass; + } + + /** + * This method tries to populate the extra fields of the {@link ArtifactAdapter}, namely: + * {@link ArtifactAdapter#packageNames}, {@link ArtifactAdapter#jarPath}, and {@link ArtifactAdapter#prunable}. + * The method tries to derive the package names as Native Image will see it as it encounters class files, + * meaning the final package names (possibly affected by shading) and the final jar path (possibly a fat + * or shaded jar). + * + * NOTE: + * - To improve chances of successful resolution, it is important to call this method with the main + * artifact last. + * - Should not be called with the same artifact more than once. + * - Shaded dependencies to the main artifact are not handled. Currently, we disable any pruning by + * Native Image of shaded dependencies since we cannot guarantee its correctness. + * + * @param jarPath the jar path as reported by the original Artifact. + * @param artifact the artifact with its class files inside the {@param jarPath}. + * @return a new path to the directory containing the class file of this shaded artifact (if it is one). + */ + Optional populateWithAdditionalFields(Path jarPath, ArtifactAdapter artifact) throws IOException { + if (!Files.exists(jarPath) || !jarPath.toString().endsWith(".jar")) { + return Optional.empty(); + } + + /* If the shade plugin is not used, then we are not dealing with a fat or shaded jar. */ + if (shadePlugin == null) { + return handleNonShadedCase(artifact, jarPath); + } + + /* Recover the path to the shaded jar by querying the shade plugin object. */ + Optional optionalShadedJarPath = getShadedJarPath(); + if (optionalShadedJarPath.isEmpty()) { + return handleNonShadedCase(artifact, jarPath); + } + + /* Check if artifact is part of the shading. */ + Path shadedJarPath = optionalShadedJarPath.get(); + FileSystem jarFileSystem = getOrCreateFileSystem(shadedJarPath); + if (!isPartOfJar(jarFileSystem, artifact)) { + return handleNonShadedCase(artifact, jarPath); + } + + /* Derive the directories of this artifact containing class files and retrieve the package names from those files. */ + Optional> optionalClassFileDirectories = resolveArtifactClassFileDirectories(jarFileSystem, jarPath, artifact); + if (optionalClassFileDirectories.isPresent()) { + Set classFileDirectories = optionalClassFileDirectories.get(); + Set packageNames = new HashSet<>(); + for (var directory : classFileDirectories) { + Set newPackageNames = FileWalkerUtility.collectPackageNamesFromFileSystem(jarFileSystem, directory) + .orElse(Set.of()); + packageNames.addAll(newPackageNames); + } + artifact.setPackageNames(packageNames); + artifact.setJarPath(shadedJarPath.toUri()); + return Optional.of(artifact); + } + return Optional.empty(); + } + + static void markShadedArtifactsAsNonPrunable(Set artifacts) throws IOException { + for (ArtifactAdapter artifact : artifacts) { + if (isShaded(artifact)) { + artifact.prunable = false; + } + } + } + + private static boolean isShaded(ArtifactAdapter artifact) throws IOException { + if (artifact.jarPath == null) { + return false; + } + + FileSystem jarFileSystem = getOrCreateFileSystem(Paths.get(artifact.jarPath)); + Optional optionalMetaInfPath = getMetaInfArtifactPath(jarFileSystem, artifact); + if (optionalMetaInfPath.isEmpty()) { + return false; + } + + Path metaInfPath = optionalMetaInfPath.get(); + Path pomPath = jarFileSystem.getPath(metaInfPath.toString(), "pom.xml"); + try (InputStream pomInputStream = Files.newInputStream(pomPath); + BufferedReader reader = new BufferedReader(new InputStreamReader(pomInputStream))) { + return reader.lines() + .anyMatch(line -> line.contains(String.format("%s", mavenShadePluginName))); + } catch (IOException e) { + return false; + } + } + + private Optional handleNonShadedCase(ArtifactAdapter artifactAdapter, Path jarPath) throws IOException { + FileSystem fileSystem = getOrCreateFileSystem(jarPath); + Set packageNames = FileWalkerUtility.collectPackageNamesFromFileSystem(fileSystem, fileSystem.getPath("/")).orElse(Set.of()); + artifactAdapter.setPackageNames(packageNames); + artifactAdapter.setJarPath(jarPath.toUri()); + return Optional.of(artifactAdapter); + } + + private Optional getShadedJarPath() { + Path targetDirectory = Paths.get(mavenProject.getBuild().getDirectory()); + + Optional outputFile = getParameterFromPlugin(shadePlugin, "outputFile"); + if (outputFile.isPresent()) { + Path outputPath = Paths.get(outputFile.get()); + if (Files.exists(outputPath)) { + return Optional.of(outputPath); + } + } + + Optional finalName = getParameterFromPlugin(shadePlugin, "finalName"); + if (finalName.isPresent()) { + Path finalJarPath = targetDirectory.resolve(finalName.get() + ".jar"); + if (Files.exists(finalJarPath)) { + return Optional.of(finalJarPath); + } + } + + Path defaultJarPath = targetDirectory.resolve(mavenProject.getArtifactId() + "-" + mavenProject.getVersion() + ".jar"); + if (Files.exists(defaultJarPath)) { + return Optional.of(defaultJarPath); + } + + return Optional.empty(); + } + + private boolean isPartOfJar(FileSystem jarFileSystem, ArtifactAdapter artifact) throws IOException { + Optional optionalMetaInfPath = getMetaInfArtifactPath(jarFileSystem, artifact); + if (optionalMetaInfPath.isEmpty()) { + return false; + } + Path metaInfPath = optionalMetaInfPath.get(); + + /* Handle case where there are multiple versions under this artifact. */ + try (DirectoryStream stream = Files.newDirectoryStream(metaInfPath)) { + int versionCount = 0; + for (Path path : stream) { + if (Files.isDirectory(path)) { + versionCount++; + } + } + + if (versionCount > 1) { + Path versionedPath = metaInfPath.resolve(artifact.version); + return Files.isDirectory(versionedPath); + } else { + return true; + } + } + } + + private static Optional getMetaInfArtifactPath(FileSystem jarFileSystem, ArtifactAdapter artifact) { + Path path = jarFileSystem.getPath("META-INF", "maven", artifact.groupId, artifact.artifactId); + if (!Files.isDirectory(path)) { + return Optional.empty(); + } + return Optional.of(path); + } + + /** + * Finds the paths to the directories containing the class files for the {@param artifact} inside the jar. + * For example, if {@param artifact} represents commons-validator and the content of a fat jar looks like this: + * + *
+     * org/
+     * ├── apache/
+     * │   ├── commons/
+     * │   │   ├── validator/
+     * │   │   │   ├── UrlValidator.class
+     * │   │   │   ├── routines/
+     * │   │   │   │   └── ValidatorUtils.class
+     * │   │   │   ├── util/
+     * │   │   │   │   └── Flags.class
+     * │   │   ├── digester/
+     * │   │   │   ├── plugins/
+     * │   │   │   │   └── strategies/
+     * │   │   │   │       └── DigesterPlugin.class
+     * ├── json/
+     * │   └── org/
+     * │       └── json/
+     * │           └── JSONObject.class
+     * META-INF/...
+     * 
+ * + * Then the method would return only the path to the directories of commons-validator: + * + *
+     * org/apache/commons/validator/
+     * org/apache/commons/validator/routines/
+     * org/apache/commons/validator/util/
+     * 
+ * + * NOTE: partial relocations--when a subset of the class files are relocated--is not supported and + * {@link Optional#empty()} is returned in these cases. + * + * @param jarFileSystem The filesystem representing the JAR. + * @param jarPath The path within the JAR file to search. + * @param artifact The artifact whose class directories should be found. + * @return A list of paths containing the class files for the artifact. + * @throws IOException if an error occurs while reading the JAR. + */ + private Optional> resolveArtifactClassFileDirectories(FileSystem jarFileSystem, Path jarPath, ArtifactAdapter artifact) throws IOException { + if (pathToClassFilesDirectories.isEmpty()) { + Set potentialDirectories = resolveDirectoriesContainingClassFiles(jarFileSystem.getPath("/")); + if (potentialDirectories.isEmpty()) { + return Optional.empty(); + } + pathToClassFilesDirectories.addAll(potentialDirectories); + } + + if (pathToClassFilesDirectories.size() == 1) { + Path onlyPossiblePath = pathToClassFilesDirectories.stream().findFirst().get(); + visitedPathToClassFileDirectories.add(onlyPossiblePath); + return Optional.of(Set.of(onlyPossiblePath)); + } + + /* If all but one path has been visited, then that path must be the correct one for this artifact. */ + Set difference = notVisitedPaths(); + if (difference.size() == 1) { + Path onlyPossiblePath = difference.stream().findFirst().get(); + visitedPathToClassFileDirectories.add(onlyPossiblePath); + return Optional.of(Set.of(onlyPossiblePath)); + } + + /* + * Try to match directly with the GAV coordinates. + */ + Optional resolvedPath = tryResolveUsingGAVCoordinates(jarFileSystem.getPath("/"), artifact); + if (resolvedPath.isPresent()) { + return Optional.of(Set.of(resolvedPath.get())); + } + + boolean isMainArtifact = artifact.equals(mavenProject.getArtifact()); + if (isMainArtifact) { + resolvedPath = findTopClassDirectory(jarFileSystem.getPath("/"), mainClass); + if (resolvedPath.isPresent()) { + return Optional.of(Set.of(resolvedPath.get())); + } + + resolvedPath = tryResolveUsingGAVCoordinates(jarFileSystem.getPath("/"), artifact); + return resolvedPath.map(Set::of); + } + + /* + * To derive the directory path when relocation is used we apply a matching strategy on the class names. + * We collect the class file names of the original jar and searches the directories in the shaded/fat jar + * and define a match to be when all class file names match the class files in the original jar. + */ + FileSystem fileSystemOriginalJar = getOrCreateFileSystem(jarPath); + Set originalClassFiles = new HashSet<>(); + FileWalkerUtility.walkFileTreeWithExtensions(fileSystemOriginalJar.getPath("/"), Set.of(".class", ".java"), file -> { + Path fileName = file.getFileName(); + if (fileName != null) { + originalClassFiles.add(fileName.toString()); + } + }); + Optional> optionalPaths = resolveDirectoriesFromClassNameMatching(artifact, originalClassFiles); + if (optionalPaths.isPresent()) { + Set paths = optionalPaths.get(); + visitedPathToClassFileDirectories.addAll(paths); + return Optional.of(paths); + } + return Optional.empty(); + } + + + /** + * Resolves the top directory containing class files by traversing backwards from the main class location. + * + * @param qualifiedName the qualified name of the class to start the search from. + * @param rootPath the root of the file system. + * @return a path of the top directory containing class files. + */ + private Optional findTopClassDirectory(Path rootPath, String qualifiedName) throws IOException { + String mainClassPath = qualifiedName.replace('.', File.separatorChar) + ".class"; + Path classFilePath = rootPath.resolve(mainClassPath); + Path currentPath = classFilePath.getParent(); + while (currentPath != null && !Files.isSameFile(currentPath, rootPath)) { + if (FileWalkerUtility.containsClassFiles(currentPath)) { + return Optional.of(currentPath); + } + currentPath = currentPath.getParent(); + } + return Optional.empty(); + } + + /** + * Helper method to resolve GAV coordinates and check if they exist in the class files directory. + * Tries both with and without using the artifactId. + */ + private Optional tryResolveUsingGAVCoordinates(Path rootPath, ArtifactAdapter artifact) throws IOException { + Optional resolvedPath = resolveGAVCoordinates(rootPath, artifact, true); + if (resolvedPath.isPresent()) return resolvedPath; + return resolveGAVCoordinates(rootPath, artifact, false); + } + + /** + * Helper method to resolve GAV coordinates for a specific configuration (with or without artifactId). + */ + private Optional resolveGAVCoordinates(Path rootPath, ArtifactAdapter artifact, boolean useArtifactId) throws IOException { + Path gavPath = pathFromGAVCoordinates(rootPath, artifact, useArtifactId); + var pathsAsStrings = pathToClassFilesDirectories.stream() + .map(Path::toString) + .collect(Collectors.toSet()); + if (pathsAsStrings.contains(gavPath.toString())) { + visitedPathToClassFileDirectories.add(gavPath); + return Optional.of(gavPath); + } + return Optional.empty(); + } + + private Set notVisitedPaths() { + Set difference = new HashSet<>(pathToClassFilesDirectories); + difference.removeAll(visitedPathToClassFileDirectories); + return difference; + } + + private Optional> resolveDirectoriesFromClassNameMatching(ArtifactAdapter artifact, Set originalClassFiles) throws IOException { + Set matchingDirectories = new HashSet<>(); + + for (Path potentialDirectory : pathToClassFilesDirectories) { + AtomicBoolean successfulMatching = new AtomicBoolean(true); + + try (DirectoryStream stream = Files.newDirectoryStream(potentialDirectory)) { + for (Path file : stream) { + if (Files.isRegularFile(file) && file.toString().endsWith(".class")) { + String fileName = file.getFileName().toString(); + if (!originalClassFiles.contains(fileName)) { + successfulMatching.set(false); + break; + } + } + } + } + + if (successfulMatching.get()) { + matchingDirectories.add(potentialDirectory); + } + } + + if (matchingDirectories.isEmpty()) { + return Optional.empty(); + } + return Optional.of(matchingDirectories); + } + + private Set resolveDirectoriesContainingClassFiles(Path rootPath) throws IOException { + Set directories = new HashSet<>(); + Files.walkFileTree(rootPath, new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + if (file.toString().endsWith(".class")) { + Path classDirectory = file.getParent(); + directories.add(classDirectory); + return FileVisitResult.CONTINUE; + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { + Path fileName = dir.getFileName(); + if (fileName != null && fileName.toString().equals("META-INF")) { + return FileVisitResult.SKIP_SUBTREE; + } + return FileVisitResult.CONTINUE; + } + }); + return directories; + } + + private Path pathFromGAVCoordinates(Path basePath, ArtifactAdapter artifact, boolean useArtifactId) throws IOException { + FileSystem fileSystem = basePath.getFileSystem(); + Path expectedPath = basePath.resolve(fileSystem.getPath( + artifact.groupId.replace('.', '/') + )); + if (useArtifactId) { + expectedPath = expectedPath.resolve(artifact.artifactId.replace('.', '/')); + } + + /* Handle case where there are multiple versions. */ + if (Files.isDirectory(expectedPath)) { + try (DirectoryStream stream = Files.newDirectoryStream(expectedPath)) { + boolean hasMultipleDirectories = StreamSupport.stream(stream.spliterator(), false) + .filter(Files::isDirectory) + .count() > 1; + + /* If multiple directories exist, append the version information. */ + if (hasMultipleDirectories) { + expectedPath = expectedPath.resolve(artifact.version); + } + } + } + + return expectedPath; + } + + + private static Optional getParameterFromPlugin(Plugin plugin, String parameter) { + Xpp3Dom configuration = (Xpp3Dom) plugin.getConfiguration(); + if (configuration != null && parameter != null && !parameter.isEmpty()) { + Xpp3Dom parameterNode = configuration.getChild(parameter); + if (parameterNode != null) { + return Optional.of(parameterNode.getValue()); + } + } + return Optional.empty(); + } + + private static Plugin getShadePluginIfUsed(MavenProject mavenProject) { + return mavenProject.getBuildPlugins().stream() + .filter(v -> mavenShadePluginName.equals(v.getArtifactId())) + .findFirst() + .orElse(null); + } + + private static FileSystem getOrCreateFileSystem(Path jarPath) throws IOException { + try { + return FileSystems.newFileSystem(jarPath, null); + } catch (FileSystemAlreadyExistsException e) { + /* If the file system already exists, return the existing file system. */ + return FileSystems.getFileSystem(jarPath.toUri()); + } + } +} diff --git a/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/sbom/ArtifactToPackageNameResolver.java b/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/sbom/ArtifactToPackageNameResolver.java new file mode 100644 index 000000000..9ae7a69d9 --- /dev/null +++ b/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/sbom/ArtifactToPackageNameResolver.java @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or + * data (collectively the "Software"), free of charge and under any and all + * copyright rights in the Software, and any and all patent rights owned or + * freely licensable by each licensor hereunder covering either (i) the + * unmodified Software as contributed to or provided by such licensor, or (ii) + * the Larger Works (as defined below), to deal in both + * + * (a) the Software, and + * + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * + * The above copyright notice and either this complete permission notice or at a + * minimum a reference to the UPL must be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.graalvm.buildtools.maven.sbom; + +import org.apache.maven.artifact.Artifact; +import org.apache.maven.project.MavenProject; +import org.eclipse.aether.RepositorySystem; +import org.eclipse.aether.RepositorySystemSession; +import org.eclipse.aether.artifact.DefaultArtifact; +import org.eclipse.aether.repository.RemoteRepository; +import org.eclipse.aether.resolution.ArtifactRequest; +import org.eclipse.aether.resolution.ArtifactResolutionException; +import org.eclipse.aether.resolution.ArtifactResult; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.*; +import java.util.stream.Collectors; + +final class ArtifactToPackageNameResolver { + private final MavenProject mavenProject; + private final RepositorySystem repositorySystem; + private final RepositorySystemSession repositorySystemSession; + private final List remoteRepositories; + private final ArtifactAdapterResolver shadedPackageNameResolver; + + ArtifactToPackageNameResolver(MavenProject mavenProject, RepositorySystem repositorySystem, RepositorySystemSession repositorySystemSession, String mainClass) { + this.mavenProject = mavenProject; + this.repositorySystem = repositorySystem; + this.repositorySystemSession = repositorySystemSession; + this.remoteRepositories = mavenProject.getRemoteProjectRepositories(); + this.shadedPackageNameResolver = new ArtifactAdapterResolver(mavenProject, mainClass); + } + + /** + * Maps the artifacts of the maven project to {@link ArtifactAdapter}s. {@link ArtifactAdapter#packageNames} will + * be non-empty if package names could accurately be derived for an artifact. If not, it will be non-empty and + * {@link ArtifactAdapter#prunable} will be set to false. {@link ArtifactAdapter#prunable} will also be set to + * false if an artifact is not the main artifact and its part of a shaded jar. + * + * @return the artifacts of this project as {@link ArtifactAdapter}s. + * @throws Exception if an error was encountered when deriving the artifacts. + */ + Set getArtifactAdapters() throws Exception { + Set artifactsWithPackageNameMappings = new HashSet<>(); + List artifacts = new ArrayList<>(mavenProject.getArtifacts()); + /* Purposefully add the project artifact last. This is important for the resolution of shaded jars. */ + artifacts.add(mavenProject.getArtifact()); + for (Artifact artifact : artifacts) { + Optional optionalArtifact = resolvePackageNamesFromArtifact(artifact); + if (optionalArtifact.isPresent()) { + artifactsWithPackageNameMappings.add(optionalArtifact.get()); + } else { + /* If resolve failed, then there are no package name mappings, so we mark it as not prunable. */ + var artifactAdapter = ArtifactAdapter.fromMavenArtifact(artifact); + artifactAdapter.prunable = false; + artifactsWithPackageNameMappings.add(artifactAdapter); + } + } + + /* + * Currently we cannot ensure that package name are derived accurately for shaded dependencies. + * Thus, we mark such artifacts as non-prunable. + */ + Set dependencies = artifactsWithPackageNameMappings.stream() + .filter(v -> !v.equals(mavenProject.getArtifact())) + .collect(Collectors.toSet()); + ArtifactAdapterResolver.markShadedArtifactsAsNonPrunable(dependencies); + return artifactsWithPackageNameMappings; + } + + private Optional resolvePackageNamesFromArtifact(Artifact artifact) throws ArtifactResolutionException, IOException { + File artifactFile = artifact.getFile(); + if (artifactFile != null && artifactFile.exists()) { + return resolvePackageNamesFromArtifactFile(artifactFile, ArtifactAdapter.fromMavenArtifact(artifact)); + } else { + DefaultArtifact sourceArtifact = new DefaultArtifact( + artifact.getGroupId(), artifact.getArtifactId(), "sources", "jar", artifact.getVersion() + ); + ArtifactRequest request = new ArtifactRequest() + .setArtifact(sourceArtifact) + .setRepositories(remoteRepositories); + + ArtifactResult result = repositorySystem.resolveArtifact(repositorySystemSession, request); + if (result != null && result.getArtifact() != null && result.getArtifact().getFile() != null) { + File sourceFile = result.getArtifact().getFile(); + return resolvePackageNamesFromArtifactFile(sourceFile, ArtifactAdapter.fromEclipseArtifact(result.getArtifact())); + } + return Optional.empty(); + } + } + + private Optional resolvePackageNamesFromArtifactFile(File artifactFile, ArtifactAdapter artifact) throws IOException { + if (!artifactFile.exists()) { + return Optional.empty(); + } + + Path sourcePath = artifactFile.toPath(); + if (artifactFile.isDirectory()) { + Set packageNames = FileWalkerUtility.collectPackageNamesFromDirectory(artifactFile.toPath()).orElse(Set.of()); + artifact.setPackageNames(packageNames); + return Optional.of(artifact); + } else if (artifactFile.getName().endsWith(".jar")) { + return shadedPackageNameResolver.populateWithAdditionalFields(sourcePath, artifact); + } else { + return Optional.empty(); + } + } +} diff --git a/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/sbom/FileWalkerUtility.java b/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/sbom/FileWalkerUtility.java new file mode 100644 index 000000000..bee964787 --- /dev/null +++ b/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/sbom/FileWalkerUtility.java @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2020, 2022, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or + * data (collectively the "Software"), free of charge and under any and all + * copyright rights in the Software, and any and all patent rights owned or + * freely licensable by each licensor hereunder covering either (i) the + * unmodified Software as contributed to or provided by such licensor, or (ii) + * the Larger Works (as defined below), to deal in both + * + * (a) the Software, and + * + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * + * The above copyright notice and either this complete permission notice or at a + * minimum a reference to the UPL must be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.graalvm.buildtools.maven.sbom; + +import java.io.File; +import java.io.IOException; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; +import java.util.function.Consumer; + +/** + * Utility class for walking file trees and collecting package names from Java source and class files. + */ +final class FileWalkerUtility { + static Optional> collectPackageNamesFromDirectory(Path path) throws IOException { + return walkFileTreeAndCollectPackageNames(path, path); + } + + static Optional> collectPackageNamesFromFileSystem(FileSystem fileSystem, Path startPath) throws IOException { + return walkFileTreeAndCollectPackageNames(fileSystem.getPath(startPath.toString()), fileSystem.getPath("/")); + } + + private static Optional> walkFileTreeAndCollectPackageNames(Path pathToSearchIn, Path basePathForPackageNameResolution) throws IOException { + Set packageNames = new HashSet<>(); + FileWalkerUtility.walkFileTreeWithExtensions(pathToSearchIn, Set.of(".java", ".class"), file -> { + Optional optionalPackageName = extractPackageName(file, basePathForPackageNameResolution); + optionalPackageName.ifPresent(packageNames::add); + }); + return Optional.of(packageNames); + } + + static void walkFileTreeWithExtensions(Path startPath, Set fileExtensions, Consumer fileHandler) throws IOException { + Files.walkFileTree(startPath, new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + for (String extension : fileExtensions) { + if (file.toString().endsWith(extension)) { + fileHandler.accept(file); + break; + } + } + return FileVisitResult.CONTINUE; + } + }); + } + + private static Optional extractPackageName(Path filePath, Path basePath) { + String relativePath = basePath.relativize(filePath).toString(); + int lastSeparatorIndex = relativePath.lastIndexOf(File.separator); + if (lastSeparatorIndex == -1) { + return Optional.empty(); + } + String packageName = relativePath.substring(0, lastSeparatorIndex); + packageName = packageName.replace(File.separatorChar, '.'); + return Optional.of(packageName); + } + + static boolean containsClassFiles(Path directory) { + try (DirectoryStream stream = Files.newDirectoryStream(directory, "*.class")) { + return stream.iterator().hasNext(); + } catch (Exception e) { + return false; + } + } +} diff --git a/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/sbom/SBOMGenerator.java b/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/sbom/SBOMGenerator.java new file mode 100644 index 000000000..28ad8cb81 --- /dev/null +++ b/native-maven-plugin/src/main/java/org/graalvm/buildtools/maven/sbom/SBOMGenerator.java @@ -0,0 +1,291 @@ +/* + * Copyright (c) 2024, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or + * data (collectively the "Software"), free of charge and under any and all + * copyright rights in the Software, and any and all patent rights owned or + * freely licensable by each licensor hereunder covering either (i) the + * unmodified Software as contributed to or provided by such licensor, or (ii) + * the Larger Works (as defined below), to deal in both + * + * (a) the Software, and + * + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * + * The above copyright notice and either this complete permission notice or at a + * minimum a reference to the UPL must be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.graalvm.buildtools.maven.sbom; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.plugin.BuildPluginManager; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.project.MavenProject; +import org.codehaus.plexus.logging.Logger; +import org.eclipse.aether.RepositorySystem; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.graalvm.buildtools.maven.NativeCompileNoForkMojo.AUGMENTED_SBOM_PARAM_NAME; +import static org.graalvm.buildtools.utils.NativeImageUtils.ORACLE_GRAALVM_IDENTIFIER; +import static org.twdata.maven.mojoexecutor.MojoExecutor.*; + +/** + * Generates a Software Bill of Materials (SBOM) that is augmented and refined by Native Image. This feature is only + * supported in Oracle GraalVM for JDK {@link SBOMGenerator#requiredNativeImageVersion} or later. + *

+ * Approach: + * 1. The cyclonedx-maven-plugin creates a baseline SBOM. + * 2. The components of the baseline SBOM (referred to as the "base" SBOM) are updated with additional metadata, + * most importantly being the set of package names associated with the component (see {@link AddedComponentFields} + * for all additional metadata). + * 3. The SBOM is stored at a known location. + * 4. Native Image processes the SBOM and removes unreachable components and unnecessary dependencies. + *

+ * Creating the package-name-to-component mapping in the context of Native Image, without the knowledge known at the + * plugin build-time is difficult, which was the primary motivation for realizing this approach. + *

+ * Benefits: + * * Great Baseline: Produces an industry-standard SBOM at minimum. + * * Enhanced Accuracy: Native Image augments and refines the SBOM, potentially significantly improving its accuracy. + */ +final public class SBOMGenerator { + public static final int requiredNativeImageVersion = 24; + + private final MavenProject mavenProject; + private final MavenSession mavenSession; + private final BuildPluginManager pluginManager; + private final RepositorySystem repositorySystem; + private final String mainClass; + private final Logger logger; + + private static final String SBOM_FILE_FORMAT = "json"; + private static final String SBOM_FILENAME_WITHOUT_EXTENSION = "base_sbom"; + private final String outputDirectory; + + public static final String SBOM_FILENAME = SBOM_FILENAME_WITHOUT_EXTENSION + "." + SBOM_FILE_FORMAT; + + private static final class AddedComponentFields { + /** + * The package names associated with this component. + */ + static final String packageNames = "packageNames"; + /** + * The path to the jar containing the class files. For a component embedded in a shaded jar, the path must + * be pointing to the shaded jar. + */ + static final String jarPath = "jarPath"; + /** + * If set to false, then this component and all its transitive dependencies SHOULD NOT be pruned by Native Image. + * This is set to false when the package names could not be derived accurately. + */ + static final String prunable = "prunable"; + } + + /** + * The external plugin used to generate the baseline SBOM. + */ + private static final class Plugin { + static final String artifactId = "cyclonedx-maven-plugin"; + static final String groupId = "org.cyclonedx"; + static final String version = "2.8.1"; + static final String goal = "makeAggregateBom"; + + private static final class Configuration { + static final String outputFormat = SBOM_FILE_FORMAT; + static final String outputName = SBOM_FILENAME_WITHOUT_EXTENSION; + static final String skipNotDeployed = "false"; + static final String schemaVersion = "1.5"; + } + } + + public SBOMGenerator( + MavenProject mavenProject, + MavenSession mavenSession, + BuildPluginManager pluginManager, + RepositorySystem repositorySystem, + String mainClass, + Logger logger) { + this.mavenProject = mavenProject; + this.mavenSession = mavenSession; + this.pluginManager = pluginManager; + this.repositorySystem = repositorySystem; + this.mainClass = mainClass; + this.logger = logger; + this.outputDirectory = mavenProject.getBuild().getDirectory(); + } + + /** + * Checks if the JDK version supports augmented SBOMs. + * + * @param detectedJdkVersion the JDK version used. + * @param throwErrorIfNotSupported if true, then an error is thrown if the check failed. + * @return true if the JDK version supports the flag, otherwise false (if throwErrorIfNotSupported is false). + * @throws IllegalArgumentException when throwErrorIfNotSupported is true and the version check failed. + */ + public static boolean checkAugmentedSBOMSupportedByJDKVersion(int detectedJdkVersion, boolean throwErrorIfNotSupported) throws IllegalArgumentException { + if (detectedJdkVersion < SBOMGenerator.requiredNativeImageVersion) { + if (throwErrorIfNotSupported) { + throw new IllegalArgumentException( + String.format("%s version %s is required to use configuration option %s but major JDK version %s has been detected.", + ORACLE_GRAALVM_IDENTIFIER, SBOMGenerator.requiredNativeImageVersion, AUGMENTED_SBOM_PARAM_NAME, detectedJdkVersion)); + } + return false; + } + return true; + } + + /** + * Generates an SBOM that will be further augmented by Native Image. The SBOM is stored in the build directory. + * + * @throws MojoExecutionException if SBOM creation fails. + */ + public void generate() throws MojoExecutionException { + Path sbomPath = Paths.get(outputDirectory, SBOM_FILENAME); + try { + /* Suppress the output from the plugin. */ + int loggingLevel = logger.getThreshold(); + logger.setThreshold(Logger.LEVEL_DISABLED); + executeMojo( + plugin( + groupId(Plugin.groupId), + artifactId(Plugin.artifactId), + version(Plugin.version) + ), + goal(Plugin.goal), + configuration( + element(name("outputFormat"), Plugin.Configuration.outputFormat), + element(name("outputName"), Plugin.Configuration.outputName), + element(name("outputDirectory"), outputDirectory), + element(name("skipNotDeployed"), Plugin.Configuration.skipNotDeployed), + element(name("schemaVersion"), Plugin.Configuration.schemaVersion) + ), + executionEnvironment(mavenProject, mavenSession, pluginManager) + ); + logger.setThreshold(loggingLevel); + + if (!Files.exists(sbomPath)) { + return; + } + + var resolver = new ArtifactToPackageNameResolver(mavenProject, repositorySystem, mavenSession.getRepositorySession(), mainClass); + Set artifacts = resolver.getArtifactAdapters(); + augmentSBOM(sbomPath, artifacts); + } catch (Exception exception) { + deleteFileIfExists(sbomPath); + String errorMsg = String.format("Failed to create SBOM. Please try again and report this issue if it persists. " + + "To bypass this failure, disable SBOM generation by setting configuration option %s to false.", AUGMENTED_SBOM_PARAM_NAME); + throw new MojoExecutionException(errorMsg, exception); + } + } + + private static void deleteFileIfExists(Path sbomPath) { + try { + Files.deleteIfExists(sbomPath); + } catch (IOException e) { + /* Failed to delete file. */ + } + } + + /** + * Augments the base SBOM with information from the derived {@param artifacts}. + * + * @param baseSBOMPath path to the base SBOM generated by the cyclonedx plugin. + * @param artifacts artifacts that possibly have been extended with package name data. + */ + private void augmentSBOM(Path baseSBOMPath, Set artifacts) throws IOException { + ObjectMapper objectMapper = new ObjectMapper(); + ObjectNode sbomJson = (ObjectNode) objectMapper.readTree(Files.newInputStream(baseSBOMPath)); + + ArrayNode componentsArray = (ArrayNode) sbomJson.get("components"); + if (componentsArray == null) { + throw new RuntimeException(String.format("SBOM generated by %s:%s contained no components.", Plugin.groupId, Plugin.artifactId)); + } + + /* Augment the "components" */ + componentsArray.forEach(componentNode -> augmentComponentNode(componentNode, artifacts, objectMapper)); + + /* Augment the main component in "metadata/component" */ + JsonNode metadataNode = sbomJson.get("metadata"); + if (metadataNode != null && metadataNode.has("component")) { + augmentComponentNode(metadataNode.get("component"), artifacts, objectMapper); + } + + /* Save the augmented SBOM back to the file */ + objectMapper.writerWithDefaultPrettyPrinter().writeValue(Files.newOutputStream(baseSBOMPath), sbomJson); + } + + /** + * Updates the {@param componentNode} with {@link AddedComponentFields} from the artifact in {@param artifactsWithPackageNames} + * with matching GAV coordinates. + * + * @param componentNode the node in the base SBOM that should be augmented. + * @param artifactsWithPackageNames the artifact with information for {@link AddedComponentFields}. + * @param objectMapper the objectMapper that is used to write the updates. + */ + private void augmentComponentNode(JsonNode componentNode, Set artifactsWithPackageNames, ObjectMapper objectMapper) { + String groupField = "group"; + String nameField = "name"; + String versionField = "version"; + if (componentNode.has(groupField) && componentNode.has(nameField) && componentNode.has(versionField)) { + String groupId = componentNode.get(groupField).asText(); + String artifactId = componentNode.get(nameField).asText(); + String version = componentNode.get(versionField).asText(); + + Optional optionalArtifact = artifactsWithPackageNames.stream() + .filter(artifact -> artifact.groupId.equals(groupId) + && artifact.artifactId.equals(artifactId) + && artifact.version.equals(version)) + .findFirst(); + + if (optionalArtifact.isPresent()) { + ArtifactAdapter artifact = optionalArtifact.get(); + ArrayNode packageNamesArray = objectMapper.createArrayNode(); + List sortedPackageNames = artifact.packageNames.stream().sorted().collect(Collectors.toList()); + sortedPackageNames.forEach(packageNamesArray::add); + ((ObjectNode) componentNode).set(AddedComponentFields.packageNames, packageNamesArray); + + String jarPath = ""; + if (artifact.jarPath != null) { + jarPath = artifact.jarPath.toString(); + } + ((ObjectNode) componentNode).put(AddedComponentFields.jarPath, jarPath); + ((ObjectNode) componentNode).put(AddedComponentFields.prunable, artifact.prunable); + } + } + } +} diff --git a/native-maven-plugin/src/main/java/org/graalvm/buildtools/utils/NativeImageConfigurationUtils.java b/native-maven-plugin/src/main/java/org/graalvm/buildtools/utils/NativeImageConfigurationUtils.java index 364e051c6..69b0c13bb 100644 --- a/native-maven-plugin/src/main/java/org/graalvm/buildtools/utils/NativeImageConfigurationUtils.java +++ b/native-maven-plugin/src/main/java/org/graalvm/buildtools/utils/NativeImageConfigurationUtils.java @@ -93,8 +93,9 @@ public static Path getJavaHomeNativeImage(String javaHomeVariable, Boolean failF return null; } } - - logger.info("Found GraalVM installation from " + javaHomeVariable + " variable."); + if (logger != null) { + logger.info("Found GraalVM installation from " + javaHomeVariable + " variable."); + } return nativeImageExe; } @@ -119,7 +120,7 @@ public static Path getNativeImage(Logger logger) throws MojoExecutionException { if (nativeImage == null) { nativeImage = getNativeImageFromPath(); - if (nativeImage != null) { + if (nativeImage != null && logger != null) { logger.info("Found GraalVM installation from PATH variable."); } } diff --git a/native-maven-plugin/src/testFixtures/groovy/org/graalvm/buildtools/maven/AbstractGraalVMMavenFunctionalTest.groovy b/native-maven-plugin/src/testFixtures/groovy/org/graalvm/buildtools/maven/AbstractGraalVMMavenFunctionalTest.groovy index a02de1d93..9184bb000 100644 --- a/native-maven-plugin/src/testFixtures/groovy/org/graalvm/buildtools/maven/AbstractGraalVMMavenFunctionalTest.groovy +++ b/native-maven-plugin/src/testFixtures/groovy/org/graalvm/buildtools/maven/AbstractGraalVMMavenFunctionalTest.groovy @@ -213,6 +213,12 @@ abstract class AbstractGraalVMMavenFunctionalTest extends Specification { normalizeString(result.stdOut).contains(normalizeString(text)) } + boolean outputContainsPattern(String pattern) { + def normalizedOutput = normalizeString(result.stdOut) + def lines = normalizedOutput.split('\n') + return lines.any { line -> line.trim().matches(pattern) } + } + String after(String text) { def out = normalizeString(result.stdOut) out.substring(out.indexOf(normalizeString(text))) diff --git a/samples/java-application/pom.xml b/samples/java-application/pom.xml index 70a6d1d8f..3769deed7 100644 --- a/samples/java-application/pom.xml +++ b/samples/java-application/pom.xml @@ -93,6 +93,64 @@ + + native-sbom + + + + org.graalvm.buildtools + native-maven-plugin + ${native.maven.plugin.version} + true + + + build-native + + compile-no-fork + + package + + + + false + ${imageName} + false + + --enable-sbom=embed,export + + + + + + + + native-augmentedSBOM-only + + + + org.graalvm.buildtools + native-maven-plugin + ${native.maven.plugin.version} + true + + + build-native + + compile-no-fork + + package + + + + false + ${imageName} + false + true + + + + + shaded