Repository: CycloneDX/cyclonedx-python

Issue:
bug: conda-parser raises error when unexpected build-number is detected
## Environment
XUbuntu 20.04
Python 3.8.10
conda 4.12.0 #miniconda
Name: cyclonedx-bom
Version: 3.1.0

## What I did
* update conda base
```
->$ conda update -n base -c defaults conda -y
Collecting package metadata (current_repodata.json): done
Solving environment: done

# All requested packages already installed.

>$ conda env list
# conda environments:
#
base                  *  /home/fis/miniconda3
```
* Create explicit list 
* [base_spec.txt](https://github.com/CycloneDX/cyclonedx-python/files/8289398/base_spec.txt)
```
>$ conda list -n base --explicit > ./base_spec.txt
```
* Create sbom from explicit list - Error
```
->$ cyclonedx-py -c -i ./base_spec.txt --format json --schema-version 1.3 -o ./conda_base_cyclonedx_1.3_sbom.json
Traceback (most recent call last):
  File "/home/fis/.local/bin/cyclonedx-py", line 8, in <module>
    sys.exit(main())
  File "/home/fis/.local/lib/python3.8/site-packages/cyclonedx_py/client.py", line 260, in main
    CycloneDxCmd(args).execute()
  File "/home/fis/.local/lib/python3.8/site-packages/cyclonedx_py/client.py", line 113, in execute
    output = self.get_output()
  File "/home/fis/.local/lib/python3.8/site-packages/cyclonedx_py/client.py", line 63, in get_output
    parser = self._get_input_parser()
  File "/home/fis/.local/lib/python3.8/site-packages/cyclonedx_py/client.py", line 244, in _get_input_parser
    return CondaListExplicitParser(conda_data=input_data)
  File "/home/fis/.local/lib/python3.8/site-packages/cyclonedx_py/parser/conda.py", line 41, in __init__
    self._parse_to_conda_packages(data_str=conda_data)
  File "/home/fis/.local/lib/python3.8/site-packages/cyclonedx_py/parser/conda.py", line 101, in _parse_to_conda_packages
    conda_package = parse_conda_list_str_to_conda_package(conda_list_str=line)
  File "/home/fis/.local/lib/python3.8/site-packages/cyclonedx_py/utils/conda.py", line 116, in parse_conda_list_str_to_conda_package
    build_number = int(build_number_with_opt_string)
ValueError: invalid literal for int() with base 10: 'main'

->$ cat base_spec.txt | grep "main\." 
https://repo.anaconda.com/pkgs/main/linux-64/_libgcc_mutex-0.1-main.conda
```

Discussion:

Patch:
diff --git a/cyclonedx_py/utils/conda.py b/cyclonedx_py/utils/conda.py
index 3cf2fc5..b5c26a0 100644
--- a/cyclonedx_py/utils/conda.py
+++ b/cyclonedx_py/utils/conda.py
@@ -20,15 +20,14 @@
 import json
 import sys
 from json import JSONDecodeError
-from typing import Optional
+from typing import Optional, Tuple
+from urllib.parse import urlparse
 
 if sys.version_info >= (3, 8):
     from typing import TypedDict
 else:
     from typing_extensions import TypedDict
 
-from urllib.parse import urlparse
-
 
 class CondaPackage(TypedDict):
     """
@@ -72,56 +71,72 @@ def parse_conda_list_str_to_conda_package(conda_list_str: str) -> Optional[Conda
 
     line = conda_list_str.strip()
 
-    if line[0:1] == '#' or line[0:1] == '@' or len(line) == 0:
+    if '' == line or line[0] in ['#', '@']:
         # Skip comments, @EXPLICT or empty lines
         return None
 
     # Remove any hash
     package_hash = None
     if '#' in line:
-        hash_parts = line.split('#')
-        if len(hash_parts) > 1:
-            package_hash = hash_parts.pop()
-            line = ''.join(hash_parts)
+        *_line_parts, package_hash = line.split('#')
+        line = ''.join(*_line_parts)
 
     package_parts = line.split('/')
-    package_name_version_build_string = package_parts.pop()
-    package_arch = package_parts.pop()
-    package_url = urlparse('/'.join(package_parts))
+    if len(package_parts) < 2:
+        raise ValueError(f'Unexpected format in {package_parts}')
+    *_package_url_parts, package_arch, package_name_version_build_string = package_parts
+    package_url = urlparse('/'.join(_package_url_parts))
 
-    try:
-        package_nvbs_parts = package_name_version_build_string.split('-')
-        build_number_with_opt_string = package_nvbs_parts.pop()
-        if '.' in build_number_with_opt_string:
-            # Remove any .conda at the end if present or other package type eg .tar.gz
-            pos = build_number_with_opt_string.find('.')
-            build_number_with_opt_string = build_number_with_opt_string[0:pos]
-
-        build_string: str
-        build_number: Optional[int]
-
-        if '_' in build_number_with_opt_string:
-            bnbs_parts = build_number_with_opt_string.split('_')
-            # Build number will be the last part - check if it's an integer
-            # Updated logic given https://github.com/CycloneDX/cyclonedx-python-lib/issues/65
-            candidate_build_number: str = bnbs_parts.pop()
-            if candidate_build_number.isdigit():
-                build_number = int(candidate_build_number)
-                build_string = build_number_with_opt_string
-            else:
-                build_number = None
-                build_string = build_number_with_opt_string
-        else:
-            build_string = ''
-            build_number = int(build_number_with_opt_string)
-
-        build_version = package_nvbs_parts.pop()
-        package_name = '-'.join(package_nvbs_parts)
-    except IndexError as e:
-        raise ValueError(f'Error parsing {package_nvbs_parts} from {conda_list_str}') from e
+    package_name, build_version, build_string = split_package_string(package_name_version_build_string)
+    build_string, build_number = split_package_build_string(build_string)
 
     return CondaPackage(
         base_url=package_url.geturl(), build_number=build_number, build_string=build_string,
         channel=package_url.path[1:], dist_name=f'{package_name}-{build_version}-{build_string}',
         name=package_name, platform=package_arch, version=build_version, md5_hash=package_hash
     )
+
+
+def split_package_string(package_name_version_build_string: str) -> Tuple[str, str, str]:
+    """Helper method for parsing package_name_version_build_string.
+
+    Returns:
+        Tuple (package_name, build_version, build_string)
+    """
+    package_nvbs_parts = package_name_version_build_string.split('-')
+    if len(package_nvbs_parts) < 3:
+        raise ValueError(f'Unexpected format in {package_nvbs_parts}')
+
+    *_package_name_parts, build_version, build_string = package_nvbs_parts
+    package_name = '-'.join(_package_name_parts)
+
+    _pos = build_string.find('.')
+    if _pos >= 0:
+        # Remove any .conda at the end if present or other package type eg .tar.gz
+        build_string = build_string[0:_pos]
+
+    return package_name, build_version, build_string
+
+
+def split_package_build_string(build_string: str) -> Tuple[str, Optional[int]]:
+    """Helper method for parsing build_string.
+
+    Returns:
+        Tuple (build_string, build_number)
+    """
+
+    if '' == build_string:
+        return '', None
+
+    if build_string.isdigit():
+        return '', int(build_string)
+
+    _pos = build_string.rindex('_') if '_' in build_string else -1
+    if _pos >= 1:
+        # Build number will be the last part - check if it's an integer
+        # Updated logic given https://github.com/CycloneDX/cyclonedx-python-lib/issues/65
+        build_number = build_string[_pos + 1:]
+        if build_number.isdigit():
+            return build_string, int(build_number)
+
+    return build_string, None
diff --git a/poetry.lock b/poetry.lock
index db46888..d333b19 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -107,7 +107,7 @@ typed-ast = {version = ">=1.4,<2.0", markers = "python_version < \"3.8\""}
 
 [[package]]
 name = "flake8-bugbear"
-version = "22.1.11"
+version = "22.3.20"
 description = "A plugin for flake8 finding likely bugs and design problems in your program. Contains warnings that don't belong in pyflakes and pycodestyle."
 category = "dev"
 optional = false
@@ -442,7 +442,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes
 [metadata]
 lock-version = "1.1"
 python-versions = "^3.6"
-content-hash = "817a5868658e3804313ae035125ee83b7f78cc32260ef64fe4b8551bde68acc5"
+content-hash = "ef3ff89f1dd8de6e1433cbfa1c112cd8f5666dcab77f82781fc6ea9d8bb0d377"
 
 [metadata.files]
 attrs = [
@@ -527,8 +527,8 @@ flake8-annotations = [
     {file = "flake8_annotations-2.7.0-py3-none-any.whl", hash = "sha256:3edfbbfb58e404868834fe6ec3eaf49c139f64f0701259f707d043185545151e"},
 ]
 flake8-bugbear = [
-    {file = "flake8-bugbear-22.1.11.tar.gz", hash = "sha256:4c2a4136bd4ecb8bf02d5159af302ffc067642784c9d0488b33ce4610da825ee"},
-    {file = "flake8_bugbear-22.1.11-py3-none-any.whl", hash = "sha256:ce7ae44aaaf67ef192b8a6de94a5ac617144e1675ad0654fdea556f48dc18d9b"},
+    {file = "flake8-bugbear-22.3.20.tar.gz", hash = "sha256:152e64a86f6bff6e295d630ccc993f62434c1fd2b20d2fae47547cb1c1b868e0"},
+    {file = "flake8_bugbear-22.3.20-py3-none-any.whl", hash = "sha256:19fe179ee3286e16198603c438788e2949e79f31d653f0bdb56d53fb69217bd0"},
 ]
 flake8-isort = [
     {file = "flake8-isort-4.1.1.tar.gz", hash = "sha256:d814304ab70e6e58859bc5c3e221e2e6e71c958e7005239202fee19c24f82717"},
diff --git a/pyproject.toml b/pyproject.toml
index cb76bc6..50cd349 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -46,7 +46,7 @@ coverage = "^6.2"
 mypy = "^0.941"
 flake8 = "^4.0.1"
 flake8-annotations = {version = "^2.7.0", python = ">= 3.6.2"}
-flake8-bugbear = "^22.1.11"
+flake8-bugbear = "^22.3.20"
 flake8-isort = { version = "^4.1.0", python = ">= 3.6.1" }
 
 [tool.poetry.scripts]


Test patch:
diff --git a/tests/fixtures/conda-list-broken.txt b/tests/fixtures/conda-list-broken.txt
new file mode 100644
index 0000000..ed67b21
--- /dev/null
+++ b/tests/fixtures/conda-list-broken.txt
@@ -0,0 +1,2 @@
+# This package list id malformed.
+https://repo.anaconda.com/pkgs/main/linux-64/malformed_source.conda
diff --git a/tests/fixtures/conda-list-build-number-text.txt b/tests/fixtures/conda-list-build-number-text.txt
new file mode 100644
index 0000000..ab3e4c2
--- /dev/null
+++ b/tests/fixtures/conda-list-build-number-text.txt
@@ -0,0 +1,45 @@
+# This file is part of https://github.com/CycloneDX/cyclonedx-python/issues/331
+
+# This file may be used to create an environment using:
+# $ conda create --name <env> --file <this file>
+# platform: linux-64
+@EXPLICIT
+https://repo.anaconda.com/pkgs/main/linux-64/_libgcc_mutex-0.1-main.conda
+https://repo.anaconda.com/pkgs/main/linux-64/ca-certificates-2022.2.1-h06a4308_0.conda
+https://repo.anaconda.com/pkgs/main/linux-64/ld_impl_linux-64-2.35.1-h7274673_9.conda
+https://repo.anaconda.com/pkgs/main/linux-64/libstdcxx-ng-9.3.0-hd4cf53a_17.conda
+https://repo.anaconda.com/pkgs/main/noarch/tzdata-2021e-hda174b7_0.conda
+https://repo.anaconda.com/pkgs/main/linux-64/libgomp-9.3.0-h5101ec6_17.conda
+https://repo.anaconda.com/pkgs/main/linux-64/_openmp_mutex-4.5-1_gnu.tar.bz2
+https://repo.anaconda.com/pkgs/main/linux-64/libgcc-ng-9.3.0-h5101ec6_17.conda
+https://repo.anaconda.com/pkgs/main/linux-64/libffi-3.3-he6710b0_2.conda
+https://repo.anaconda.com/pkgs/main/linux-64/ncurses-6.3-h7f8727e_2.conda
+https://repo.anaconda.com/pkgs/main/linux-64/openssl-1.1.1m-h7f8727e_0.conda
+https://repo.anaconda.com/pkgs/main/linux-64/xz-5.2.5-h7b6447c_0.conda
+https://repo.anaconda.com/pkgs/main/linux-64/yaml-0.2.5-h7b6447c_0.conda
+https://repo.anaconda.com/pkgs/main/linux-64/zlib-1.2.11-h7f8727e_4.conda
+https://repo.anaconda.com/pkgs/main/linux-64/readline-8.1.2-h7f8727e_1.conda
+https://repo.anaconda.com/pkgs/main/linux-64/tk-8.6.11-h1ccaba5_0.conda
+https://repo.anaconda.com/pkgs/main/linux-64/sqlite-3.38.0-hc218d9a_0.conda
+https://repo.anaconda.com/pkgs/main/linux-64/python-3.9.7-h12debd9_1.conda
+https://repo.anaconda.com/pkgs/main/linux-64/certifi-2021.10.8-py39h06a4308_2.conda
+https://repo.anaconda.com/pkgs/main/noarch/charset-normalizer-2.0.4-pyhd3eb1b0_0.conda
+https://repo.anaconda.com/pkgs/main/noarch/colorama-0.4.4-pyhd3eb1b0_0.conda
+https://repo.anaconda.com/pkgs/main/noarch/idna-3.3-pyhd3eb1b0_0.conda
+https://repo.anaconda.com/pkgs/main/linux-64/pycosat-0.6.3-py39h27cfd23_0.conda
+https://repo.anaconda.com/pkgs/main/noarch/pycparser-2.21-pyhd3eb1b0_0.conda
+https://repo.anaconda.com/pkgs/main/linux-64/pysocks-1.7.1-py39h06a4308_0.conda
+https://repo.anaconda.com/pkgs/main/linux-64/ruamel_yaml-0.15.100-py39h27cfd23_0.conda
+https://repo.anaconda.com/pkgs/main/noarch/six-1.16.0-pyhd3eb1b0_1.conda
+https://repo.anaconda.com/pkgs/main/noarch/wheel-0.37.1-pyhd3eb1b0_0.conda
+https://repo.anaconda.com/pkgs/main/linux-64/cffi-1.15.0-py39hd667e15_1.conda
+https://repo.anaconda.com/pkgs/main/linux-64/setuptools-58.0.4-py39h06a4308_0.conda
+https://repo.anaconda.com/pkgs/main/noarch/tqdm-4.63.0-pyhd3eb1b0_0.conda
+https://repo.anaconda.com/pkgs/main/linux-64/brotlipy-0.7.0-py39h27cfd23_1003.conda
+https://repo.anaconda.com/pkgs/main/linux-64/conda-package-handling-1.7.3-py39h27cfd23_1.conda
+https://repo.anaconda.com/pkgs/main/linux-64/cryptography-36.0.0-py39h9ce1e76_0.conda
+https://repo.anaconda.com/pkgs/main/linux-64/pip-21.2.4-py39h06a4308_0.conda
+https://repo.anaconda.com/pkgs/main/noarch/pyopenssl-22.0.0-pyhd3eb1b0_0.conda
+https://repo.anaconda.com/pkgs/main/noarch/urllib3-1.26.8-pyhd3eb1b0_0.conda
+https://repo.anaconda.com/pkgs/main/noarch/requests-2.27.1-pyhd3eb1b0_0.conda
+https://repo.anaconda.com/pkgs/main/linux-64/conda-4.12.0-py39h06a4308_0.conda
diff --git a/tests/test_parser_conda.py b/tests/test_parser_conda.py
index 7f3810a..287341e 100644
--- a/tests/test_parser_conda.py
+++ b/tests/test_parser_conda.py
@@ -18,6 +18,7 @@
 # Copyright (c) OWASP Foundation. All Rights Reserved.
 
 import os
+import re
 from unittest import TestCase
 
 from cyclonedx_py.parser.conda import CondaListExplicitParser, CondaListJsonParser
@@ -54,3 +55,32 @@ class TestCondaParser(TestCase):
         self.assertEqual('2.10', c_noarch.version)
         self.assertEqual(1, len(c_noarch.external_references))
         self.assertEqual(0, len(c_noarch.external_references.pop().hashes))
+
+    def test_conda_list_build_number_text(self) -> None:
+        conda_list_output_file = os.path.join(os.path.dirname(__file__), 'fixtures/conda-list-build-number-text.txt')
+
+        with (open(conda_list_output_file, 'r')) as conda_list_ouptut_fh:
+            parser = CondaListExplicitParser(conda_data=conda_list_ouptut_fh.read())
+
+        self.assertEqual(39, parser.component_count())
+        components = parser.get_components()
+
+        c_libgcc_mutex = next(filter(lambda c: c.name == '_libgcc_mutex', components), None)
+        self.assertIsNotNone(c_libgcc_mutex)
+        self.assertEqual('_libgcc_mutex', c_libgcc_mutex.name)
+        self.assertEqual('0.1', c_libgcc_mutex.version)
+        c_pycparser = next(filter(lambda c: c.name == 'pycparser', components), None)
+        self.assertIsNotNone(c_pycparser)
+        self.assertEqual('pycparser', c_pycparser.name)
+        self.assertEqual('2.21', c_pycparser.version)
+        c_openmp_mutex = next(filter(lambda c: c.name == '_openmp_mutex', components), None)
+        self.assertIsNotNone(c_openmp_mutex)
+        self.assertEqual('_openmp_mutex', c_openmp_mutex.name)
+        self.assertEqual('4.5', c_openmp_mutex.version)
+
+    def test_conda_list_malformed(self) -> None:
+        conda_list_output_file = os.path.join(os.path.dirname(__file__), 'fixtures/conda-list-broken.txt')
+
+        with (open(conda_list_output_file, 'r')) as conda_list_ouptut_fh:
+            with self.assertRaisesRegex(ValueError, re.compile(r'^unexpected format', re.IGNORECASE)):
+                CondaListExplicitParser(conda_data=conda_list_ouptut_fh.read())
diff --git a/tests/test_utils_conda.py b/tests/test_utils_conda.py
index 0bed7f8..584dc38 100644
--- a/tests/test_utils_conda.py
+++ b/tests/test_utils_conda.py
@@ -129,3 +129,51 @@ class TestUtilsConda(TestCase):
         self.assertEqual(cp['platform'], 'linux-64')
         self.assertEqual(cp['version'], '0.1')
         self.assertEqual(cp['md5_hash'], 'd7c89558ba9fa0495403155b64376d81')
+
+    def test_parse_conda_list_build_number(self) -> None:
+        cp: CondaPackage = parse_conda_list_str_to_conda_package(
+            conda_list_str='https://repo.anaconda.com/pkgs/main/osx-64/chardet-4.0.0-py39hecd8cb5_1003.conda'
+        )
+
+        self.assertIsInstance(cp, dict)
+        self.assertEqual('https://repo.anaconda.com/pkgs/main', cp['base_url'])
+        self.assertEqual(1003, cp['build_number'])
+        self.assertEqual('py39hecd8cb5_1003', cp['build_string'])
+        self.assertEqual('pkgs/main', cp['channel'])
+        self.assertEqual('chardet-4.0.0-py39hecd8cb5_1003', cp['dist_name'])
+        self.assertEqual('chardet', cp['name'])
+        self.assertEqual('osx-64', cp['platform'])
+        self.assertEqual('4.0.0', cp['version'])
+        self.assertIsNone(cp['md5_hash'])
+
+    def test_parse_conda_list_no_build_number(self) -> None:
+        cp: CondaPackage = parse_conda_list_str_to_conda_package(
+            conda_list_str='https://repo.anaconda.com/pkgs/main/linux-64/_libgcc_mutex-0.1-main.conda'
+        )
+
+        self.assertIsInstance(cp, dict)
+        self.assertEqual('https://repo.anaconda.com/pkgs/main', cp['base_url'])
+        self.assertEqual(None, cp['build_number'])
+        self.assertEqual('main', cp['build_string'])
+        self.assertEqual('pkgs/main', cp['channel'])
+        self.assertEqual('_libgcc_mutex-0.1-main', cp['dist_name'])
+        self.assertEqual('_libgcc_mutex', cp['name'])
+        self.assertEqual('linux-64', cp['platform'])
+        self.assertEqual('0.1', cp['version'])
+        self.assertIsNone(cp['md5_hash'])
+
+    def test_parse_conda_list_no_build_number2(self) -> None:
+        cp: CondaPackage = parse_conda_list_str_to_conda_package(
+            conda_list_str='https://repo.anaconda.com/pkgs/main/linux-64/_openmp_mutex-4.5-1_gnu.tar.bz2'
+        )
+
+        self.assertIsInstance(cp, dict)
+        self.assertEqual('https://repo.anaconda.com/pkgs/main', cp['base_url'])
+        self.assertEqual(None, cp['build_number'])
+        self.assertEqual('1_gnu', cp['build_string'])
+        self.assertEqual('pkgs/main', cp['channel'])
+        self.assertEqual('_openmp_mutex-4.5-1_gnu', cp['dist_name'])
+        self.assertEqual('_openmp_mutex', cp['name'])
+        self.assertEqual('linux-64', cp['platform'])
+        self.assertEqual('4.5', cp['version'])
+        self.assertIsNone(cp['md5_hash'])