diff --git a/cyclonedx/_internal/__init__.py b/cyclonedx/_internal/__init__.py index 4513dbac..c0943da5 100644 --- a/cyclonedx/_internal/__init__.py +++ b/cyclonedx/_internal/__init__.py @@ -20,3 +20,14 @@ !!! ALL SYMBOLS IN HERE ARE INTERNAL. Everything might change without any notice. """ + +from typing import Optional, Union + +from ..model.bom_ref import BomRef + + +def bom_ref_from_str(bom_ref: Optional[Union[str, BomRef]]) -> BomRef: + if isinstance(bom_ref, BomRef): + return bom_ref + else: + return BomRef(value=str(bom_ref) if bom_ref else None) diff --git a/cyclonedx/model/bom.py b/cyclonedx/model/bom.py index 7bd09740..c4478ac1 100644 --- a/cyclonedx/model/bom.py +++ b/cyclonedx/model/bom.py @@ -41,6 +41,7 @@ from .bom_ref import BomRef from .component import Component from .contact import OrganizationalContact, OrganizationalEntity +from .definition import DefinitionRepository from .dependency import Dependable, Dependency from .license import License, LicenseExpression, LicenseRepository from .service import Service @@ -317,6 +318,7 @@ def __init__( dependencies: Optional[Iterable[Dependency]] = None, vulnerabilities: Optional[Iterable[Vulnerability]] = None, properties: Optional[Iterable[Property]] = None, + definitions: Optional[DefinitionRepository] = None, ) -> None: """ Create a new Bom that you can manually/programmatically add data to later. @@ -333,6 +335,7 @@ def __init__( self.vulnerabilities = vulnerabilities or [] # type:ignore[assignment] self.dependencies = dependencies or [] # type:ignore[assignment] self.properties = properties or [] # type:ignore[assignment] + self.definitions = definitions or DefinitionRepository() @property @serializable.type_mapping(UrnUuidHelper) @@ -520,6 +523,22 @@ def vulnerabilities(self) -> 'SortedSet[Vulnerability]': def vulnerabilities(self, vulnerabilities: Iterable[Vulnerability]) -> None: self._vulnerabilities = SortedSet(vulnerabilities) + @property + @serializable.view(SchemaVersion1Dot6) + @serializable.xml_sequence(90) + def definitions(self) -> Optional[DefinitionRepository]: + """ + The repository for definitions + + Returns: + `DefinitionRepository` + """ + return self._definitions if len(self._definitions) > 0 else None + + @definitions.setter + def definitions(self, definitions: DefinitionRepository) -> None: + self._definitions = definitions + # @property # ... # @serializable.view(SchemaVersion1Dot5) diff --git a/cyclonedx/model/definition.py b/cyclonedx/model/definition.py new file mode 100644 index 00000000..66a2edbc --- /dev/null +++ b/cyclonedx/model/definition.py @@ -0,0 +1,244 @@ +# This file is part of CycloneDX Python Library +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) OWASP Foundation. All Rights Reserved. + +from typing import TYPE_CHECKING, Any, Iterable, Optional, Union + +import serializable +from sortedcontainers import SortedSet + +from .._internal import bom_ref_from_str +from .._internal.compare import ComparableTuple as _ComparableTuple +from ..serialization import BomRefHelper +from . import ExternalReference +from .bom_ref import BomRef + +if TYPE_CHECKING: # pragma: no cover + pass + + +@serializable.serializable_class(serialization_types=[ + serializable.SerializationType.JSON, + serializable.SerializationType.XML] +) +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, + external_references: Optional[Iterable['ExternalReference']] = None + ) -> None: + self._bom_ref = bom_ref_from_str(bom_ref) + self.name = name + self.version = version + self.description = description + self.owner = owner + self.external_references = external_references or [] # type:ignore[assignment] + + 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))) + return NotImplemented + + def __eq__(self, other: object) -> bool: + if isinstance(other, Standard): + return hash(other) == hash(self) + return False + + def __hash__(self) -> int: + return hash(( + self.bom_ref, self.name, self.version, self.description, self.owner, tuple(self.external_references) + )) + + def __repr__(self) -> str: + return f'' + + @property + @serializable.json_name('bom-ref') + @serializable.type_mapping(BomRefHelper) + @serializable.xml_attribute() + @serializable.xml_name('bom-ref') + 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 + unique within the BOM. If a value was not provided in the constructor, a UUIDv4 will have been assigned. + Returns: + `BomRef` + """ + return self._bom_ref + + @property + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + @serializable.xml_sequence(2) + def name(self) -> Optional[str]: + """ + Returns: + The name of the standard + """ + return self._name + + @name.setter + def name(self, name: Optional[str]) -> None: + self._name = name + + @property + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + @serializable.xml_sequence(3) + def version(self) -> Optional[str]: + """ + Returns: + The version of the standard + """ + return self._version + + @version.setter + def version(self, version: Optional[str]) -> None: + self._version = version + + @property + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + @serializable.xml_sequence(4) + def description(self) -> Optional[str]: + """ + Returns: + The description of the standard + """ + return self._description + + @description.setter + def description(self, description: Optional[str]) -> None: + self._description = description + + @property + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + @serializable.xml_sequence(5) + def owner(self) -> Optional[str]: + """ + Returns: + The owner of the standard, often the entity responsible for its release. + """ + return self._owner + + @owner.setter + def owner(self, owner: Optional[str]) -> None: + self._owner = owner + + # @property + # @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'requirement') + # @serializable.xml_sequence(10) + # 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(20) + # 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') + @serializable.xml_sequence(30) + def external_references(self) -> 'SortedSet[ExternalReference]': + """ + Returns: + A SortedSet of external references associated with the standard. + """ + return self._external_references + + @external_references.setter + def external_references(self, external_references: Iterable[ExternalReference]) -> None: + self._external_references = SortedSet(external_references) + + +@serializable.serializable_class(name='definitions', + serialization_types=[ + serializable.SerializationType.JSON, + serializable.SerializationType.XML] + ) +class DefinitionRepository: + """ + The repository for definitions + """ + + def __init__( + self, *, + standards: Optional[Iterable[Standard]] = None + ) -> None: + self.standards = standards or () # type:ignore[assignment] + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'standard') + @serializable.xml_sequence(1) + def standards(self) -> 'SortedSet[Standard]': + """ + Returns: + A SortedSet of Standards + """ + return self._standards + + @standards.setter + def standards(self, standards: Iterable[Standard]) -> None: + self._standards = SortedSet(standards) + + def __len__(self) -> int: + return len(self._standards) + + def __bool__(self) -> bool: + return len(self._standards) > 0 + + def __eq__(self, other: object) -> bool: + if not isinstance(other, DefinitionRepository): + return False + + return self._standards == other._standards + + def __hash__(self) -> int: + return hash((tuple(self._standards))) + + def __lt__(self, other: Any) -> bool: + if isinstance(other, DefinitionRepository): + return (_ComparableTuple(self._standards) + < _ComparableTuple(other.standards)) + return NotImplemented + + def __repr__(self) -> str: + return '' diff --git a/tests/test_model_definition_repository.py b/tests/test_model_definition_repository.py new file mode 100644 index 00000000..5605c058 --- /dev/null +++ b/tests/test_model_definition_repository.py @@ -0,0 +1,65 @@ +# This file is part of CycloneDX Python Library +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) OWASP Foundation. All Rights Reserved. + + +from unittest import TestCase + +from cyclonedx.model.definition import DefinitionRepository, Standard + + +class TestModelDefinitionRepository(TestCase): + + def test_init(self) -> DefinitionRepository: + s = Standard(name='test-standard') + dr = DefinitionRepository( + standards=(s, ), + ) + self.assertIs(s, tuple(dr.standards)[0]) + return dr + + def test_filled(self) -> None: + dr = self.test_init() + self.assertEqual(1, len(dr)) + self.assertTrue(dr) + + def test_empty(self) -> None: + dr = DefinitionRepository() + self.assertEqual(0, len(dr)) + self.assertFalse(dr) + + def test_unequal_different_type(self) -> None: + dr = DefinitionRepository() + self.assertFalse(dr == 'other') + + def test_equal_self(self) -> None: + dr = DefinitionRepository() + dr.standards.add(Standard(name='my-standard')) + self.assertTrue(dr == dr) + + def test_unequal(self) -> None: + dr1 = DefinitionRepository() + dr1.standards.add(Standard(name='my-standard')) + tr2 = DefinitionRepository() + self.assertFalse(dr1 == tr2) + + def test_equal(self) -> None: + s = Standard(name='my-standard') + dr1 = DefinitionRepository() + dr1.standards.add(s) + tr2 = DefinitionRepository() + tr2.standards.add(s) + self.assertTrue(dr1 == tr2)