diff --git a/cyclonedx/model/bom.py b/cyclonedx/model/bom.py index 7bd09740..94cdd83e 100644 --- a/cyclonedx/model/bom.py +++ b/cyclonedx/model/bom.py @@ -43,6 +43,7 @@ from .contact import OrganizationalContact, OrganizationalEntity from .dependency import Dependable, Dependency from .license import License, LicenseExpression, LicenseRepository +from .lifecycle import Lifecycle, LifecycleRepository, _LifecycleRepositoryHelper from .service import Service from .tool import Tool, ToolRepository, _ToolRepositoryHelper from .vulnerability import Vulnerability @@ -70,6 +71,7 @@ def __init__( properties: Optional[Iterable[Property]] = None, timestamp: Optional[datetime] = None, manufacturer: Optional[OrganizationalEntity] = None, + lifecycles: Optional[Iterable[Lifecycle]] = None, # Deprecated as of v1.6 manufacture: Optional[OrganizationalEntity] = None, ) -> None: @@ -81,6 +83,7 @@ def __init__( self.licenses = licenses or [] # type:ignore[assignment] self.properties = properties or [] # type:ignore[assignment] self.manufacturer = manufacturer + self.lifecycles = lifecycles or [] # type:ignore[assignment] self.manufacture = manufacture if manufacture: @@ -105,16 +108,23 @@ def timestamp(self) -> datetime: def timestamp(self, timestamp: datetime) -> None: self._timestamp = timestamp - # @property - # ... - # @serializable.view(SchemaVersion1Dot5) - # @serializable.xml_sequence(2) - # def lifecycles(self) -> ...: - # ... # TODO since CDX1.5 - # - # @lifecycles.setter - # def lifecycles(self, ...) -> None: - # ... # TODO since CDX1.5 + @property + @serializable.view(SchemaVersion1Dot5) + @serializable.view(SchemaVersion1Dot6) + @serializable.type_mapping(_LifecycleRepositoryHelper) + @serializable.xml_sequence(2) + def lifecycles(self) -> LifecycleRepository: + """ + An optional list of BOM lifecycle stages. + + Returns: + Set of `Lifecycle` + """ + return self._lifecycles + + @lifecycles.setter + def lifecycles(self, lifecycles: Iterable[Lifecycle]) -> None: + self._lifecycles = LifecycleRepository(lifecycles) @property @serializable.type_mapping(_ToolRepositoryHelper) @@ -290,7 +300,7 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash(( tuple(self.authors), self.component, tuple(self.licenses), self.manufacture, tuple(self.properties), - self.supplier, self.timestamp, self.tools, self.manufacturer, + tuple(self.lifecycles), self.supplier, self.timestamp, self.tools, self.manufacturer )) def __repr__(self) -> str: diff --git a/cyclonedx/model/lifecycle.py b/cyclonedx/model/lifecycle.py new file mode 100644 index 00000000..24082f5d --- /dev/null +++ b/cyclonedx/model/lifecycle.py @@ -0,0 +1,245 @@ +# 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. + +""" + This set of classes represents the lifecycles types in the CycloneDX standard. + +.. note:: + Introduced in CycloneDX v1.5 + +.. note:: + See the CycloneDX Schema for lifecycles: https://cyclonedx.org/docs/1.5/#metadata_lifecycles +""" + +from enum import Enum +from json import loads as json_loads +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type, Union +from xml.etree.ElementTree import Element # nosec B405 + +import serializable +from serializable.helpers import BaseHelper +from sortedcontainers import SortedSet + +from .._internal.compare import ComparableTuple as _ComparableTuple +from ..exception.serialization import CycloneDxDeserializationException + +if TYPE_CHECKING: # pragma: no cover + from serializable import ViewType + + +@serializable.serializable_enum +class LifecyclePhase(str, Enum): + """ + Enum object that defines the permissible 'phase' for a Lifecycle according to the CycloneDX schema. + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.3/#type_classification + """ + DESIGN = 'design' + PRE_BUILD = 'pre-build' + BUILD = 'build' + POST_BUILD = 'post-build' + OPERATIONS = 'operations' + DISCOVERY = 'discovery' + DECOMMISSION = 'decommission' + + +@serializable.serializable_class +class PredefinedLifecycle: + """ + Object that defines pre-defined phases in the product lifecycle. + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.5/#metadata_lifecycles + """ + + def __init__(self, phase: LifecyclePhase) -> None: + self._phase = phase + + @property + def phase(self) -> LifecyclePhase: + return self._phase + + @phase.setter + def phase(self, phase: LifecyclePhase) -> None: + self._phase = phase + + def __hash__(self) -> int: + return hash(self._phase) + + def __eq__(self, other: object) -> bool: + if isinstance(other, PredefinedLifecycle): + return hash(other) == hash(self) + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, PredefinedLifecycle): + return self._phase < other._phase + if isinstance(other, NamedLifecycle): + return True # put PredefinedLifecycle before any NamedLifecycle + return NotImplemented + + def __repr__(self) -> str: + return f'' + + +@serializable.serializable_class +class NamedLifecycle: + """ + Object that defines custom state in the product lifecycle. + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.5/#metadata_lifecycles + """ + + def __init__(self, name: str, *, description: Optional[str] = None) -> None: + self._name = name + self._description = description + + @property + @serializable.xml_sequence(1) + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + def name(self) -> str: + """ + Name of the lifecycle phase. + + Returns: + `str` + """ + return self._name + + @name.setter + def name(self, name: str) -> None: + self._name = name + + @property + @serializable.xml_sequence(2) + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + def description(self) -> Optional[str]: + """ + Description of the lifecycle phase. + + Returns: + `str` + """ + return self._description + + @description.setter + def description(self, description: Optional[str]) -> None: + self._description = description + + def __hash__(self) -> int: + return hash((self._name, self._description)) + + def __eq__(self, other: object) -> bool: + if isinstance(other, NamedLifecycle): + return hash(other) == hash(self) + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, NamedLifecycle): + return _ComparableTuple((self._name, self._description)) < _ComparableTuple( + (other._name, other._description) + ) + if isinstance(other, PredefinedLifecycle): + return False # put NamedLifecycle after any PredefinedLifecycle + return NotImplemented + + def __repr__(self) -> str: + return f'' + + +Lifecycle = Union[PredefinedLifecycle, NamedLifecycle] +"""TypeAlias for a union of supported lifecycle models. + +- :class:`PredefinedLifecycle` +- :class:`NamedLifecycle` +""" + +if TYPE_CHECKING: # pragma: no cover + # workaround for https://github.com/python/mypy/issues/5264 + # this code path is taken when static code analysis or documentation tools runs through. + class LifecycleRepository(SortedSet[Lifecycle]): + """Collection of :class:`Lifecycle`. + + This is a `set`, not a `list`. Order MUST NOT matter here. + """ +else: + class LifecycleRepository(SortedSet): + """Collection of :class:`Lifecycle`. + + This is a `set`, not a `list`. Order MUST NOT matter here. + """ + + +class _LifecycleRepositoryHelper(BaseHelper): + @classmethod + def json_normalize(cls, o: LifecycleRepository, *, + view: Optional[Type['ViewType']], + **__: Any) -> Any: + if len(o) == 0: + return None + return [json_loads(li.as_json( # type:ignore[union-attr] + view_=view)) for li in o] + + @classmethod + def json_denormalize(cls, o: List[Dict[str, Any]], + **__: Any) -> LifecycleRepository: + repo = LifecycleRepository() + for li in o: + if 'phase' in li: + repo.add(PredefinedLifecycle.from_json( # type:ignore[attr-defined] + li)) + elif 'name' in li: + repo.add(NamedLifecycle.from_json( # type:ignore[attr-defined] + li)) + else: + raise CycloneDxDeserializationException(f'unexpected: {li!r}') + return repo + + @classmethod + def xml_normalize(cls, o: LifecycleRepository, *, + element_name: str, + view: Optional[Type['ViewType']], + xmlns: Optional[str], + **__: Any) -> Optional[Element]: + if len(o) == 0: + return None + elem = Element(element_name) + for li in o: + elem.append(li.as_xml( # type:ignore[union-attr] + view_=view, as_string=False, element_name='lifecycle', xmlns=xmlns)) + return elem + + @classmethod + def xml_denormalize(cls, o: Element, + default_ns: Optional[str], + **__: Any) -> LifecycleRepository: + repo = LifecycleRepository() + ns_map = {'bom': default_ns or ''} + # Do not iterate over `o` and do not check for expected `.tag` of items. + # This check could have been done by schema validators before even deserializing. + for li in o.iterfind('bom:lifecycle', ns_map): + if li.find('bom:phase', ns_map) is not None: + repo.add(PredefinedLifecycle.from_xml( # type:ignore[attr-defined] + li, default_ns)) + elif li.find('bom:name', ns_map) is not None: + repo.add(NamedLifecycle.from_xml( # type:ignore[attr-defined] + li, default_ns)) + else: + raise CycloneDxDeserializationException(f'unexpected content: {li!r}') + return repo diff --git a/tests/_data/models.py b/tests/_data/models.py index 72504e83..ab1805eb 100644 --- a/tests/_data/models.py +++ b/tests/_data/models.py @@ -87,6 +87,7 @@ ) from cyclonedx.model.issue import IssueClassification, IssueType, IssueTypeSource from cyclonedx.model.license import DisjunctiveLicense, License, LicenseAcknowledgement, LicenseExpression +from cyclonedx.model.lifecycle import LifecyclePhase, NamedLifecycle, PredefinedLifecycle from cyclonedx.model.release_note import ReleaseNotes from cyclonedx.model.service import Service from cyclonedx.model.tool import Tool, ToolRepository @@ -534,6 +535,7 @@ def get_bom_just_complete_metadata() -> Bom: content='VGVzdCBjb250ZW50IC0gdGhpcyBpcyBub3QgdGhlIEFwYWNoZSAyLjAgbGljZW5zZSE=' ) )] + bom.metadata.lifecycles = [PredefinedLifecycle(LifecyclePhase.BUILD)] bom.metadata.properties = get_properties_1() return bom @@ -1273,6 +1275,20 @@ def get_bom_for_issue_630_empty_property() -> Bom: }) +def get_bom_with_lifecycles() -> Bom: + return _make_bom( + metadata=BomMetaData( + lifecycles=[ + PredefinedLifecycle(LifecyclePhase.BUILD), + PredefinedLifecycle(LifecyclePhase.POST_BUILD), + NamedLifecycle(name='platform-integration-testing', + description='Integration testing specific to the runtime platform'), + ], + component=Component(name='app', type=ComponentType.APPLICATION, bom_ref='my-app'), + ), + ) + + # --- @@ -1318,4 +1334,5 @@ def get_bom_for_issue_630_empty_property() -> Bom: get_bom_for_issue_598_multiple_components_with_purl_qualifiers, get_bom_with_component_setuptools_with_v16_fields, get_bom_for_issue_630_empty_property, + get_bom_with_lifecycles, } diff --git a/tests/_data/snapshots/enum_LifecyclePhase-1.0.xml.bin b/tests/_data/snapshots/enum_LifecyclePhase-1.0.xml.bin new file mode 100644 index 00000000..acb06612 --- /dev/null +++ b/tests/_data/snapshots/enum_LifecyclePhase-1.0.xml.bin @@ -0,0 +1,4 @@ + + + + diff --git a/tests/_data/snapshots/enum_LifecyclePhase-1.1.xml.bin b/tests/_data/snapshots/enum_LifecyclePhase-1.1.xml.bin new file mode 100644 index 00000000..55ef5cda --- /dev/null +++ b/tests/_data/snapshots/enum_LifecyclePhase-1.1.xml.bin @@ -0,0 +1,4 @@ + + + + diff --git a/tests/_data/snapshots/enum_LifecyclePhase-1.2.json.bin b/tests/_data/snapshots/enum_LifecyclePhase-1.2.json.bin new file mode 100644 index 00000000..8f473bd3 --- /dev/null +++ b/tests/_data/snapshots/enum_LifecyclePhase-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/enum_LifecyclePhase-1.2.xml.bin b/tests/_data/snapshots/enum_LifecyclePhase-1.2.xml.bin new file mode 100644 index 00000000..df1938ec --- /dev/null +++ b/tests/_data/snapshots/enum_LifecyclePhase-1.2.xml.bin @@ -0,0 +1,6 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + diff --git a/tests/_data/snapshots/enum_LifecyclePhase-1.3.json.bin b/tests/_data/snapshots/enum_LifecyclePhase-1.3.json.bin new file mode 100644 index 00000000..02943890 --- /dev/null +++ b/tests/_data/snapshots/enum_LifecyclePhase-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/enum_LifecyclePhase-1.3.xml.bin b/tests/_data/snapshots/enum_LifecyclePhase-1.3.xml.bin new file mode 100644 index 00000000..8341ff60 --- /dev/null +++ b/tests/_data/snapshots/enum_LifecyclePhase-1.3.xml.bin @@ -0,0 +1,6 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + diff --git a/tests/_data/snapshots/enum_LifecyclePhase-1.4.json.bin b/tests/_data/snapshots/enum_LifecyclePhase-1.4.json.bin new file mode 100644 index 00000000..48f1745d --- /dev/null +++ b/tests/_data/snapshots/enum_LifecyclePhase-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/enum_LifecyclePhase-1.4.xml.bin b/tests/_data/snapshots/enum_LifecyclePhase-1.4.xml.bin new file mode 100644 index 00000000..d0a7d4c9 --- /dev/null +++ b/tests/_data/snapshots/enum_LifecyclePhase-1.4.xml.bin @@ -0,0 +1,6 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + diff --git a/tests/_data/snapshots/enum_LifecyclePhase-1.5.json.bin b/tests/_data/snapshots/enum_LifecyclePhase-1.5.json.bin new file mode 100644 index 00000000..438211b7 --- /dev/null +++ b/tests/_data/snapshots/enum_LifecyclePhase-1.5.json.bin @@ -0,0 +1,43 @@ +{ + "metadata": { + "lifecycles": [ + { + "phase": "build" + }, + { + "phase": "decommission" + }, + { + "phase": "design" + }, + { + "phase": "discovery" + }, + { + "phase": "operations" + }, + { + "phase": "post-build" + }, + { + "phase": "pre-build" + } + ], + "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/enum_LifecyclePhase-1.5.xml.bin b/tests/_data/snapshots/enum_LifecyclePhase-1.5.xml.bin new file mode 100644 index 00000000..a7b6f45c --- /dev/null +++ b/tests/_data/snapshots/enum_LifecyclePhase-1.5.xml.bin @@ -0,0 +1,33 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + + build + + + decommission + + + design + + + discovery + + + operations + + + post-build + + + pre-build + + + + + val1 + val2 + + diff --git a/tests/_data/snapshots/enum_LifecyclePhase-1.6.json.bin b/tests/_data/snapshots/enum_LifecyclePhase-1.6.json.bin new file mode 100644 index 00000000..4daf2f8f --- /dev/null +++ b/tests/_data/snapshots/enum_LifecyclePhase-1.6.json.bin @@ -0,0 +1,43 @@ +{ + "metadata": { + "lifecycles": [ + { + "phase": "build" + }, + { + "phase": "decommission" + }, + { + "phase": "design" + }, + { + "phase": "discovery" + }, + { + "phase": "operations" + }, + { + "phase": "post-build" + }, + { + "phase": "pre-build" + } + ], + "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/enum_LifecyclePhase-1.6.xml.bin b/tests/_data/snapshots/enum_LifecyclePhase-1.6.xml.bin new file mode 100644 index 00000000..514837b9 --- /dev/null +++ b/tests/_data/snapshots/enum_LifecyclePhase-1.6.xml.bin @@ -0,0 +1,33 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + + build + + + decommission + + + design + + + discovery + + + operations + + + post-build + + + pre-build + + + + + val1 + val2 + + diff --git a/tests/_data/snapshots/get_bom_just_complete_metadata-1.5.json.bin b/tests/_data/snapshots/get_bom_just_complete_metadata-1.5.json.bin index d2c06c75..c3e653c7 100644 --- a/tests/_data/snapshots/get_bom_just_complete_metadata-1.5.json.bin +++ b/tests/_data/snapshots/get_bom_just_complete_metadata-1.5.json.bin @@ -350,6 +350,11 @@ } } ], + "lifecycles": [ + { + "phase": "build" + } + ], "manufacture": { "contact": [ { diff --git a/tests/_data/snapshots/get_bom_just_complete_metadata-1.5.xml.bin b/tests/_data/snapshots/get_bom_just_complete_metadata-1.5.xml.bin index 928f05ed..0280b1ad 100644 --- a/tests/_data/snapshots/get_bom_just_complete_metadata-1.5.xml.bin +++ b/tests/_data/snapshots/get_bom_just_complete_metadata-1.5.xml.bin @@ -2,6 +2,11 @@ 2023-01-07T13:44:32.312678+00:00 + + + build + + A N Other diff --git a/tests/_data/snapshots/get_bom_just_complete_metadata-1.6.json.bin b/tests/_data/snapshots/get_bom_just_complete_metadata-1.6.json.bin index fa530802..ff9232be 100644 --- a/tests/_data/snapshots/get_bom_just_complete_metadata-1.6.json.bin +++ b/tests/_data/snapshots/get_bom_just_complete_metadata-1.6.json.bin @@ -380,6 +380,11 @@ } } ], + "lifecycles": [ + { + "phase": "build" + } + ], "manufacture": { "address": { "country": "GB", diff --git a/tests/_data/snapshots/get_bom_just_complete_metadata-1.6.xml.bin b/tests/_data/snapshots/get_bom_just_complete_metadata-1.6.xml.bin index fcc591fd..1c0dc447 100644 --- a/tests/_data/snapshots/get_bom_just_complete_metadata-1.6.xml.bin +++ b/tests/_data/snapshots/get_bom_just_complete_metadata-1.6.xml.bin @@ -2,6 +2,11 @@ 2023-01-07T13:44:32.312678+00:00 + + + build + + A N Other diff --git a/tests/_data/snapshots/get_bom_with_lifecycles-1.0.xml.bin b/tests/_data/snapshots/get_bom_with_lifecycles-1.0.xml.bin new file mode 100644 index 00000000..acb06612 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_lifecycles-1.0.xml.bin @@ -0,0 +1,4 @@ + + + + diff --git a/tests/_data/snapshots/get_bom_with_lifecycles-1.1.xml.bin b/tests/_data/snapshots/get_bom_with_lifecycles-1.1.xml.bin new file mode 100644 index 00000000..55ef5cda --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_lifecycles-1.1.xml.bin @@ -0,0 +1,4 @@ + + + + diff --git a/tests/_data/snapshots/get_bom_with_lifecycles-1.2.json.bin b/tests/_data/snapshots/get_bom_with_lifecycles-1.2.json.bin new file mode 100644 index 00000000..18150abd --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_lifecycles-1.2.json.bin @@ -0,0 +1,21 @@ +{ + "dependencies": [ + { + "ref": "my-app" + } + ], + "metadata": { + "component": { + "bom-ref": "my-app", + "name": "app", + "type": "application", + "version": "" + }, + "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_lifecycles-1.2.xml.bin b/tests/_data/snapshots/get_bom_with_lifecycles-1.2.xml.bin new file mode 100644 index 00000000..5fb21515 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_lifecycles-1.2.xml.bin @@ -0,0 +1,13 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + app + + + + + + + diff --git a/tests/_data/snapshots/get_bom_with_lifecycles-1.3.json.bin b/tests/_data/snapshots/get_bom_with_lifecycles-1.3.json.bin new file mode 100644 index 00000000..fd64f145 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_lifecycles-1.3.json.bin @@ -0,0 +1,21 @@ +{ + "dependencies": [ + { + "ref": "my-app" + } + ], + "metadata": { + "component": { + "bom-ref": "my-app", + "name": "app", + "type": "application", + "version": "" + }, + "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_lifecycles-1.3.xml.bin b/tests/_data/snapshots/get_bom_with_lifecycles-1.3.xml.bin new file mode 100644 index 00000000..7bb6d933 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_lifecycles-1.3.xml.bin @@ -0,0 +1,13 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + app + + + + + + + diff --git a/tests/_data/snapshots/get_bom_with_lifecycles-1.4.json.bin b/tests/_data/snapshots/get_bom_with_lifecycles-1.4.json.bin new file mode 100644 index 00000000..19983566 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_lifecycles-1.4.json.bin @@ -0,0 +1,20 @@ +{ + "dependencies": [ + { + "ref": "my-app" + } + ], + "metadata": { + "component": { + "bom-ref": "my-app", + "name": "app", + "type": "application" + }, + "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_lifecycles-1.4.xml.bin b/tests/_data/snapshots/get_bom_with_lifecycles-1.4.xml.bin new file mode 100644 index 00000000..118c192f --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_lifecycles-1.4.xml.bin @@ -0,0 +1,12 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + app + + + + + + diff --git a/tests/_data/snapshots/get_bom_with_lifecycles-1.5.json.bin b/tests/_data/snapshots/get_bom_with_lifecycles-1.5.json.bin new file mode 100644 index 00000000..fca2c94b --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_lifecycles-1.5.json.bin @@ -0,0 +1,42 @@ +{ + "dependencies": [ + { + "ref": "my-app" + } + ], + "metadata": { + "component": { + "bom-ref": "my-app", + "name": "app", + "type": "application" + }, + "lifecycles": [ + { + "phase": "build" + }, + { + "phase": "post-build" + }, + { + "description": "Integration testing specific to the runtime platform", + "name": "platform-integration-testing" + } + ], + "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_lifecycles-1.5.xml.bin b/tests/_data/snapshots/get_bom_with_lifecycles-1.5.xml.bin new file mode 100644 index 00000000..cfa09097 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_lifecycles-1.5.xml.bin @@ -0,0 +1,28 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + + build + + + post-build + + + platform-integration-testing + Integration testing specific to the runtime platform + + + + app + + + + + + + val1 + val2 + + diff --git a/tests/_data/snapshots/get_bom_with_lifecycles-1.6.json.bin b/tests/_data/snapshots/get_bom_with_lifecycles-1.6.json.bin new file mode 100644 index 00000000..194bf22f --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_lifecycles-1.6.json.bin @@ -0,0 +1,42 @@ +{ + "dependencies": [ + { + "ref": "my-app" + } + ], + "metadata": { + "component": { + "bom-ref": "my-app", + "name": "app", + "type": "application" + }, + "lifecycles": [ + { + "phase": "build" + }, + { + "phase": "post-build" + }, + { + "description": "Integration testing specific to the runtime platform", + "name": "platform-integration-testing" + } + ], + "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_lifecycles-1.6.xml.bin b/tests/_data/snapshots/get_bom_with_lifecycles-1.6.xml.bin new file mode 100644 index 00000000..7ff8cb10 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_lifecycles-1.6.xml.bin @@ -0,0 +1,28 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + + build + + + post-build + + + platform-integration-testing + Integration testing specific to the runtime platform + + + + app + + + + + + + val1 + val2 + + diff --git a/tests/test_enums.py b/tests/test_enums.py index 294c58f2..27c37def 100644 --- a/tests/test_enums.py +++ b/tests/test_enums.py @@ -29,10 +29,11 @@ from cyclonedx.exception import MissingOptionalDependencyException from cyclonedx.exception.serialization import SerializationOfUnsupportedComponentTypeException from cyclonedx.model import AttachedText, ExternalReference, HashType, XsUri -from cyclonedx.model.bom import Bom +from cyclonedx.model.bom import Bom, BomMetaData from cyclonedx.model.component import Component, Patch, Pedigree from cyclonedx.model.issue import IssueType from cyclonedx.model.license import DisjunctiveLicense +from cyclonedx.model.lifecycle import LifecyclePhase, PredefinedLifecycle from cyclonedx.model.service import DataClassification, Service from cyclonedx.model.vulnerability import ( BomTarget, @@ -471,3 +472,21 @@ def test_cases_render_valid(self, of: OutputFormat, sv: SchemaVersion, *_: Any, for vs in VulnerabilitySeverity ))]) super()._test_cases_render(bom, of, sv) + + +@ddt +class TestEnumLifecyclePhase(_EnumTestCase): + + @idata(set(chain( + dp_cases_from_xml_schemas(f"./{SCHEMA_NS}simpleType[@name='lifecyclePhaseType']"), + dp_cases_from_json_schemas('definitions', 'metadata', 'properties', 'lifecycles', 'items', 'phase'), + ))) + def test_knows_value(self, value: str) -> None: + super()._test_knows_value(LifecyclePhase, value) + + @named_data(*NAMED_OF_SV) + def test_cases_render_valid(self, of: OutputFormat, sv: SchemaVersion, *_: Any, **__: Any) -> None: + bom = _make_bom(metadata=BomMetaData( + lifecycles=[PredefinedLifecycle(phase=phase) for phase in LifecyclePhase] + )) + super()._test_cases_render(bom, of, sv) diff --git a/tests/test_model_bom.py b/tests/test_model_bom.py index 2cd36e5e..74046a09 100644 --- a/tests/test_model_bom.py +++ b/tests/test_model_bom.py @@ -29,6 +29,7 @@ from cyclonedx.model.component import Component, ComponentType from cyclonedx.model.contact import OrganizationalContact, OrganizationalEntity from cyclonedx.model.license import DisjunctiveLicense +from cyclonedx.model.lifecycle import LifecyclePhase, NamedLifecycle, PredefinedLifecycle from cyclonedx.model.tool import Tool from tests._data.models import ( get_bom_component_licenses_invalid, @@ -54,6 +55,7 @@ def test_empty_bom_metadata(self) -> None: self.assertIsNone(metadata.manufacture) self.assertIsNone(metadata.supplier) self.assertEqual(0, len(metadata.licenses)) + self.assertEqual(0, len(metadata.lifecycles)) self.assertEqual(0, len(metadata.properties)) self.assertEqual(0, len(metadata.tools)) @@ -73,12 +75,16 @@ def test_basic_bom_metadata(self) -> None: DisjunctiveLicense(id='MIT'), DisjunctiveLicense(id='Apache-2.0'), ] + lifecycles = [ + PredefinedLifecycle(phase=LifecyclePhase.BUILD), + NamedLifecycle(name='named_lifecycle', description='test'), + ] properties = [ Property(name='property_1', value='value_1'), Property(name='property_2', value='value_2', ) ] - metadata = BomMetaData(tools=tools, authors=authors, component=component, + metadata = BomMetaData(tools=tools, authors=authors, component=component, lifecycles=lifecycles, manufacture=manufacturer, supplier=supplier, licenses=licenses, properties=properties) self.assertIsNotNone(metadata.timestamp) self.assertIsNotNone(metadata.authors) @@ -90,6 +96,9 @@ def test_basic_bom_metadata(self) -> None: self.assertIsNotNone(metadata.licenses) self.assertTrue(licenses[0] in metadata.licenses) self.assertTrue(licenses[1] in metadata.licenses) + self.assertIsNotNone(metadata.lifecycles) + self.assertTrue(lifecycles[0] in metadata.lifecycles) + self.assertTrue(lifecycles[1] in metadata.lifecycles) self.assertIsNotNone(metadata.properties) self.assertTrue(properties[0] in metadata.properties) self.assertTrue(properties[1] in metadata.properties) diff --git a/tests/test_model_lifecycle.py b/tests/test_model_lifecycle.py new file mode 100644 index 00000000..96420a5a --- /dev/null +++ b/tests/test_model_lifecycle.py @@ -0,0 +1,107 @@ +# 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 random import shuffle +from unittest import TestCase + +from cyclonedx.model.lifecycle import LifecyclePhase, NamedLifecycle, PredefinedLifecycle +from tests import reorder + + +class TestModelPredefinedLifecycle(TestCase): + def test_create(self) -> None: + lifecycle = PredefinedLifecycle(phase=LifecyclePhase.BUILD) + self.assertIs(LifecyclePhase.BUILD, lifecycle.phase) + + def test_update(self) -> None: + lifecycle = PredefinedLifecycle(phase=LifecyclePhase.DESIGN) + lifecycle.phase = LifecyclePhase.DISCOVERY + self.assertIs(LifecyclePhase.DISCOVERY, lifecycle.phase) + + def test_equal(self) -> None: + a = PredefinedLifecycle(phase=LifecyclePhase.BUILD) + b = PredefinedLifecycle(phase=LifecyclePhase.BUILD) + c = PredefinedLifecycle(phase=LifecyclePhase.DESIGN) + self.assertEqual(a, b) + self.assertNotEqual(a, c) + + def test_sort(self) -> None: + expected_order = [3, 0, 2, 1] + lifecycles = [ + NamedLifecycle(name='foo', description='baz'), + NamedLifecycle(name='foo'), + NamedLifecycle(name='foo', description='qux'), + NamedLifecycle(name='bar'), + ] + expected_lifecycles = reorder(lifecycles, expected_order) + shuffle(lifecycles) + sorted_lifecycles = sorted(lifecycles) + self.assertListEqual(sorted_lifecycles, expected_lifecycles) + + +class TestModelNamedLifecycle(TestCase): + def test_create(self) -> None: + lifecycle = NamedLifecycle(name='foo') + self.assertEqual('foo', lifecycle.name) + self.assertIsNone(lifecycle.description) + + lifecycle = NamedLifecycle(name='foo2n', description='foo2d') + self.assertEqual('foo2n', lifecycle.name) + self.assertEqual('foo2d', lifecycle.description) + + def test_update(self) -> None: + lifecycle = NamedLifecycle(name='foo') + self.assertEqual('foo', lifecycle.name) + lifecycle.name = 'bar' + self.assertEqual('bar', lifecycle.name) + + def test_equal(self) -> None: + a = NamedLifecycle('foo') + b = NamedLifecycle('foo') + c = NamedLifecycle('bar') + self.assertEqual(a, b) + self.assertNotEqual(a, c) + self.assertNotEqual(a, 'foo') + + def test_sort(self) -> None: + expected_order = [3, 0, 2, 1] + lifecycles = [ + NamedLifecycle(name='foo', description='baz'), + NamedLifecycle(name='foo'), + NamedLifecycle(name='foo', description='qux'), + NamedLifecycle(name='bar'), + ] + expected_lifecycles = reorder(lifecycles, expected_order) + shuffle(lifecycles) + sorted_lifecycles = sorted(lifecycles) + self.assertListEqual(sorted_lifecycles, expected_lifecycles) + + +class TestModelLifecycle(TestCase): + def test_sort_mixed(self) -> None: + expected_order = [3, 0, 2, 1] + lifecycles = [ + PredefinedLifecycle(phase=LifecyclePhase.DESIGN), + NamedLifecycle(name='Example2'), + NamedLifecycle(name='Example'), + PredefinedLifecycle(phase=LifecyclePhase.BUILD), + ] + expected_lifecycles = reorder(lifecycles, expected_order) + shuffle(lifecycles) + sorted_lifecycles = sorted(lifecycles) + self.assertListEqual(sorted_lifecycles, expected_lifecycles)