Repository: sw360/capycli (language: python)

Issue:
"project createBom" doesn't handle multiple purls
After fixing #26 in `main`, we still lack correct handling of multiple purls. CaPyCli silently takes the JSON-encoded string containing the array, so we get such a BOM:

```json
    { 
      "type": "library",
      "bom-ref": "[\"pkg:deb/debian/libacl1@2.2.52-3\",\"pkg:deb/debian/acl@2.2.52-3?arch=source\"]",
      "name": "acl",
      "version": "2.2.52-3.debian",
      "purl": "[\"pkg:deb/debian/libacl1@2.2.52-3\",\"pkg:deb/debian/acl@2.2.52-3?arch=source\"]",
```

I also think there's no perfect solution as CycloneDX allows only one purl per component, but we should at least warn the user and probably make it easy for him to select the right purl, e.g. by adding them separated by space?
"project createBom" doesn't handle multiple purls
After fixing #26 in `main`, we still lack correct handling of multiple purls. CaPyCli silently takes the JSON-encoded string containing the array, so we get such a BOM:

```json
    { 
      "type": "library",
      "bom-ref": "[\"pkg:deb/debian/libacl1@2.2.52-3\",\"pkg:deb/debian/acl@2.2.52-3?arch=source\"]",
      "name": "acl",
      "version": "2.2.52-3.debian",
      "purl": "[\"pkg:deb/debian/libacl1@2.2.52-3\",\"pkg:deb/debian/acl@2.2.52-3?arch=source\"]",
```

I also think there's no perfect solution as CycloneDX allows only one purl per component, but we should at least warn the user and probably make it easy for him to select the right purl, e.g. by adding them separated by space?


Discussion / hints:

Fix patch:
diff --git a/ChangeLog.md b/ChangeLog.md
index 137b905..d7e05f8 100644
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -10,7 +10,8 @@
 * Be more resilient about missing metadata in CycloneDX SBOMs.
 * The `-o` parameter of the command `project GetLicenseInfo` is now optional.
   But you still need this output when you want to create a Readme.
-* `project createbom` add purl, source and repository url from SW360 if available
+* `project createbom` add purls, source and repository url from SW360 if available.
+  If multiple purls are found, a warning is printed asking user to manually edit SBOM.
 * `project createbom` add SW360 source and binary attachments as external reference to SBOM.
 * `project createbom` adds SW360 project name, version and description to SBOM.
 
@@ -27,7 +28,7 @@
   * `bom map` will report matches by name, but different version **only** if `-all` has been specified.
     The original idea of CaPyCLI was to report as many potential matches as possible and to let the user
     decide which match to take by editing the SBOM. But it seems that many users did not read the documentation
-    and the expectations were different. Therefore the default behavior has been changed.  
+    and the expectations were different. Therefore the default behavior has been changed.
     The original behavior of versions prior to 2.x can be enabled via the `-all` switch.
 
 ## 2.0.0.dev (2023-05-19)
diff --git a/capycli/project/create_bom.py b/capycli/project/create_bom.py
index c22817b..4834729 100644
--- a/capycli/project/create_bom.py
+++ b/capycli/project/create_bom.py
@@ -17,6 +17,7 @@
 import capycli.common.script_base
 from capycli import get_logger
 from capycli.common.capycli_bom_support import CaPyCliBom, CycloneDxSupport, SbomCreator
+from capycli.common.purl_utils import PurlUtils
 from capycli.common.print import print_red, print_text, print_yellow
 from capycli.main.result_codes import ResultCode
 
@@ -59,6 +60,12 @@ def create_project_bom(self, project) -> list:
                     # try another id name
                     purl = self.get_external_id("purl", release_details)
 
+                purl = PurlUtils.parse_purls_from_external_id(purl)
+                if len(purl) > 1:
+                    print_yellow("      Multiple purls added for", release["name"], release["version"])
+                    print_yellow("      You must remove all but one in your SBOM!")
+                purl = " ".join(purl)
+
                 if purl:
                     rel_item = Component(name=release["name"], version=release["version"], purl=purl, bom_ref=purl)
                 else:


Test patch:
diff --git a/tests/test_create_bom.py b/tests/test_create_bom.py
index b23011c..da14515 100644
--- a/tests/test_create_bom.py
+++ b/tests/test_create_bom.py
@@ -126,6 +126,44 @@ def test_project_not_found(self) -> None:
         except SystemExit as ex:
             self.assertEqual(ResultCode.RESULT_ERROR_ACCESSING_SW360, ex.code)
 
+    @responses.activate
+    def test_create_bom_multiple_purls(self):
+        sut = CreateBom()
+
+        self.add_login_response()
+        sut.login(token=TestBase.MYTOKEN, url=TestBase.MYURL)
+
+        # the first release
+        responses.add(
+            responses.GET,
+            url=self.MYURL + "resource/api/releases/r001",
+            json=self.get_release_wheel_for_test(),
+            status=200,
+            content_type="application/json",
+            adding_headers={"Authorization": "Token " + self.MYTOKEN},
+        )
+
+        # the second release
+        release = self.get_release_cli_for_test()
+        # use a specific purl
+        release["externalIds"]["package-url"] = "[\"pkg:deb/debian/cli-support@1.3-1\",\"pkg:pypi/cli-support@1.3\"]"
+        responses.add(
+            responses.GET,
+            url=self.MYURL + "resource/api/releases/r002",
+            json=release,
+            status=200,
+            content_type="application/json",
+            adding_headers={"Authorization": "Token " + self.MYTOKEN},
+        )
+
+        out = self.capture_stdout(sut.create_project_bom, self.get_project_for_test())
+        self.assertIn("Multiple purls added", out)
+
+        # TODO self.capture_stdout doesn't allow us to get return value,
+        # so re-run test. See also https://github.com/sw360/capycli/issues/39
+        cdx_components = sut.create_project_bom(self.get_project_for_test())
+        self.assertEqual(cdx_components[0].purl, "pkg:deb/debian/cli-support@1.3-1 pkg:pypi/cli-support@1.3")
+
     @responses.activate
     def test_project_by_id(self):
         sut = CreateBom()