Repository: CycloneDX/cyclonedx-maven-plugin (java)

Issue:
Cyclic Dependency when project depends on itself
Maven normally excludes cyclic dependencies, however there is one case when this is allowed.  This results in recursion leading to a StackOverflowError.

This was first discovered when generating a bom for the `netty-tcnative` project which includes `netty-tcnative-boringssl-static`. 
 The `netty-tcnative-boringssl-static` project has dependencies on itself, albeit with individual qualifiers (such as `linux-x86_64`) for each of its supported binaries.


Pull request description:
Fixes #307, addresses cyclic dependencies created by self references
issue described in #307 

Patch:
diff --git a/src/main/java/org/cyclonedx/maven/DefaultModelConverter.java b/src/main/java/org/cyclonedx/maven/DefaultModelConverter.java
index 3a2b6ba..14bb96d 100644
--- a/src/main/java/org/cyclonedx/maven/DefaultModelConverter.java
+++ b/src/main/java/org/cyclonedx/maven/DefaultModelConverter.java
@@ -105,26 +105,31 @@ public class DefaultModelConverter implements ModelConverter {
     }
 
     public String generatePackageUrl(final org.eclipse.aether.artifact.Artifact artifact) {
-        return generatePackageUrl(artifact, true);
+        return generatePackageUrl(artifact, true, true);
     }
 
     public String generateVersionlessPackageUrl(final org.eclipse.aether.artifact.Artifact artifact) {
-        return generatePackageUrl(artifact, false);
+        return generatePackageUrl(artifact, false, true);
+    }
+
+    public String generateClassifierlessPackageUrl(final org.eclipse.aether.artifact.Artifact artifact) {
+        return generatePackageUrl(artifact, true, false);
     }
 
     private boolean isEmpty(final String value) {
         return (value == null) || (value.length() == 0);
     }
