diff --git a/cyclonedx/model/__init__.py b/cyclonedx/model/__init__.py index 6b77dbb7..121bde24 100644 --- a/cyclonedx/model/__init__.py +++ b/cyclonedx/model/__init__.py @@ -27,7 +27,9 @@ from enum import Enum from functools import reduce from json import loads as json_loads -from typing import Any, Dict, FrozenSet, Generator, Iterable, List, Optional, Tuple, Type +from typing import Any, Dict, FrozenSet, Generator, Iterable, List, Optional, Tuple, Type, Union +from urllib.parse import quote as url_quote +from uuid import UUID from warnings import warn from xml.etree.ElementTree import Element as XmlElement # nosec B405 @@ -51,6 +53,9 @@ SchemaVersion1Dot5, SchemaVersion1Dot6, ) +from .bom_ref import BomRef + +_BOM_LINK_PREFIX = 'urn:cdx:' @serializable.serializable_enum @@ -767,6 +772,36 @@ def deserialize(cls, o: Any) -> 'XsUri': f'XsUri string supplied does not parse: {o!r}' ) from err + @classmethod + def make_bom_link( + cls, + serial_number: Union[UUID, str], + version: int = 1, + bom_ref: Optional[Union[str, BomRef]] = None + ) -> 'XsUri': + """ + Generate a BOM-Link URI. + + Args: + serial_number: The unique serial number of the BOM. + version: The version of the BOM. The default version is 1. + bom_ref: The unique identifier of the component, service, or vulnerability within the BOM. + + Returns: + XsUri: Instance of XsUri with the generated BOM-Link URI. + """ + bom_ref_part = f'#{url_quote(str(bom_ref))}' if bom_ref else '' + return cls(f'{_BOM_LINK_PREFIX}{serial_number}/{version}{bom_ref_part}') + + def is_bom_link(self) -> bool: + """ + Check if the URI is a BOM-Link. + + Returns: + `bool` + """ + return self._uri.startswith(_BOM_LINK_PREFIX) + @serializable.serializable_class class ExternalReference: diff --git a/cyclonedx/model/bom.py b/cyclonedx/model/bom.py index 65c578fb..81b2161b 100644 --- a/cyclonedx/model/bom.py +++ b/cyclonedx/model/bom.py @@ -37,7 +37,7 @@ SchemaVersion1Dot6, ) from ..serialization import LicenseRepositoryHelper, UrnUuidHelper -from . import ExternalReference, Property +from . import _BOM_LINK_PREFIX, ExternalReference, Property from .bom_ref import BomRef from .component import Component from .contact import OrganizationalContact, OrganizationalEntity @@ -663,7 +663,7 @@ def register_dependency(self, target: Dependable, depends_on: Optional[Iterable[ self.register_dependency(target=_d2, depends_on=None) def urn(self) -> str: - return f'urn:cdx:{self.serial_number}/{self.version}' + return f'{_BOM_LINK_PREFIX}{self.serial_number}/{self.version}' def validate(self) -> bool: """ diff --git a/tests/test_model.py b/tests/test_model.py index c101d8e0..46232611 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -19,6 +19,7 @@ import datetime from enum import Enum from unittest import TestCase +from uuid import UUID from ddt import ddt, named_data @@ -42,6 +43,7 @@ Property, XsUri, ) +from cyclonedx.model.bom_ref import BomRef from cyclonedx.model.contact import OrganizationalContact from cyclonedx.model.issue import IssueClassification, IssueType, IssueTypeSource from tests import reorder @@ -545,6 +547,19 @@ def test_sort(self) -> None: expected_uris = reorder(uris, expected_order) self.assertListEqual(sorted_uris, expected_uris) + def test_make_bom_link_without_bom_ref(self) -> None: + bom_link = XsUri.make_bom_link(UUID('e5a93409-fd7c-4ffa-bf7f-6dc1630b1b9d'), 2) + self.assertEqual(bom_link.uri, 'urn:cdx:e5a93409-fd7c-4ffa-bf7f-6dc1630b1b9d/2') + + def test_make_bom_link_with_bom_ref(self) -> None: + bom_link = XsUri.make_bom_link(UUID('e5a93409-fd7c-4ffa-bf7f-6dc1630b1b9d'), + 2, BomRef('componentA#sub-componentB%2')) + self.assertEqual(bom_link.uri, 'urn:cdx:e5a93409-fd7c-4ffa-bf7f-6dc1630b1b9d/2#componentA%23sub-componentB%252') + + def test_is_bom_link(self) -> None: + self.assertTrue(XsUri('urn:cdx:e5a93409-fd7c-4ffa-bf7f-6dc1630b1b9d/2').is_bom_link()) + self.assertFalse(XsUri('http://example.com/resource').is_bom_link()) + class TestModelProperty(TestCase):