diff --git a/src/main/java/org/cyclonedx/maven/BaseCycloneDxMojo.java b/src/main/java/org/cyclonedx/maven/BaseCycloneDxMojo.java index b6369f6e..356f2889 100644 --- a/src/main/java/org/cyclonedx/maven/BaseCycloneDxMojo.java +++ b/src/main/java/org/cyclonedx/maven/BaseCycloneDxMojo.java @@ -167,6 +167,16 @@ public abstract class BaseCycloneDxMojo extends AbstractMojo { @Parameter(property = "excludeTypes", required = false) private String[] excludeTypes; + /** + * Define component scope (REQUIRED/OPTIONAL) using the maven artifact optionality, determined during dependency analysis. + * + * Note: The default mechanism uses bytecode analysis to determine component scope. + * + * @since 2.7.9 + */ + @Parameter(property = "useMavenOptionality", defaultValue = "false") + protected boolean useMavenOptionality; + @org.apache.maven.plugins.annotations.Component(hint = "default") private RepositorySystem aetherRepositorySystem; @@ -280,6 +290,8 @@ public void execute() throws MojoExecutionException { if (includeSystemScope) scopes.add("system"); if (includeTestScope) scopes.add("test"); metadata.addProperty(newProperty("maven.scopes", String.join(",", scopes))); + + metadata.addProperty(newProperty("maven.optionality", Boolean.toString(useMavenOptionality))); } final Component rootComponent = metadata.getComponent(); @@ -426,7 +438,7 @@ protected void populateComponents(final Set topLevelComponents, final Ma for (Map.Entry entry: artifacts.entrySet()) { final String purl = entry.getKey(); final Artifact artifact = entry.getValue(); - final Component.Scope artifactScope = (dependencyAnalysis != null ? inferComponentScope(artifact, dependencyAnalysis) : null); + final Component.Scope artifactScope = getComponentScope(artifact, dependencyAnalysis); final Component component = components.get(purl); if (component == null) { final Component newComponent = convert(artifact); @@ -438,6 +450,25 @@ protected void populateComponents(final Set topLevelComponents, final Ma } } + /** + * Get the BOM component scope (required/optional/excluded). The scope can either be determined through bytecode + * analysis or through maven dependency resolution. + * + * @param artifact Artifact from maven project + * @param projectDependencyAnalysis Maven Project Dependency Analysis data + * + * @return Component.Scope - REQUIRED, OPTIONAL or null if it cannot be determined + * + * @see useMavenOptionality + */ + private Component.Scope getComponentScope(Artifact artifact, ProjectDependencyAnalysis projectDependencyAnalysis) { + if (useMavenOptionality) { + return (artifact.isOptional() ? Component.Scope.OPTIONAL : Component.Scope.REQUIRED); + } else { + return inferComponentScope(artifact, projectDependencyAnalysis); + } + } + /** * Infer BOM component scope (required/optional/excluded) based on Maven project dependency analysis. * @@ -446,7 +477,7 @@ protected void populateComponents(final Set topLevelComponents, final Ma * * @return Component.Scope - REQUIRED: If the component is used (as detected by project dependency analysis). OPTIONAL: If it is unused */ - protected Component.Scope inferComponentScope(Artifact artifact, ProjectDependencyAnalysis projectDependencyAnalysis) { + private Component.Scope inferComponentScope(Artifact artifact, ProjectDependencyAnalysis projectDependencyAnalysis) { if (projectDependencyAnalysis == null) { return null; } diff --git a/src/main/java/org/cyclonedx/maven/CycloneDxMojo.java b/src/main/java/org/cyclonedx/maven/CycloneDxMojo.java index 83294d31..8b7ca238 100644 --- a/src/main/java/org/cyclonedx/maven/CycloneDxMojo.java +++ b/src/main/java/org/cyclonedx/maven/CycloneDxMojo.java @@ -83,6 +83,9 @@ private ProjectDependencyAnalyzer getProjectDependencyAnalyzer() throws MojoExec } protected ProjectDependencyAnalysis doProjectDependencyAnalysis(final MavenProject mavenProject, final BomDependencies bomDependencies) throws MojoExecutionException { + if (useMavenOptionality) { + return null; + } final MavenProject localMavenProject = new MavenProject(mavenProject); localMavenProject.setArtifacts(new LinkedHashSet<>(bomDependencies.getArtifacts().values())); localMavenProject.setDependencyArtifacts(new LinkedHashSet<>(bomDependencies.getDependencyArtifacts().values())); diff --git a/src/test/java/org/cyclonedx/maven/BaseMavenVerifier.java b/src/test/java/org/cyclonedx/maven/BaseMavenVerifier.java index c3dd951d..49283c78 100644 --- a/src/test/java/org/cyclonedx/maven/BaseMavenVerifier.java +++ b/src/test/java/org/cyclonedx/maven/BaseMavenVerifier.java @@ -10,6 +10,7 @@ import java.io.FileInputStream; import java.io.IOException; import java.io.InputStreamReader; +import java.util.Map; import java.util.Properties; import org.junit.Rule; @@ -55,10 +56,14 @@ protected File cleanAndBuild(final String project, final String[] excludeTypes) } protected File cleanAndBuild(final String project, final String[] excludeTypes, final String[] profiles) throws Exception { - return mvnBuild(project, null, excludeTypes, null); + return mvnBuild(project, null, excludeTypes, profiles, null); } - protected File mvnBuild(final String project, final String[] goals, final String[] excludeTypes, final String[] profiles) throws Exception { + protected File cleanAndBuild(final String project, final Map properties, final String[] excludeTypes) throws Exception { + return mvnBuild(project, null, excludeTypes, null, properties); + } + + protected File mvnBuild(final String project, final String[] goals, final String[] excludeTypes, final String[] profiles, final Map properties) throws Exception { File projDir = resources.getBasedir(project); MavenExecution execution = verifier @@ -70,7 +75,12 @@ protected File mvnBuild(final String project, final String[] goals, final String execution = execution.withCliOption("-DexcludeTypes=" + String.join(",", excludeTypes)); } if ((profiles != null) && (profiles.length > 0)) { - execution = execution.withCliOption("-P" + String.join(",", profiles)); + execution.withCliOption("-P" + String.join(",", profiles)); + } + if (properties != null) { + for (Map.Entry entry: properties.entrySet()) { + execution.withCliOption("-D" + entry.getKey() + "=" + entry.getValue()); + } } if (goals != null && goals.length > 0) { execution.execute(goals).assertErrorFreeLog(); diff --git a/src/test/java/org/cyclonedx/maven/CyclicTest.java b/src/test/java/org/cyclonedx/maven/CyclicTest.java index c0dd4221..fe7d790d 100644 --- a/src/test/java/org/cyclonedx/maven/CyclicTest.java +++ b/src/test/java/org/cyclonedx/maven/CyclicTest.java @@ -43,7 +43,7 @@ public void testCyclicDependency() throws Exception { cleanAndBuild("cyclic", null); File projDir = null; try { - projDir = mvnBuild("cyclic", new String[]{"package"}, null, new String[] {"profile"}); + projDir = mvnBuild("cyclic", new String[]{"package"}, null, new String[] {"profile"}, null); } catch (final Exception ex) { fail("Failed to generate SBOM", ex); } diff --git a/src/test/java/org/cyclonedx/maven/Issue311Test.java b/src/test/java/org/cyclonedx/maven/Issue311Test.java index d72e9ff7..56f78bda 100644 --- a/src/test/java/org/cyclonedx/maven/Issue311Test.java +++ b/src/test/java/org/cyclonedx/maven/Issue311Test.java @@ -39,7 +39,7 @@ public Issue311Test(MavenRuntimeBuilder runtimeBuilder) throws Exception { @Test public void testLatestAndRelease() throws Exception { - final File projDir = mvnBuild("issue-311", new String[]{"clean", "install"}, null, null); + final File projDir = mvnBuild("issue-311", new String[]{"clean", "install"}, null, null, null); checkLatest(projDir); checkRelease(projDir); diff --git a/src/test/java/org/cyclonedx/maven/Issue314Test.java b/src/test/java/org/cyclonedx/maven/Issue314Test.java new file mode 100644 index 00000000..a1209ef2 --- /dev/null +++ b/src/test/java/org/cyclonedx/maven/Issue314Test.java @@ -0,0 +1,112 @@ +package org.cyclonedx.maven; + +import java.io.File; +import java.util.HashMap; +import java.util.Map; + +import static org.cyclonedx.maven.TestUtils.getComponentNode; +import static org.cyclonedx.maven.TestUtils.getElement; +import static org.cyclonedx.maven.TestUtils.readXML; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import org.cyclonedx.model.Component; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +import io.takari.maven.testing.executor.MavenRuntime.MavenRuntimeBuilder; +import io.takari.maven.testing.executor.MavenVersions; +import io.takari.maven.testing.executor.junit.MavenJUnitTestRunner; + +/** + * Fix BOM handling of conflicting dependency tree graphs + */ +@RunWith(MavenJUnitTestRunner.class) +@MavenVersions({"3.6.3"}) +public class Issue314Test extends BaseMavenVerifier { + + private static final String ISSUE_314_DEPENDENCY_B = "pkg:maven/com.example.issue_314/dependency_B@1.0.0?type=jar"; + private static final String ISSUE_314_DEPENDENCY_C = "pkg:maven/com.example.issue_314/dependency_C@1.0.0?type=jar"; + private static final String ISSUE_314_DEPENDENCY_D = "pkg:maven/com.example.issue_314/dependency_D@1.0.0?type=jar"; + + public Issue314Test(MavenRuntimeBuilder runtimeBuilder) throws Exception { + super(runtimeBuilder); + } + + /** + * Validate the bytecode analysis components. + * - No component should be marked as optional + */ + @Test + public void testBytecodeDependencyTree() throws Exception { + final Map properties = new HashMap<>(); + properties.put("useMavenOptionality", "false"); + final File projDir = mvnBuild("issue-314", null, null, null, properties); + + final String requiredName = Component.Scope.REQUIRED.getScopeName(); + + final Document bom = readXML(new File(projDir, "dependency_A/target/bom.xml")); + + final NodeList componentsList = bom.getElementsByTagName("components"); + assertEquals("Expected a single components element", 1, componentsList.getLength()); + final Element components = (Element)componentsList.item(0); + + final Element componentBNode = getComponentNode(components, ISSUE_314_DEPENDENCY_B); + final Element componentBScope = getElement(componentBNode, "scope"); + if (componentBScope != null) { + assertEquals("dependency_B scope should be " + requiredName, requiredName, componentBScope.getTextContent()); + } + + final Element componentCNode = getComponentNode(components, ISSUE_314_DEPENDENCY_C); + final Element componentCScope = getElement(componentCNode, "scope"); + if (componentCScope != null) { + assertEquals("dependency_C scope should be " + requiredName, requiredName, componentCScope.getTextContent()); + } + + final Element componentDNode = getComponentNode(components, ISSUE_314_DEPENDENCY_D); + final Element componentDScope = getElement(componentDNode, "scope"); + if (componentDScope != null) { + assertEquals("dependency_D scope should be " + requiredName, requiredName, componentDScope.getTextContent()); + } + } + + /** + * Validate the bytecode analysis components. + * - com.example.issue_314:dependency_C:1.0.0 and com.example.issue_314:dependency_D:1.0.0 *should* be marked as optional + */ + @Test + public void testMavenOptionalityDependencyTree() throws Exception { + final Map properties = new HashMap<>(); + properties.put("useMavenOptionality", "true"); + final File projDir = mvnBuild("issue-314", null, null, null, properties); + + final String requiredName = Component.Scope.REQUIRED.getScopeName(); + final String optionalName = Component.Scope.OPTIONAL.getScopeName(); + + final Document bom = readXML(new File(projDir, "dependency_A/target/bom.xml")); + + final NodeList componentsList = bom.getElementsByTagName("components"); + assertEquals("Expected a single components element", 1, componentsList.getLength()); + final Element components = (Element)componentsList.item(0); + + final Element componentBNode = getComponentNode(components, ISSUE_314_DEPENDENCY_B); + final Element componentBScope = getElement(componentBNode, "scope"); + if (componentBScope != null) { + assertEquals("dependency_B scope should be " + requiredName, requiredName, componentBScope.getTextContent()); + } + + final Element componentCNode = getComponentNode(components, ISSUE_314_DEPENDENCY_C); + final Element componentCScope = getElement(componentCNode, "scope"); + assertNotNull("dependency_C is missing its scope", componentCScope); + assertEquals("dependency_C scope should be " + optionalName, optionalName, componentCScope.getTextContent()); + + final Element componentDNode = getComponentNode(components, ISSUE_314_DEPENDENCY_D); + final Element componentDScope = getElement(componentDNode, "scope"); + assertNotNull("dependency_D is missing its scope", componentDScope); + assertEquals("dependency_D scope should be " + optionalName, optionalName, componentDScope.getTextContent()); + } +} diff --git a/src/test/java/org/cyclonedx/maven/TestUtils.java b/src/test/java/org/cyclonedx/maven/TestUtils.java index bb9731b4..0f6cc2e7 100644 --- a/src/test/java/org/cyclonedx/maven/TestUtils.java +++ b/src/test/java/org/cyclonedx/maven/TestUtils.java @@ -10,20 +10,38 @@ import javax.xml.parsers.ParserConfigurationException; import org.w3c.dom.Document; +import org.w3c.dom.Element; import org.w3c.dom.Node; import org.w3c.dom.NodeList; import org.xml.sax.SAXException; class TestUtils { - static Node getDependencyNode(final Node dependencies, final String ref) { + static Element getElement(final Element parent, final String elementName) throws Exception { + Element element = null; + Node child = parent.getFirstChild(); + while (child != null) { + if (Node.ELEMENT_NODE == child.getNodeType()) { + if (child.getNodeName().equals(elementName)) { + if (element != null) { + throw new Exception("Second instance of element " + elementName + " discovered in " + parent.getNodeName()); + } + element = (Element)child; + } + } + child = child.getNextSibling(); + } + return element; + } + + static Element getDependencyNode(final Node dependencies, final String ref) { return getChildElement(dependencies, ref, "dependency", "ref"); } - static Node getComponentNode(final Node components, final String ref) { + static Element getComponentNode(final Node components, final String ref) { return getChildElement(components, ref, "component", "bom-ref"); } - private static Node getChildElement(final Node parent, final String ref, final String elementName, final String attrName) { + private static Element getChildElement(final Node parent, final String ref, final String elementName, final String attrName) { final NodeList children = parent.getChildNodes(); final int numChildNodes = children.getLength(); for (int index = 0 ; index < numChildNodes ; index++) { @@ -31,7 +49,7 @@ private static Node getChildElement(final Node parent, final String ref, final S if ((child.getNodeType() == Node.ELEMENT_NODE) && elementName.equals(child.getNodeName())) { final Node refNode = child.getAttributes().getNamedItem(attrName); if (ref.equals(refNode.getNodeValue())) { - return child; + return (Element)child; } } } diff --git a/src/test/resources/issue-314/dependency_A/pom.xml b/src/test/resources/issue-314/dependency_A/pom.xml new file mode 100644 index 00000000..27ba8495 --- /dev/null +++ b/src/test/resources/issue-314/dependency_A/pom.xml @@ -0,0 +1,68 @@ + + + + 4.0.0 + + + com.example.issue_314 + issue_314_parent + 1.0.0 + + + dependency_A + + Dependency A + + + 1.8 + 1.8 + + + + + com.example.issue_314 + dependency_B + 1.0.0 + compile + + + com.example.issue_314 + dependency_C + 1.0.0 + provided + true + + + + + + + org.cyclonedx + cyclonedx-maven-plugin + ${current.version} + + + package + + makeBom + + + + + library + 1.4 + true + true + true + false + false + false + false + xml + + + + + diff --git a/src/test/resources/issue-314/dependency_A/src/main/java/com/example/issue_314/liba/LibA.java b/src/test/resources/issue-314/dependency_A/src/main/java/com/example/issue_314/liba/LibA.java new file mode 100644 index 00000000..0bda872f --- /dev/null +++ b/src/test/resources/issue-314/dependency_A/src/main/java/com/example/issue_314/liba/LibA.java @@ -0,0 +1,18 @@ +package com.example.issue_314.liba; + +import com.example.issue_314.libb.LibB; +import com.example.issue_314.libc.LibC; + +public class LibA { + private LibA() {} + + public static void main(final String[] args) { + System.out.println("In libA"); + LibB.libBMethod(); + try { + LibC.libCMethod(); + } catch (final NoClassDefFoundError ncdfe) { + System.out.println("Optional library libC not present on classpath"); + } + } +} \ No newline at end of file diff --git a/src/test/resources/issue-314/dependency_B/pom.xml b/src/test/resources/issue-314/dependency_B/pom.xml new file mode 100644 index 00000000..603b8a1b --- /dev/null +++ b/src/test/resources/issue-314/dependency_B/pom.xml @@ -0,0 +1,22 @@ + + + + 4.0.0 + + + com.example.issue_314 + issue_314_parent + 1.0.0 + + + dependency_B + + Dependency B + + + 1.8 + 1.8 + + diff --git a/src/test/resources/issue-314/dependency_B/src/main/java/com/example/issue_314/libb/LibB.java b/src/test/resources/issue-314/dependency_B/src/main/java/com/example/issue_314/libb/LibB.java new file mode 100644 index 00000000..c9741b46 --- /dev/null +++ b/src/test/resources/issue-314/dependency_B/src/main/java/com/example/issue_314/libb/LibB.java @@ -0,0 +1,9 @@ +package com.example.issue_314.libb; + +public class LibB { + private LibB() {} + + public static void libBMethod() { + System.out.println("In libB"); + } +} \ No newline at end of file diff --git a/src/test/resources/issue-314/dependency_C/pom.xml b/src/test/resources/issue-314/dependency_C/pom.xml new file mode 100644 index 00000000..0a45e02d --- /dev/null +++ b/src/test/resources/issue-314/dependency_C/pom.xml @@ -0,0 +1,31 @@ + + + + 4.0.0 + + + com.example.issue_314 + issue_314_parent + 1.0.0 + + + dependency_C + + Dependency C + + + 1.8 + 1.8 + + + + + com.example.issue_314 + dependency_D + 1.0.0 + compile + + + diff --git a/src/test/resources/issue-314/dependency_C/src/main/java/com/example/issue_314/libc/LibC.java b/src/test/resources/issue-314/dependency_C/src/main/java/com/example/issue_314/libc/LibC.java new file mode 100644 index 00000000..bfeb36cd --- /dev/null +++ b/src/test/resources/issue-314/dependency_C/src/main/java/com/example/issue_314/libc/LibC.java @@ -0,0 +1,12 @@ +package com.example.issue_314.libc; + +import com.example.issue_314.libd.LibD; + +public class LibC { + private LibC() {} + + public static void libCMethod() { + System.out.println("In libC"); + LibD.libDMethod(); + } +} \ No newline at end of file diff --git a/src/test/resources/issue-314/dependency_D/pom.xml b/src/test/resources/issue-314/dependency_D/pom.xml new file mode 100644 index 00000000..73d747ad --- /dev/null +++ b/src/test/resources/issue-314/dependency_D/pom.xml @@ -0,0 +1,22 @@ + + + + 4.0.0 + + + com.example.issue_314 + issue_314_parent + 1.0.0 + + + dependency_D + + Dependency D + + + 1.8 + 1.8 + + diff --git a/src/test/resources/issue-314/dependency_D/src/main/java/com/example/issue_314/libd/LibD.java b/src/test/resources/issue-314/dependency_D/src/main/java/com/example/issue_314/libd/LibD.java new file mode 100644 index 00000000..6cd69774 --- /dev/null +++ b/src/test/resources/issue-314/dependency_D/src/main/java/com/example/issue_314/libd/LibD.java @@ -0,0 +1,9 @@ +package com.example.issue_314.libd; + +public class LibD { + private LibD() {} + + public static void libDMethod() { + System.out.println("In libD"); + } +} \ No newline at end of file diff --git a/src/test/resources/issue-314/pom.xml b/src/test/resources/issue-314/pom.xml new file mode 100644 index 00000000..64d8cb97 --- /dev/null +++ b/src/test/resources/issue-314/pom.xml @@ -0,0 +1,26 @@ + + + + 4.0.0 + + com.example.issue_314 + issue_314_parent + pom + 1.0.0 + + Optionality Tests Parent + + + dependency_A + dependency_B + dependency_C + dependency_D + + + + UTF-8 + + +