-    private String generatePackageUrl(final org.eclipse.aether.artifact.Artifact artifact, final boolean includeVersion) {
+
+    private String generatePackageUrl(final org.eclipse.aether.artifact.Artifact artifact, final boolean includeVersion, final boolean includeClassifier) {
         TreeMap<String, String> qualifiers = null;
         final String type = artifact.getProperties().get(ArtifactProperties.TYPE);
         final String classifier = artifact.getClassifier();
-        if (!isEmpty(type) || !isEmpty(classifier)) {
+        if (!isEmpty(type) || (includeClassifier && !isEmpty(classifier))) {
             qualifiers = new TreeMap<>();
             if (!isEmpty(type)) {
                 qualifiers.put("type", type);
             }
-            if (!isEmpty(classifier)) {
+            if (includeClassifier && !isEmpty(classifier)) {
                 qualifiers.put("classifier", classifier);
             }
         }
diff --git a/src/main/java/org/cyclonedx/maven/DefaultProjectDependenciesConverter.java b/src/main/java/org/cyclonedx/maven/DefaultProjectDependenciesConverter.java
index 090870b..f20f8d2 100644
--- a/src/main/java/org/cyclonedx/maven/DefaultProjectDependenciesConverter.java
+++ b/src/main/java/org/cyclonedx/maven/DefaultProjectDependenciesConverter.java
@@ -87,7 +87,8 @@ public class DefaultProjectDependenciesConverter implements ProjectDependenciesC
             // Generate the tree, removing excluded and filtered nodes
             final Set<String> loggedReplacementPUrls = new HashSet<>();
             final Set<String> loggedFilteredArtifacts = new HashSet<>();
-            buildDependencyGraphNode(dependencies, root, null, resolvedPUrls, loggedReplacementPUrls, loggedFilteredArtifacts);
+
+            buildDependencyGraphNode(dependencies, root, null, null, resolvedPUrls, loggedReplacementPUrls, loggedFilteredArtifacts);
         } catch (DependencyCollectorBuilderException e) {
             // When executing makeAggregateBom, some projects may not yet be built. Workaround is to warn on this
             // rather than throwing an exception https://github.com/CycloneDX/cyclonedx-maven-plugin/issues/55
@@ -142,8 +143,9 @@ public class DefaultProjectDependenciesConverter implements ProjectDependenciesC
         return ((type == null) || excludeTypesSet.contains(type));
     }
 
-    private void buildDependencyGraphNode(final Map<Dependency, Dependency> dependencies, DependencyNode node, final Dependency parent,
-            final Map<String, String> resolvedPUrls, final Set<String> loggedReplacementPUrls, final Set<String> loggedFilteredArtifacts) {
+    private void buildDependencyGraphNode(final Map<Dependency, Dependency> dependencies, DependencyNode node,
+            final Dependency parent, final String parentClassifierlessPUrl, final Map<String, String> resolvedPUrls,
+            final Set<String> loggedReplacementPUrls, final Set<String> loggedFilteredArtifacts) {
         String purl = modelConverter.generatePackageUrl(node.getArtifact());
 
         if (isExcludedNode(node) || (parent != null && isFilteredNode(node, loggedFilteredArtifacts))) {
@@ -177,8 +179,12 @@ public class DefaultProjectDependenciesConverter implements ProjectDependenciesC
         if (parent != null) {
             parent.addDependency(new Dependency(purl));
         }
-        for (final DependencyNode childrenNode : node.getChildren()) {
-            buildDependencyGraphNode(dependencies, childrenNode, topDependency, resolvedPUrls, loggedReplacementPUrls, loggedFilteredArtifacts);
+
+        final String nodeClassifierlessPUrl = modelConverter.generateClassifierlessPackageUrl(node.getArtifact());
+        if (!nodeClassifierlessPUrl.equals(parentClassifierlessPUrl)) {
+            for (final DependencyNode childrenNode : node.getChildren()) {
+                buildDependencyGraphNode(dependencies, childrenNode, topDependency, nodeClassifierlessPUrl, resolvedPUrls, loggedReplacementPUrls, loggedFilteredArtifacts);
+            }
         }
     }
 
diff --git a/src/main/java/org/cyclonedx/maven/ModelConverter.java b/src/main/java/org/cyclonedx/maven/ModelConverter.java
index e051d3d..675a0d1 100644
--- a/src/main/java/org/cyclonedx/maven/ModelConverter.java
+++ b/src/main/java/org/cyclonedx/maven/ModelConverter.java
@@ -37,6 +37,8 @@ public interface ModelConverter {
 
     String generateVersionlessPackageUrl(final org.eclipse.aether.artifact.Artifact artifact);
 
+    String generateClassifierlessPackageUrl(final org.eclipse.aether.artifact.Artifact artifact);
+
     /**
      * Converts a Maven artifact (dependency or transitive dependency) into a
      * CycloneDX component.


Test patch:
diff --git a/src/test/java/org/cyclonedx/maven/BaseMavenVerifier.java b/src/test/java/org/cyclonedx/maven/BaseMavenVerifier.java
index 073de4f..c3dd951 100644
--- a/src/test/java/org/cyclonedx/maven/BaseMavenVerifier.java
+++ b/src/test/java/org/cyclonedx/maven/BaseMavenVerifier.java
@@ -51,21 +51,32 @@ public class BaseMavenVerifier {
     }
 
     protected File cleanAndBuild(final String project, final String[] excludeTypes) throws Exception {
+        return cleanAndBuild(project, excludeTypes, null);
+    }
+
+    protected File cleanAndBuild(final String project, final String[] excludeTypes, final String[] profiles) throws Exception {
+        return mvnBuild(project, null, excludeTypes, null);
+    }
+
+    protected File mvnBuild(final String project, final String[] goals, final String[] excludeTypes, final String[] profiles) throws Exception {
         File projDir = resources.getBasedir(project);
 
-        final MavenExecution initExecution = verifier
+        MavenExecution execution = verifier
                 .forProject(projDir)
                 .withCliOption("-Dcurrent.version=" + getCurrentVersion()) // inject cyclonedx-maven-plugin version
                 .withCliOption("-X") // debug
                 .withCliOption("-B");
-        final MavenExecution execution;
         if ((excludeTypes != null) && (excludeTypes.length > 0)) {
-            execution = initExecution.withCliOption("-DexcludeTypes=" + String.join(",", excludeTypes));
+            execution = execution.withCliOption("-DexcludeTypes=" + String.join(",", excludeTypes));
+        }
+        if ((profiles != null) && (profiles.length > 0)) {
+            execution = execution.withCliOption("-P" + String.join(",", profiles));
+        }
+        if (goals != null && goals.length > 0) {
+            execution.execute(goals).assertErrorFreeLog();
         } else {
-            execution = initExecution;
+            execution.execute("clean", "package").assertErrorFreeLog();
         }
-        execution.execute("clean", "package")
-            .assertErrorFreeLog();
         return projDir;
     }
 }
diff --git a/src/test/java/org/cyclonedx/maven/CyclicTest.java b/src/test/java/org/cyclonedx/maven/CyclicTest.java
new file mode 100644
index 0000000..66427c4
--- /dev/null
+++ b/src/test/java/org/cyclonedx/maven/CyclicTest.java
@@ -0,0 +1,113 @@
+package org.cyclonedx.maven;
+
+import java.io.File;
+import java.util.Set;
+
+import static org.cyclonedx.maven.TestUtils.getComponentNode;
+import static org.cyclonedx.maven.TestUtils.getDependencyNode;
+import static org.cyclonedx.maven.TestUtils.getDependencyReferences;
+import static org.cyclonedx.maven.TestUtils.readXML;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.w3c.dom.Document;
+import org.w3c.dom.Node;
+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;
+
+/**
+ * Test for cyclic dependencies on the same project
+ */
+@RunWith(MavenJUnitTestRunner.class)
+@MavenVersions({"3.8.7"})
+public class CyclicTest extends BaseMavenVerifier {
+    private static final String CYCLIC_A_DEPENDENCY = "pkg:maven/com.example.cyclic/cyclic_A@1.0.0?type=jar";
+    private static final String CYCLIC_A_DEPENDENCY_CLASSIFIER_1 = "pkg:maven/com.example.cyclic/cyclic_A@1.0.0?classifier=classifier_1&type=jar";
+    private static final String CYCLIC_A_DEPENDENCY_CLASSIFIER_2 = "pkg:maven/com.example.cyclic/cyclic_A@1.0.0?classifier=classifier_2&type=jar";
+    private static final String CYCLIC_A_DEPENDENCY_CLASSIFIER_3 = "pkg:maven/com.example.cyclic/cyclic_A@1.0.0?classifier=classifier_3&type=jar";
+
+    public CyclicTest(MavenRuntimeBuilder runtimeBuilder) throws Exception {
+        super(runtimeBuilder);
+    }
+
+    @Test
+    public void testCyclicDependency() throws Exception {
+        cleanAndBuild("cyclic", null);
+        File projDir = null;
+        try {
+            projDir = mvnBuild("cyclic", new String[]{"package"}, null, new String[] {"profile"});
+        } catch (final Exception ex) {
+            fail("Failed to generate SBOM", ex);
+        }
+
+        final Document bom = readXML(new File(projDir, "target/bom.xml"));
+
+        final NodeList componentsList = bom.getElementsByTagName("components");
+        assertEquals("Expected a single components element", 1, componentsList.getLength());
+        final Node components = componentsList.item(0);
+
+        final NodeList dependenciesList = bom.getElementsByTagName("dependencies");
+        assertEquals("Expected a single dependencies element", 1, dependenciesList.getLength());
+        final Node dependencies = dependenciesList.item(0);
+
+        // BOM should contain pkg:maven/com.example.cyclic/cyclic_A@1.0.0?classifier=classifier_1&type=jar
+        final Node cyclicAClassifier1ComponentNode = getComponentNode(components, CYCLIC_A_DEPENDENCY_CLASSIFIER_1);
+        assertNotNull("Missing cyclic_A:classifier_1:1.0.0 component", cyclicAClassifier1ComponentNode);
+
+        // BOM should contain pkg:maven/com.example.cyclic/cyclic_A@1.0.0?classifier=classifier_2&type=jar
+        final Node cyclicAClassifier2ComponentNode = getComponentNode(components, CYCLIC_A_DEPENDENCY_CLASSIFIER_2);
+        assertNotNull("Missing cyclic_A:classifier_2:1.0.0 component", cyclicAClassifier2ComponentNode);
+
+        // BOM should contain pkg:maven/com.example.cyclic/cyclic_A@1.0.0?classifier=classifier_3&type=jar
+        final Node cyclicAClassifier3ComponentNode = getComponentNode(components, CYCLIC_A_DEPENDENCY_CLASSIFIER_3);
+        assertNotNull("Missing cyclic_A:classifier_3:1.0.0 component", cyclicAClassifier3ComponentNode);
+
+        /*
+          <dependency ref="pkg:maven/com.example.cyclic/cyclic_A@1.0.0?type=jar">
+            <dependency ref="pkg:maven/com.example.cyclic/cyclic_A@1.0.0?classifier=classifier_1&amp;type=jar"/>
+            <dependency ref="pkg:maven/com.example.cyclic/cyclic_A@1.0.0?classifier=classifier_2&amp;type=jar"/>
+            <dependency ref="pkg:maven/com.example.cyclic/cyclic_A@1.0.0?classifier=classifier_3&amp;type=jar"/>
+          </dependency>
+        */
+        final Node cyclicADependencyNode = getDependencyNode(dependencies, CYCLIC_A_DEPENDENCY);
+        assertNotNull("Missing cyclic_A:1.0.0 dependency", cyclicADependencyNode);
+        Set<String> cyclicADependencies = getDependencyReferences(cyclicADependencyNode);
+        assertEquals("Invalid dependency count for cyclic_A:1.0.0", 3, cyclicADependencies.size());
+        assertTrue("Missing cyclic_A:classifier_1:1.0.0 dependency for cyclic_A:1.0.0", cyclicADependencies.contains(CYCLIC_A_DEPENDENCY_CLASSIFIER_1));
+        assertTrue("Missing cyclic_A:classifier_2:1.0.0 dependency for cyclic_A:1.0.0", cyclicADependencies.contains(CYCLIC_A_DEPENDENCY_CLASSIFIER_2));
+        assertTrue("Missing cyclic_A:classifier_3:1.0.0 dependency for cyclic_A:1.0.0", cyclicADependencies.contains(CYCLIC_A_DEPENDENCY_CLASSIFIER_3));
+
+        /*
+          <dependency ref="pkg:maven/com.example.cyclic/cyclic_A@1.0.0?classifier=classifier_1&amp;type=jar"/>
+         */
+        final Node cyclicAClassifier1DependencyNode = getDependencyNode(dependencies, CYCLIC_A_DEPENDENCY_CLASSIFIER_1);
+        assertNotNull("Missing cyclic_A:classifier_1:1.0.0 dependency", cyclicAClassifier1DependencyNode);
+        Set<String> cyclicAClassifier1Dependencies = getDependencyReferences(cyclicAClassifier1DependencyNode);
+        assertEquals("Invalid dependency count for cyclic_A:classifier_1:1.0.0", 0, cyclicAClassifier1Dependencies.size());
+
+        /*
+          <dependency ref="pkg:maven/com.example.cyclic/cyclic_A@1.0.0?classifier=classifier_2&amp;type=jar"/>
+         */
+        final Node cyclicAClassifier2DependencyNode = getDependencyNode(dependencies, CYCLIC_A_DEPENDENCY_CLASSIFIER_2);
+        assertNotNull("Missing cyclic_A:classifier_2:1.0.0 dependency", cyclicAClassifier2DependencyNode);
+        Set<String> cyclicAClassifier2Dependencies = getDependencyReferences(cyclicAClassifier2DependencyNode);
+        assertEquals("Invalid dependency count for cyclic_A:classifier_2:1.0.0", 0, cyclicAClassifier2Dependencies.size());
+
+        /*
+          <dependency ref="pkg:maven/com.example.cyclic/cyclic_A@1.0.0?classifier=classifier_3&amp;type=jar"/>
+         */
+        final Node cyclicAClassifier3DependencyNode = getDependencyNode(dependencies, CYCLIC_A_DEPENDENCY_CLASSIFIER_3);
+        assertNotNull("Missing cyclic_A:classifier_3:1.0.0 dependency", cyclicAClassifier3DependencyNode);
+        Set<String> cyclicAClassifier3Dependencies = getDependencyReferences(cyclicAClassifier3DependencyNode);
+        assertEquals("Invalid dependency count for cyclic_A:classifier_3:1.0.0", 0, cyclicAClassifier3Dependencies.size());
+    }
+}
diff --git a/src/test/resources/cyclic/pom.xml b/src/test/resources/cyclic/pom.xml
new file mode 100644
index 0000000..4b30743
--- /dev/null
+++ b/src/test/resources/cyclic/pom.xml
@@ -0,0 +1,150 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+
+    <modelVersion>4.0.0</modelVersion>
+
+    <groupId>com.example.cyclic</groupId>
+    <artifactId>cyclic_A</artifactId>
+    <version>1.0.0</version>
+
+    <name>Cyclic Dependency A</name>
+
+    <profiles>
+        <profile>
+            <id>profile</id>
+            <dependencies>
+                  <dependency>
+                      <groupId>com.example.cyclic</groupId>
+                      <artifactId>cyclic_A</artifactId>
+                      <version>1.0.0</version>
+                      <classifier>classifier_1</classifier>
+                  </dependency>
+                  <dependency>
+                      <groupId>com.example.cyclic</groupId>
+                      <artifactId>cyclic_A</artifactId>
+                      <version>1.0.0</version>
+                      <classifier>classifier_2</classifier>
+                  </dependency>
+                  <dependency>
+                      <groupId>com.example.cyclic</groupId>
+                      <artifactId>cyclic_A</artifactId>
+                      <version>1.0.0</version>
+                      <classifier>classifier_3</classifier>
+                  </dependency>
+            </dependencies>
+            <build>
+                <plugins>
+                    <plugin>
+                        <groupId>org.cyclonedx</groupId>
+                        <artifactId>cyclonedx-maven-plugin</artifactId>
+                        <version>${current.version}</version>
+                        <executions>
+                            <execution>
+                                <phase>package</phase>
+                                <goals>
+                                    <goal>makeAggregateBom</goal>
+                                </goals>
+                            </execution>
+                        </executions>
+                        <configuration>
+                            <projectType>library</projectType>
+                            <schemaVersion>1.4</schemaVersion>
+                            <includeBomSerialNumber>true</includeBomSerialNumber>
+                            <includeCompileScope>true</includeCompileScope>
+                            <includeProvidedScope>true</includeProvidedScope>
+                            <includeRuntimeScope>true</includeRuntimeScope>
+                            <includeSystemScope>true</includeSystemScope>
+                            <includeTestScope>false</includeTestScope>
+                            <includeLicenseText>false</includeLicenseText>
+                            <outputFormat>xml</outputFormat>
+                        </configuration>
+                    </plugin>
+                </plugins>
+            </build>
+        </profile>
+    </profiles>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-jar-plugin</artifactId>
+                <version>3.3.0</version>
+                <executions>
+                    <execution>
+                      <id>classifier_1</id>
+                      <phase>package</phase>
+                      <goals>
+                          <goal>jar</goal>
+                      </goals>
+                      <configuration>
+                          <classifier>classifier_1</classifier>
+                      </configuration>
+                    </execution>
+                    <execution>
+                      <id>classifier_2</id>
+                      <phase>package</phase>
+                      <goals>
+                          <goal>jar</goal>
+                      </goals>
+                      <configuration>
+                          <classifier>classifier_2</classifier>
+                      </configuration>
+                    </execution>
+                    <execution>
+                      <id>classifier_3</id>
+                      <phase>package</phase>
+                      <goals>
+                          <goal>jar</goal>
+                      </goals>
+                      <configuration>
+                          <classifier>classifier_3</classifier>
+                      </configuration>
+                    </execution>
+                </executions>
+            </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-install-plugin</artifactId>
+                <version>3.1.0</version>
+                <executions>
+                    <execution>
+                        <id>classifier_1</id>
+                        <phase>package</phase>
+                        <goals>
+                            <goal>install-file</goal>
+                        </goals>
+                        <configuration>
+                            <classifier>classifier_1</classifier>
+                            <file>${project.build.directory}/cyclic_A-1.0.0-classifier_1.jar</file>
+                        </configuration>
+                    </execution>
+                    <execution>
+                        <id>classifier_2</id>
+                        <phase>package</phase>
+                        <goals>
+                            <goal>install-file</goal>
+                        </goals>
+                        <configuration>
+                            <classifier>classifier_2</classifier>
+                            <file>${project.build.directory}/cyclic_A-1.0.0-classifier_2.jar</file>
+                        </configuration>
+                    </execution>
+                    <execution>
+                        <id>classifier_3</id>
+                        <phase>package</phase>
+                        <goals>
+                            <goal>install-file</goal>
+                        </goals>
+                        <configuration>
+                            <classifier>classifier_3</classifier>
+                            <file>${project.build.directory}/cyclic_A-1.0.0-classifier_3.jar</file>
+                        </configuration>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+    </build>
+</project>