diff --git a/cyclonedx/exception/model.py b/cyclonedx/exception/model.py index cf354ed2..3484b606 100644 --- a/cyclonedx/exception/model.py +++ b/cyclonedx/exception/model.py @@ -123,3 +123,11 @@ class LicenseExpressionAlongWithOthersException(CycloneDxModelException): See https://github.com/CycloneDX/specification/pull/205 """ pass + + +class InvalidCreIdException(CycloneDxModelException): + """ + Raised when a supplied value for an CRE ID does not meet the format requirements + as defined at https://opencre.org/ + """ + pass diff --git a/cyclonedx/model/definition.py b/cyclonedx/model/definition.py index 7c142247..90872e32 100644 --- a/cyclonedx/model/definition.py +++ b/cyclonedx/model/definition.py @@ -15,68 +15,419 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) OWASP Foundation. All Rights Reserved. +import re from typing import TYPE_CHECKING, Any, Iterable, Optional, Union import serializable from sortedcontainers import SortedSet -from .._internal.bom_ref import bom_ref_from_str +from .._internal.bom_ref import bom_ref_from_str as _bom_ref_from_str from .._internal.compare import ComparableTuple as _ComparableTuple -from . import ExternalReference +from ..exception.model import InvalidCreIdException +from ..exception.serialization import SerializationOfUnexpectedValueException +from . import ExternalReference, Property from .bom_ref import BomRef if TYPE_CHECKING: # pragma: no cover - pass + from typing import Type, TypeVar + + _T_CreId = TypeVar('_T_CreId', bound='CreId') @serializable.serializable_class -class Standard: +class CreId(serializable.helpers.BaseHelper): """ - A standard of regulations, industry or organizational-specific standards, maturity models, best practices, - or any other requirements. + Helper class that allows us to perform validation on data strings that must conform to + Common Requirements Enumeration (CRE) identifier(s). + + """ + + _VALID_CRE_REGEX = re.compile(r'^CRE:[0-9]+-[0-9]+$') + + def __init__(self, id: str) -> None: + if CreId._VALID_CRE_REGEX.match(id) is None: + raise InvalidCreIdException( + f'Supplied value "{id} does not meet format specification.' + ) + self._id = id + + @property + @serializable.json_name('.') + @serializable.xml_name('.') + def id(self) -> str: + return self._id + + @classmethod + def serialize(cls, o: Any) -> str: + if isinstance(o, cls): + return str(o) + raise SerializationOfUnexpectedValueException( + f'Attempt to serialize a non-CreId: {o!r}') + + @classmethod + def deserialize(cls: 'Type[_T_CreId]', o: Any) -> '_T_CreId': + return cls(id=str(o)) + + def __eq__(self, other: Any) -> bool: + if isinstance(other, CreId): + return self._id == other._id + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, CreId): + return self._id < other._id + return NotImplemented + + def __hash__(self) -> int: + return hash(self._id) + + def __repr__(self) -> str: + return f'' + + def __str__(self) -> str: + return self._id + + +@serializable.serializable_class +class Requirement: + """ + A requirement comprising a standard. """ def __init__( self, *, bom_ref: Optional[Union[str, BomRef]] = None, - name: Optional[str] = None, - version: Optional[str] = None, + identifier: Optional[str] = None, + title: Optional[str] = None, + text: Optional[str] = None, + descriptions: Optional[Iterable[str]] = None, + open_cre: Optional[Iterable[CreId]] = None, + parent: Optional[Union[str, BomRef]] = None, + properties: Optional[Iterable[Property]] = None, + external_references: Optional[Iterable[ExternalReference]] = None, + ) -> None: + self._bom_ref = _bom_ref_from_str(bom_ref) + self.identifier = identifier + self.title = title + self.text = text + self.descriptions = descriptions or () # type:ignore[assignment] + self.open_cre = open_cre or () # type:ignore[assignment] + self.parent = parent # type:ignore[assignment] + self.properties = properties or () # type:ignore[assignment] + self.external_references = external_references or () # type:ignore[assignment] + + @property + @serializable.type_mapping(BomRef) + @serializable.json_name('bom-ref') + @serializable.xml_name('bom-ref') + @serializable.xml_attribute() + def bom_ref(self) -> BomRef: + """ + An optional identifier which can be used to reference the requirement elsewhere in the BOM. + Every bom-ref MUST be unique within the BOM. + + Returns: + `BomRef` + """ + return self._bom_ref + + @property + @serializable.xml_sequence(1) + def identifier(self) -> Optional[str]: + """ + Returns: + The identifier of the requirement. + """ + return self._identifier + + @identifier.setter + def identifier(self, identifier: Optional[str]) -> None: + self._identifier = identifier + + @property + @serializable.xml_sequence(2) + def title(self) -> Optional[str]: + """ + Returns: + The title of the requirement. + """ + return self._title + + @title.setter + def title(self, title: Optional[str]) -> None: + self._title = title + + @property + @serializable.xml_sequence(3) + def text(self) -> Optional[str]: + """ + Returns: + The text of the requirement. + """ + return self._text + + @text.setter + def text(self, text: Optional[str]) -> None: + self._text = text + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'description') + @serializable.xml_sequence(4) + def descriptions(self) -> 'SortedSet[str]': + """ + Returns: + A SortedSet of descriptions of the requirement. + """ + return self._descriptions + + @descriptions.setter + def descriptions(self, descriptions: Iterable[str]) -> None: + self._descriptions = SortedSet(descriptions) + + @property + @serializable.json_name('openCre') + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'openCre') + @serializable.xml_sequence(5) + def open_cre(self) -> 'SortedSet[CreId]': + """ + CRE is a structured and standardized framework for uniting security standards and guidelines. CRE links each + section of a resource to a shared topic identifier (a Common Requirement). Through this shared topic link, all + resources map to each other. Use of CRE promotes clear and unambiguous communication among stakeholders. + + Returns: + The Common Requirements Enumeration (CRE) identifier(s). + CREs must match regular expression: ^CRE:[0-9]+-[0-9]+$ + """ + return self._open_cre + + @open_cre.setter + def open_cre(self, open_cre: Iterable[CreId]) -> None: + self._open_cre = SortedSet(open_cre) + + @property + @serializable.type_mapping(BomRef) + @serializable.xml_sequence(6) + def parent(self) -> Optional[BomRef]: + """ + Returns: + The optional bom-ref to a parent requirement. This establishes a hierarchy of requirements. Top-level + requirements must not define a parent. Only child requirements should define parents. + """ + return self._parent + + @parent.setter + def parent(self, parent: Optional[Union[str, BomRef]]) -> None: + self._parent = _bom_ref_from_str(parent, optional=True) + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'property') + @serializable.xml_sequence(7) + def properties(self) -> 'SortedSet[Property]': + """ + Provides the ability to document properties in a key/value store. This provides flexibility to include data not + officially supported in the standard without having to use additional namespaces or create extensions. + + Return: + Set of `Property` + """ + return self._properties + + @properties.setter + def properties(self, properties: Iterable[Property]) -> None: + self._properties = SortedSet(properties) + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'reference') + @serializable.xml_sequence(8) + def external_references(self) -> 'SortedSet[ExternalReference]': + """ + Provides the ability to document external references related to the component or to the project the component + describes. + + Returns: + Set of `ExternalReference` + """ + return self._external_references + + @external_references.setter + def external_references(self, external_references: Iterable[ExternalReference]) -> None: + self._external_references = SortedSet(external_references) + + def __comparable_tuple(self) -> _ComparableTuple: + # all properties are optional - so need to compare all, in hope that one is unique + return _ComparableTuple(( + self.bom_ref, self.identifier, + self.title, self.text, + _ComparableTuple(self.descriptions), + _ComparableTuple(self.open_cre), self.parent, _ComparableTuple(self.properties), + _ComparableTuple(self.external_references) + )) + + def __lt__(self, other: Any) -> bool: + if isinstance(other, Requirement): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __eq__(self, other: object) -> bool: + if isinstance(other, Requirement): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +@serializable.serializable_class +class Level: + """ + Level of compliance for a standard. + """ + + def __init__( + self, *, + bom_ref: Optional[Union[str, BomRef]] = None, + identifier: Optional[str] = None, + title: Optional[str] = None, description: Optional[str] = None, - owner: Optional[str] = None, - external_references: Optional[Iterable['ExternalReference']] = None + requirements: Optional[Iterable[Union[str, BomRef]]] = None, ) -> None: - self._bom_ref = bom_ref_from_str(bom_ref) - self.name = name - self.version = version + self._bom_ref = _bom_ref_from_str(bom_ref) + self.identifier = identifier + self.title = title self.description = description - self.owner = owner - self.external_references = external_references or [] # type:ignore[assignment] + self.requirements = requirements or () # type:ignore[assignment] + + @property + @serializable.type_mapping(BomRef) + @serializable.json_name('bom-ref') + @serializable.xml_name('bom-ref') + @serializable.xml_attribute() + def bom_ref(self) -> BomRef: + """ + An optional identifier which can be used to reference the level elsewhere in the BOM. + Every bom-ref MUST be unique within the BOM. + + Returns: + `BomRef` + """ + return self._bom_ref + + @property + @serializable.xml_sequence(1) + def identifier(self) -> Optional[str]: + """ + Returns: + The identifier of the level. + """ + return self._identifier + + @identifier.setter + def identifier(self, identifier: Optional[str]) -> None: + self._identifier = identifier + + @property + @serializable.xml_sequence(2) + def title(self) -> Optional[str]: + """ + Returns: + The title of the level. + """ + return self._title + + @title.setter + def title(self, title: Optional[str]) -> None: + self._title = title + + @property + @serializable.xml_sequence(3) + def description(self) -> Optional[str]: + """ + Returns: + The description of the level. + """ + return self._description + + @description.setter + def description(self, description: Optional[str]) -> None: + self._description = description + + @property + @serializable.xml_sequence(4) + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'requirement') + def requirements(self) -> 'SortedSet[BomRef]': + """ + Returns: + A SortedSet of requirements associated with the level. + """ + return self._requirements + + @requirements.setter + def requirements(self, requirements: Iterable[Union[str, BomRef]]) -> None: + self._requirements = SortedSet(map(_bom_ref_from_str, # type: ignore[arg-type] + requirements)) + + def __comparable_tuple(self) -> _ComparableTuple: + # all properties are optional - so need to compare all, in hope that one is unique + return _ComparableTuple(( + self.bom_ref, self.identifier, self.title, self.description, _ComparableTuple(self.requirements) + )) def __lt__(self, other: Any) -> bool: - if isinstance(other, Standard): - return (_ComparableTuple((self.bom_ref, self.name, self.version)) - < _ComparableTuple((other.bom_ref, other.name, other.version))) + if isinstance(other, Level): + return self.__comparable_tuple() < other.__comparable_tuple() return NotImplemented def __eq__(self, other: object) -> bool: - if isinstance(other, Standard): - return hash(other) == hash(self) + if isinstance(other, Level): + return self.__comparable_tuple() == other.__comparable_tuple() return False def __hash__(self) -> int: - return hash(( - self.bom_ref, self.name, self.version, self.description, self.owner, tuple(self.external_references) - )) + return hash(self.__comparable_tuple()) def __repr__(self) -> str: - return f'' + return f'' + + +@serializable.serializable_class +class Standard: + """ + A standard of regulations, industry or organizational-specific standards, maturity models, best practices, + or any other requirements. + """ + + def __init__( + self, *, + bom_ref: Optional[Union[str, BomRef]] = None, + name: Optional[str] = None, + version: Optional[str] = None, + description: Optional[str] = None, + owner: Optional[str] = None, + requirements: Optional[Iterable[Requirement]] = None, + levels: Optional[Iterable[Level]] = None, + external_references: Optional[Iterable['ExternalReference']] = None + # TODO: signature + ) -> None: + self._bom_ref = _bom_ref_from_str(bom_ref) + self.name = name + self.version = version + self.description = description + self.owner = owner + self.requirements = requirements or () # type:ignore[assignment] + self.levels = levels or () # type:ignore[assignment] + self.external_references = external_references or () # type:ignore[assignment] + # TODO: signature @property - @serializable.json_name('bom-ref') @serializable.type_mapping(BomRef) - @serializable.xml_attribute() + @serializable.json_name('bom-ref') @serializable.xml_name('bom-ref') + @serializable.xml_attribute() def bom_ref(self) -> BomRef: """ An optional identifier which can be used to reference the standard elsewhere in the BOM. Every bom-ref MUST be @@ -139,33 +490,33 @@ def owner(self) -> Optional[str]: def owner(self, owner: Optional[str]) -> None: self._owner = owner - # @property - # @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'requirement') - # @serializable.xml_sequence(5) - # def requirements(self) -> 'SortedSet[Requirement]': - # """ - # Returns: - # A SortedSet of requirements comprising the standard. - # """ - # return self._requirements - # - # @requirements.setter - # def requirements(self, requirements: Iterable[Requirement]) -> None: - # self._requirements = SortedSet(requirements) - # - # @property - # @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'level') - # @serializable.xml_sequence(6) - # def levels(self) -> 'SortedSet[Level]': - # """ - # Returns: - # A SortedSet of levels associated with the standard. Some standards have different levels of compliance. - # """ - # return self._levels - # - # @levels.setter - # def levels(self, levels: Iterable[Level]) -> None: - # self._levels = SortedSet(levels) + @property + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'requirement') + @serializable.xml_sequence(5) + def requirements(self) -> 'SortedSet[Requirement]': + """ + Returns: + A SortedSet of requirements comprising the standard. + """ + return self._requirements + + @requirements.setter + def requirements(self, requirements: Iterable[Requirement]) -> None: + self._requirements = SortedSet(requirements) + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'level') + @serializable.xml_sequence(6) + def levels(self) -> 'SortedSet[Level]': + """ + Returns: + A SortedSet of levels associated with the standard. Some standards have different levels of compliance. + """ + return self._levels + + @levels.setter + def levels(self, levels: Iterable[Level]) -> None: + self._levels = SortedSet(levels) @property @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'reference') @@ -181,6 +532,43 @@ def external_references(self) -> 'SortedSet[ExternalReference]': def external_references(self, external_references: Iterable[ExternalReference]) -> None: self._external_references = SortedSet(external_references) + # @property + # @serializable.xml_sequence(8) + # # MUST NOT RENDER FOR XML -- this is JSON only + # def signature(self) -> ...: + # ... + # + # @signature.setter + # def levels(self, signature: ...) -> None: + # ... + + def __comparable_tuple(self) -> _ComparableTuple: + # all properties are optional - so need to apply all, in hope that one is unique + return _ComparableTuple(( + self.bom_ref, + self.name, self.version, self.description, self.owner, + _ComparableTuple(self.requirements), _ComparableTuple(self.levels), + _ComparableTuple(self.external_references) + )) + + def __lt__(self, other: Any) -> bool: + if isinstance(other, Standard): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __eq__(self, other: object) -> bool: + if isinstance(other, Standard): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + @serializable.serializable_class(name='definitions') class Definitions: @@ -211,20 +599,22 @@ def standards(self, standards: Iterable[Standard]) -> None: def __bool__(self) -> bool: return len(self._standards) > 0 - def __eq__(self, other: object) -> bool: - if not isinstance(other, Definitions): - return False + def __comparable_tuple(self) -> _ComparableTuple: + # all properties are optional - so need to apply all, in hope that one is unique + return _ComparableTuple(self._standards) - return self._standards == other._standards + def __eq__(self, other: object) -> bool: + if isinstance(other, Definitions): + return self.__comparable_tuple() == other.__comparable_tuple() + return False def __hash__(self) -> int: - return hash((tuple(self._standards))) + return hash(self.__comparable_tuple()) def __lt__(self, other: Any) -> bool: if isinstance(other, Definitions): - return (_ComparableTuple(self._standards) - < _ComparableTuple(other.standards)) + return self.__comparable_tuple() < other.__comparable_tuple() return NotImplemented def __repr__(self) -> str: - return '' + return f'' diff --git a/tests/_data/models.py b/tests/_data/models.py index ffbf7d4a..6bba3499 100644 --- a/tests/_data/models.py +++ b/tests/_data/models.py @@ -78,7 +78,7 @@ RelatedCryptoMaterialState, RelatedCryptoMaterialType, ) -from cyclonedx.model.definition import Definitions, Standard +from cyclonedx.model.definition import CreId, Definitions, Level, Requirement, Standard from cyclonedx.model.dependency import Dependency from cyclonedx.model.impact_analysis import ( ImpactAnalysisAffectedStatus, @@ -1303,13 +1303,104 @@ def get_bom_with_definitions_standards() -> Bom: """ return _make_bom( definitions=Definitions(standards=[ - Standard(name='Some Standard', version='1.2.3', description='Some description', bom_ref='some-standard', - owner='Some Owner', external_references=[get_external_reference_2()] - ) + Standard( + bom_ref='some-standard', + name='Some Standard', + version='1.2.3', + description='Some description', + owner='Some Owner', + external_references=[get_external_reference_2()] + ) ]) ) +def get_bom_with_definitions_and_detailed_standards() -> Bom: + """ + Returns a BOM with definitions and multiple detailed standards including requirements and levels. + """ + return _make_bom( + definitions=Definitions(standards=[ + Standard( + bom_ref='some-standard', + name='Some Standard', + version='1.2.3', + description='Some description', + owner='Some Owner', + requirements=[ + req1 := Requirement( + bom_ref='req-1', + identifier='REQ-1', + title='Requirement 1', + text='some requirement text', + descriptions=['Requirement 1 described here', 'and here'], + open_cre=[CreId('CRE:1-2')], + properties=[ + Property(name='key1', value='val1a'), + Property(name='key1', value='val1b'), + ], + external_references=[get_external_reference_2()], + ), + req2 := Requirement( + bom_ref='req-2', + identifier='REQ-2', + title='Requirement 2', + text='some requirement text', + descriptions=['Requirement 2 described here'], + open_cre=[CreId('CRE:1-2'), CreId('CRE:3-4')], + parent=req1.bom_ref, + properties=[Property(name='key2', value='val2')], + ), + ], + levels=[ + Level( + bom_ref='lvl-1', + identifier='LVL-1', + title='Level 1', + description='Level 1 description', + # no requirements! + ), + Level( + bom_ref='lvl-2', + identifier='LVL-2', + title='Level 2', + description='Level 2 description', + requirements=[req1.bom_ref, req2.bom_ref], + ), + ], + external_references=[get_external_reference_1()], + ), + Standard( + bom_ref='other-standard', + name='Other Standard', + version='1.0.0', + description='Other description', + owner='Other Owner', + requirements=[ + req3 := Requirement( + bom_ref='req-3', + identifier='REQ-3', + title='Requirement 3', + text='some requirement text', + descriptions=['Requirement 3 described here', 'and here'], + open_cre=[CreId('CRE:5-6'), CreId('CRE:7-8')], + properties=[Property(name='key3', value='val3')] + ), + ], + levels=[ + Level( + bom_ref='lvl-3', + identifier='LVL-3', + title='Level 3', + description='Level 3 description', + requirements=[req3.bom_ref] + ), + ], + external_references=[get_external_reference_2()], + ) + ])) + + # --- @@ -1357,4 +1448,5 @@ def get_bom_with_definitions_standards() -> Bom: get_bom_for_issue_630_empty_property, get_bom_with_lifecycles, get_bom_with_definitions_standards, + get_bom_with_definitions_and_detailed_standards, } diff --git a/tests/_data/snapshots/get_bom_v1_6_with_crypto-1.6.xml.bin b/tests/_data/snapshots/get_bom_v1_6_with_crypto-1.6.xml.bin deleted file mode 100644 index 72618139..00000000 --- a/tests/_data/snapshots/get_bom_v1_6_with_crypto-1.6.xml.bin +++ /dev/null @@ -1,92 +0,0 @@ - - - - 2023-01-07T13:44:32.312678+00:00 - - - CycloneDX - cyclonedx-python-lib - TESTING - - - https://github.com/CycloneDX/cyclonedx-python-lib/actions - - - https://pypi.org/project/cyclonedx-python-lib/ - - - https://cyclonedx-python-library.readthedocs.io/ - - - https://github.com/CycloneDX/cyclonedx-python-lib/issues - - - https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/LICENSE - - - https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/CHANGELOG.md - - - https://github.com/CycloneDX/cyclonedx-python-lib - - - https://github.com/CycloneDX/cyclonedx-python-lib/#readme - - - - - - - - TLS - v1.3 - - protocol - - tls - 1.3 - - - TLS_AES_128_CCM_8_SHA256 - - TLS_AES_128_CCM_8_SHA256 - - - - TLS_AES_128_CCM_SHA256 - - TLS_AES_128_CCM_SHA256 - - - - TLS_AES_128_GCM_SHA256 - - TLS_AES_128_GCM_SHA256 - - - - TLS_AES_256_GCM_SHA384 - - TLS_AES_256_GCM_SHA384 - - - - TLS_CHACHA20_POLY1305_SHA256 - - TLS_CHACHA20_POLY1305_SHA256 - - - - - an-oid-here - - - protocl - tls - - - - - - - diff --git a/tests/_data/snapshots/get_bom_with_crypto-1.6.xml.bin b/tests/_data/snapshots/get_bom_with_crypto-1.6.xml.bin deleted file mode 100644 index 72618139..00000000 --- a/tests/_data/snapshots/get_bom_with_crypto-1.6.xml.bin +++ /dev/null @@ -1,92 +0,0 @@ - - - - 2023-01-07T13:44:32.312678+00:00 - - - CycloneDX - cyclonedx-python-lib - TESTING - - - https://github.com/CycloneDX/cyclonedx-python-lib/actions - - - https://pypi.org/project/cyclonedx-python-lib/ - - - https://cyclonedx-python-library.readthedocs.io/ - - - https://github.com/CycloneDX/cyclonedx-python-lib/issues - - - https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/LICENSE - - - https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/CHANGELOG.md - - - https://github.com/CycloneDX/cyclonedx-python-lib - - - https://github.com/CycloneDX/cyclonedx-python-lib/#readme - - - - - - - - TLS - v1.3 - - protocol - - tls - 1.3 - - - TLS_AES_128_CCM_8_SHA256 - - TLS_AES_128_CCM_8_SHA256 - - - - TLS_AES_128_CCM_SHA256 - - TLS_AES_128_CCM_SHA256 - - - - TLS_AES_128_GCM_SHA256 - - TLS_AES_128_GCM_SHA256 - - - - TLS_AES_256_GCM_SHA384 - - TLS_AES_256_GCM_SHA384 - - - - TLS_CHACHA20_POLY1305_SHA256 - - TLS_CHACHA20_POLY1305_SHA256 - - - - - an-oid-here - - - protocl - tls - - - - - - - diff --git a/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.0.xml.bin b/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.0.xml.bin new file mode 100644 index 00000000..acb06612 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.0.xml.bin @@ -0,0 +1,4 @@ + + + + diff --git a/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.1.xml.bin b/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.1.xml.bin new file mode 100644 index 00000000..55ef5cda --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.1.xml.bin @@ -0,0 +1,4 @@ + + + + diff --git a/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.2.json.bin b/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.2.json.bin new file mode 100644 index 00000000..8f473bd3 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.2.json.bin @@ -0,0 +1,10 @@ +{ + "metadata": { + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.2b.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.2" +} \ No newline at end of file diff --git a/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.2.xml.bin b/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.2.xml.bin new file mode 100644 index 00000000..df1938ec --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.2.xml.bin @@ -0,0 +1,6 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + diff --git a/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.3.json.bin b/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.3.json.bin new file mode 100644 index 00000000..02943890 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.3.json.bin @@ -0,0 +1,10 @@ +{ + "metadata": { + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.3a.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.3" +} \ No newline at end of file diff --git a/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.3.xml.bin b/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.3.xml.bin new file mode 100644 index 00000000..8341ff60 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.3.xml.bin @@ -0,0 +1,6 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + diff --git a/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.4.json.bin b/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.4.json.bin new file mode 100644 index 00000000..48f1745d --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.4.json.bin @@ -0,0 +1,10 @@ +{ + "metadata": { + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.4.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.4" +} \ No newline at end of file diff --git a/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.4.xml.bin b/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.4.xml.bin new file mode 100644 index 00000000..d0a7d4c9 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.4.xml.bin @@ -0,0 +1,6 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + diff --git a/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.5.json.bin b/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.5.json.bin new file mode 100644 index 00000000..57b5e590 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.5.json.bin @@ -0,0 +1,20 @@ +{ + "metadata": { + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ], + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.5" +} \ No newline at end of file diff --git a/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.5.xml.bin b/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.5.xml.bin new file mode 100644 index 00000000..f952637c --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.5.xml.bin @@ -0,0 +1,10 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + + val1 + val2 + + diff --git a/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.6.json.bin b/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.6.json.bin new file mode 100644 index 00000000..21195512 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.6.json.bin @@ -0,0 +1,159 @@ +{ + "definitions": { + "standards": [ + { + "bom-ref": "other-standard", + "description": "Other description", + "externalReferences": [ + { + "type": "website", + "url": "https://cyclonedx.org" + } + ], + "levels": [ + { + "bom-ref": "lvl-3", + "description": "Level 3 description", + "identifier": "LVL-3", + "requirements": [ + "req-3" + ], + "title": "Level 3" + } + ], + "name": "Other Standard", + "owner": "Other Owner", + "requirements": [ + { + "bom-ref": "req-3", + "descriptions": [ + "Requirement 3 described here", + "and here" + ], + "identifier": "REQ-3", + "openCre": [ + "CRE:5-6", + "CRE:7-8" + ], + "properties": [ + { + "name": "key3", + "value": "val3" + } + ], + "text": "some requirement text", + "title": "Requirement 3" + } + ], + "version": "1.0.0" + }, + { + "bom-ref": "some-standard", + "description": "Some description", + "externalReferences": [ + { + "comment": "No comment", + "hashes": [ + { + "alg": "SHA-256", + "content": "806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" + } + ], + "type": "distribution", + "url": "https://cyclonedx.org" + } + ], + "levels": [ + { + "bom-ref": "lvl-1", + "description": "Level 1 description", + "identifier": "LVL-1", + "title": "Level 1" + }, + { + "bom-ref": "lvl-2", + "description": "Level 2 description", + "identifier": "LVL-2", + "requirements": [ + "req-1", + "req-2" + ], + "title": "Level 2" + } + ], + "name": "Some Standard", + "owner": "Some Owner", + "requirements": [ + { + "bom-ref": "req-1", + "descriptions": [ + "Requirement 1 described here", + "and here" + ], + "externalReferences": [ + { + "type": "website", + "url": "https://cyclonedx.org" + } + ], + "identifier": "REQ-1", + "openCre": [ + "CRE:1-2" + ], + "properties": [ + { + "name": "key1", + "value": "val1a" + }, + { + "name": "key1", + "value": "val1b" + } + ], + "text": "some requirement text", + "title": "Requirement 1" + }, + { + "bom-ref": "req-2", + "descriptions": [ + "Requirement 2 described here" + ], + "identifier": "REQ-2", + "openCre": [ + "CRE:1-2", + "CRE:3-4" + ], + "parent": "req-1", + "properties": [ + { + "name": "key2", + "value": "val2" + } + ], + "text": "some requirement text", + "title": "Requirement 2" + } + ], + "version": "1.2.3" + } + ] + }, + "metadata": { + "timestamp": "2023-01-07T13:44:32.312678+00:00" + }, + "properties": [ + { + "name": "key1", + "value": "val1" + }, + { + "name": "key2", + "value": "val2" + } + ], + "serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac", + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.6" +} \ No newline at end of file diff --git a/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.6.xml.bin b/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.6.xml.bin new file mode 100644 index 00000000..3e6a145e --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_definitions_and_detailed_standards-1.6.xml.bin @@ -0,0 +1,117 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + + val1 + val2 + + + + + Other Standard + 1.0.0 + Other description + Other Owner + + + REQ-3 + Requirement 3 + some requirement text + + Requirement 3 described here + and here + + CRE:5-6 + CRE:7-8 + + val3 + + + + + + LVL-3 + Level 3 + Level 3 description + + req-3 + + + + + + https://cyclonedx.org + + + + + Some Standard + 1.2.3 + Some description + Some Owner + + + REQ-1 + Requirement 1 + some requirement text + + Requirement 1 described here + and here + + CRE:1-2 + + val1a + val1b + + + + https://cyclonedx.org + + + + + REQ-2 + Requirement 2 + some requirement text + + Requirement 2 described here + + CRE:1-2 + CRE:3-4 + req-1 + + val2 + + + + + + LVL-1 + Level 1 + Level 1 description + + + LVL-2 + Level 2 + Level 2 description + + req-1 + req-2 + + + + + + https://cyclonedx.org + No comment + + 806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b + + + + + + + diff --git a/tests/test_model_definition.py b/tests/test_model_definition.py index a464bfcd..cf026bbc 100644 --- a/tests/test_model_definition.py +++ b/tests/test_model_definition.py @@ -18,7 +18,10 @@ from unittest import TestCase -from cyclonedx.model.definition import Definitions, Standard +from ddt import ddt, named_data + +from cyclonedx.exception.model import InvalidCreIdException +from cyclonedx.model.definition import CreId, Definitions, Level, Requirement, Standard class TestModelDefinitions(TestCase): @@ -28,13 +31,13 @@ def test_init(self) -> Definitions: dr = Definitions( standards=(s, ), ) + self.assertEqual(1, len(dr.standards)) self.assertIs(s, tuple(dr.standards)[0]) return dr def test_filled(self) -> None: dr = self.test_init() self.assertIsNotNone(dr.standards) - self.assertEqual(1, len(dr.standards)) self.assertTrue(dr) def test_empty(self) -> None: @@ -65,3 +68,73 @@ def test_equal(self) -> None: tr2 = Definitions() tr2.standards.add(s) self.assertTrue(dr1 == tr2) + + +@ddt +class TestModelCreId(TestCase): + + def test_different(self) -> None: + id1 = CreId('CRE:123-456') + id2 = CreId('CRE:987-654') + self.assertNotEqual(id(id1), id(id2)) + self.assertNotEqual(hash(id1), hash(id2)) + self.assertFalse(id1 == id2) + + def test_same(self) -> None: + id1 = CreId('CRE:123-456') + id2 = CreId('CRE:123-456') + self.assertNotEqual(id(id1), id(id2)) + self.assertEqual(hash(id1), hash(id2)) + self.assertTrue(id1 == id2) + + def test_invalid_no_id(self) -> None: + with self.assertRaises(TypeError): + CreId() + + @named_data( + ['empty', ''], + ['arbitrary string', 'some string'], + ['missing prefix', '123-456'], + ['additional part', 'CRE:123-456-789'], + ['no numbers', 'CRE:abc-def'], + ['no delimiter', 'CRE:123456'], + ) + def test_invalid_id(self, wrong_id: str) -> None: + with self.assertRaises(InvalidCreIdException): + CreId(wrong_id) + + +class TestModelRequirements(TestCase): + + def test_bom_ref_is_set_from_value(self) -> None: + r = Requirement(bom_ref='123-456') + self.assertIsNotNone(r.bom_ref) + self.assertEqual('123-456', r.bom_ref.value) + + def test_bom_ref_is_set_if_none_given(self) -> None: + r = Requirement() + self.assertIsNotNone(r.bom_ref) + + +class TestModelLevel(TestCase): + + def test_bom_ref_is_set_from_value(self) -> None: + r = Level(bom_ref='123-456') + self.assertIsNotNone(r.bom_ref) + self.assertEqual('123-456', r.bom_ref.value) + + def test_bom_ref_is_set_if_none_given(self) -> None: + r = Level() + self.assertIsNotNone(r.bom_ref) + + +class TestModelStandard(TestCase): + + def test_bom_ref_is_set_from_value(self) -> None: + r = Standard(bom_ref='123-456') + self.assertIsNotNone(r.bom_ref) + self.assertEqual('123-456', r.bom_ref.value) + + def test_bom_ref_is_set_if_none_given(self) -> None: + r = Standard() + self.assertIsNotNone(r.bom_ref)