diff --git a/cyclonedx/model/bom.py b/cyclonedx/model/bom.py index 3f76c94a..4dc7de4e 100644 --- a/cyclonedx/model/bom.py +++ b/cyclonedx/model/bom.py @@ -44,6 +44,7 @@ from ..serialization import UrnUuidHelper from . import _BOM_LINK_PREFIX, ExternalReference, Property from .bom_ref import BomRef +from .citation import Citation from .component import Component from .contact import OrganizationalContact, OrganizationalEntity from .definition import Definitions @@ -444,6 +445,7 @@ def __init__( vulnerabilities: Optional[Iterable[Vulnerability]] = None, properties: Optional[Iterable[Property]] = None, definitions: Optional[Definitions] = None, + citations: Optional[Iterable[Citation]] = None, ) -> None: """ Create a new Bom that you can manually/programmatically add data to later. @@ -461,6 +463,7 @@ def __init__( self.dependencies = dependencies or [] self.properties = properties or [] self.definitions = definitions or Definitions() + self.citations = citations or [] @property @serializable.type_mapping(UrnUuidHelper) @@ -694,6 +697,26 @@ def definitions(self) -> Optional[Definitions]: def definitions(self, definitions: Definitions) -> None: self._definitions = definitions + @property + @serializable.view(SchemaVersion1Dot7) + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'citation') + @serializable.xml_sequence(120) + def citations(self) -> 'SortedSet[Citation]': + """ + A collection of attributions indicating which entity supplied information for specific fields within the BOM. + + Returns: + Set of `Citation` + + .. note:: + Introduced in CycloneDX v1.7 + """ + return self._citations + + @citations.setter + def citations(self, citations: Iterable[Citation]) -> None: + self._citations = SortedSet(citations) + def get_component_by_purl(self, purl: Optional['PackageURL']) -> Optional[Component]: """ Get a Component already in the Bom by its PURL @@ -872,7 +895,7 @@ def __comparable_tuple(self) -> _ComparableTuple: self.components), _ComparableTuple(self.services), _ComparableTuple(self.external_references), _ComparableTuple( self.dependencies), _ComparableTuple(self.properties), - _ComparableTuple(self.vulnerabilities), + _ComparableTuple(self.vulnerabilities), _ComparableTuple(self.citations), )) def __eq__(self, other: object) -> bool: diff --git a/cyclonedx/model/citation.py b/cyclonedx/model/citation.py new file mode 100644 index 00000000..6a985aa5 --- /dev/null +++ b/cyclonedx/model/citation.py @@ -0,0 +1,239 @@ +# 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 data about Citations. + +.. note:: + Introduced in CycloneDX v1.7 + +.. note:: + See the CycloneDX Schema definition https://cyclonedx.org/docs/1.7/xml/#type_citationType +""" + +from collections.abc import Iterable +from datetime import datetime +from typing import Any, Optional, Union + +import py_serializable as serializable +from sortedcontainers import SortedSet + +from .._internal.bom_ref import bom_ref_from_str as _bom_ref_from_str +from .._internal.compare import ComparableTuple as _ComparableTuple +from ..exception.model import MutuallyExclusivePropertiesException, NoPropertiesProvidedException +from .bom_ref import BomRef + + +@serializable.serializable_class(ignore_unknown_during_deserialization=True) +class Citation: + """ + Details a specific attribution of data within the BOM to a contributing entity or process. + + Exactly one of `pointers` or `expressions` must be provided. + At least one of `attributed_to` or `process` must be provided. + + .. note:: + Introduced in CycloneDX v1.7 + + .. note:: + See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.7/xml/#type_citationType + """ + + def __init__( + self, *, + timestamp: datetime, + bom_ref: Optional[Union[str, BomRef]] = None, + pointers: Optional[Iterable[str]] = None, + expressions: Optional[Iterable[str]] = None, + attributed_to: Optional[Union[str, BomRef]] = None, + process: Optional[Union[str, BomRef]] = None, + note: Optional[str] = None, + # TODO: signature + ) -> None: + pointers = SortedSet(pointers or ()) + expressions = SortedSet(expressions or ()) + if not pointers and not expressions: + raise NoPropertiesProvidedException( + 'One of `pointers` or `expressions` must be supplied - neither supplied' + ) + if pointers and expressions: + raise MutuallyExclusivePropertiesException( + 'Only one of `pointers` or `expressions` must be supplied - both supplied' + ) + if attributed_to is None and process is None: + raise NoPropertiesProvidedException( + 'At least one of `attributed_to` or `process` must be supplied - neither supplied' + ) + self._bom_ref = _bom_ref_from_str(bom_ref) + self.pointers = pointers + self.expressions = expressions + self.timestamp = timestamp + self.attributed_to = attributed_to + self.process = process + self.note = note + # TODO: signature + + @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 citation elsewhere in the BOM. + Every bom-ref MUST be unique within the BOM. + + Returns: + `BomRef` + """ + return self._bom_ref + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'pointer') + @serializable.xml_sequence(1) + def pointers(self) -> 'SortedSet[str]': + """ + One or more JSON Pointers (RFC 6901) identifying the BOM fields to which the attribution applies. + Users of other serialization formats (e.g. XML) shall use the JSON Pointer format as well. + + Returns: + Set of `str` + """ + return self._pointers + + @pointers.setter + def pointers(self, pointers: Iterable[str]) -> None: + self._pointers = SortedSet(pointers) + + @property + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'expression') + @serializable.xml_sequence(2) + def expressions(self) -> 'SortedSet[str]': + """ + One or more path expressions used to locate values within a BOM. + Use JSONPath for JSON and XPath for XML. + + Returns: + Set of `str` + """ + return self._expressions + + @expressions.setter + def expressions(self, expressions: Iterable[str]) -> None: + self._expressions = SortedSet(expressions) + + @property + @serializable.type_mapping(serializable.helpers.XsdDateTime) + @serializable.xml_sequence(3) + def timestamp(self) -> datetime: + """ + The date and time when the attribution was made or the information was supplied. + + Returns: + `datetime` + """ + return self._timestamp + + @timestamp.setter + def timestamp(self, timestamp: datetime) -> None: + self._timestamp = timestamp + + @property + @serializable.type_mapping(BomRef) + @serializable.json_name('attributedTo') + @serializable.xml_name('attributedTo') + @serializable.xml_sequence(4) + def attributed_to(self) -> Optional[BomRef]: + """ + The `bom-ref` of an object, such as a component, service, tool, organisational entity, or person + that supplied the cited information. + + Returns: + `BomRef` if set else `None` + """ + return self._attributed_to + + @attributed_to.setter + def attributed_to(self, attributed_to: Optional[Union[str, BomRef]]) -> None: + self._attributed_to = _bom_ref_from_str(attributed_to, optional=True) + + @property + @serializable.type_mapping(BomRef) + @serializable.xml_sequence(5) + def process(self) -> Optional[BomRef]: + """ + The `bom-ref` to a process (such as a formula, workflow, task, or step) defined in the `formulation` + section that executed or generated the attributed data. + + Returns: + `BomRef` if set else `None` + """ + return self._process + + @process.setter + def process(self, process: Optional[Union[str, BomRef]]) -> None: + self._process = _bom_ref_from_str(process, optional=True) + + @property + @serializable.xml_sequence(6) + def note(self) -> Optional[str]: + """ + A description or comment about the context or quality of the data attribution. + + Returns: + `str` if set else `None` + """ + return self._note + + @note.setter + def note(self, note: Optional[str]) -> None: + self._note = note + + # @property + # # MUST NOT RENDER FOR XML -- this is JSON only + # def signature(self) -> ...: + # ... + # + # @signature.setter + # def signature(self, signature: ...) -> None: + # ... + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple(( + self.timestamp, + _ComparableTuple(self.pointers), _ComparableTuple(self.expressions), + self.attributed_to, self.process, + self.bom_ref.value, self.note, + )) + + def __lt__(self, other: Any) -> bool: + if isinstance(other, Citation): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __eq__(self, other: object) -> bool: + if isinstance(other, Citation): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' diff --git a/docs/schema-support.rst b/docs/schema-support.rst index fe37633d..b34bc3df 100644 --- a/docs/schema-support.rst +++ b/docs/schema-support.rst @@ -58,6 +58,8 @@ This is a snapshot. See the API docs for the latest state. +----------------------------+---------------+---------------------------------------------------------------------------------------------------+ | ``bom.definitions`` | No | | +----------------------------+---------------+---------------------------------------------------------------------------------------------------+ +| ``bom.citations`` | Yes | Since ``next``. Note: ``citation.signature`` is not supported yet. | ++----------------------------+---------------+---------------------------------------------------------------------------------------------------+ | ``bom.signature`` | No | | +----------------------------+---------------+---------------------------------------------------------------------------------------------------+ diff --git a/tests/_data/models.py b/tests/_data/models.py index e2052878..6b04e0f4 100644 --- a/tests/_data/models.py +++ b/tests/_data/models.py @@ -44,6 +44,7 @@ ) from cyclonedx.model.bom import Bom, BomMetaData, DistributionConstraints, TlpClassification from cyclonedx.model.bom_ref import BomRef +from cyclonedx.model.citation import Citation from cyclonedx.model.component import ( Commit, Component, @@ -604,6 +605,35 @@ def get_bom_with_external_component_1_7() -> Bom: return bom +def get_bom_with_citations_1_7() -> Bom: + component = get_component_external() + return _make_bom( + components=[component], + citations=[ + Citation( + bom_ref='citation-pointers', + pointers=['/components/0/name', '/components/0/version'], + timestamp=datetime.fromisoformat('2025-10-01T12:00:00+00:00'), + attributed_to=component.bom_ref, + note='Name and version supplied by the component author.', + ), + Citation( + expressions=['$.components[0].scope'], + timestamp=datetime.fromisoformat('2025-10-02T08:30:00+00:00'), + process='some-formula-step', + ), + Citation( + bom_ref='citation-both', + pointers=['/components/0/type'], + timestamp=datetime.fromisoformat('2025-10-03T09:15:00+00:00'), + attributed_to=component.bom_ref, + process='some-formula-step', + note='Type supplied by the component author via a documented process.', + ), + ], + ) + + def get_bom_with_services_simple() -> Bom: bom = _make_bom(services=[ Service(name='my-first-service', bom_ref='my-specific-bom-ref-for-my-first-service'), @@ -1719,6 +1749,7 @@ def get_bom_for_issue941_nested_dependencies_irreversible_migrate() -> Bom: get_bom_with_multiple_licenses, get_bom_for_issue_497_urls, get_bom_with_external_component_1_7, + get_bom_with_citations_1_7, 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, diff --git a/tests/_data/snapshots/get_bom_with_citations_1_7-1.0.xml.bin b/tests/_data/snapshots/get_bom_with_citations_1_7-1.0.xml.bin new file mode 100644 index 00000000..aaae8337 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_citations_1_7-1.0.xml.bin @@ -0,0 +1,11 @@ + + + + + external-lib + 1.0.0 + required + false + + + diff --git a/tests/_data/snapshots/get_bom_with_citations_1_7-1.1.xml.bin b/tests/_data/snapshots/get_bom_with_citations_1_7-1.1.xml.bin new file mode 100644 index 00000000..06e044d3 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_citations_1_7-1.1.xml.bin @@ -0,0 +1,10 @@ + + + + + external-lib + 1.0.0 + required + + + diff --git a/tests/_data/snapshots/get_bom_with_citations_1_7-1.2.json.bin b/tests/_data/snapshots/get_bom_with_citations_1_7-1.2.json.bin new file mode 100644 index 00000000..fe5a4e0a --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_citations_1_7-1.2.json.bin @@ -0,0 +1,24 @@ +{ + "components": [ + { + "bom-ref": "external-lib-1.0.0", + "name": "external-lib", + "scope": "required", + "type": "library", + "version": "1.0.0" + } + ], + "dependencies": [ + { + "ref": "external-lib-1.0.0" + } + ], + "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_citations_1_7-1.2.xml.bin b/tests/_data/snapshots/get_bom_with_citations_1_7-1.2.xml.bin new file mode 100644 index 00000000..266020af --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_citations_1_7-1.2.xml.bin @@ -0,0 +1,16 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + + + external-lib + 1.0.0 + required + + + + + + diff --git a/tests/_data/snapshots/get_bom_with_citations_1_7-1.3.json.bin b/tests/_data/snapshots/get_bom_with_citations_1_7-1.3.json.bin new file mode 100644 index 00000000..8500a9f7 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_citations_1_7-1.3.json.bin @@ -0,0 +1,24 @@ +{ + "components": [ + { + "bom-ref": "external-lib-1.0.0", + "name": "external-lib", + "scope": "required", + "type": "library", + "version": "1.0.0" + } + ], + "dependencies": [ + { + "ref": "external-lib-1.0.0" + } + ], + "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_citations_1_7-1.3.xml.bin b/tests/_data/snapshots/get_bom_with_citations_1_7-1.3.xml.bin new file mode 100644 index 00000000..120d5d28 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_citations_1_7-1.3.xml.bin @@ -0,0 +1,16 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + + + external-lib + 1.0.0 + required + + + + + + diff --git a/tests/_data/snapshots/get_bom_with_citations_1_7-1.4.json.bin b/tests/_data/snapshots/get_bom_with_citations_1_7-1.4.json.bin new file mode 100644 index 00000000..c2c3bbea --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_citations_1_7-1.4.json.bin @@ -0,0 +1,24 @@ +{ + "components": [ + { + "bom-ref": "external-lib-1.0.0", + "name": "external-lib", + "scope": "required", + "type": "library", + "version": "1.0.0" + } + ], + "dependencies": [ + { + "ref": "external-lib-1.0.0" + } + ], + "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_citations_1_7-1.4.xml.bin b/tests/_data/snapshots/get_bom_with_citations_1_7-1.4.xml.bin new file mode 100644 index 00000000..2d85c7de --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_citations_1_7-1.4.xml.bin @@ -0,0 +1,16 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + + + external-lib + 1.0.0 + required + + + + + + diff --git a/tests/_data/snapshots/get_bom_with_citations_1_7-1.5.json.bin b/tests/_data/snapshots/get_bom_with_citations_1_7-1.5.json.bin new file mode 100644 index 00000000..f3d16896 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_citations_1_7-1.5.json.bin @@ -0,0 +1,34 @@ +{ + "components": [ + { + "bom-ref": "external-lib-1.0.0", + "name": "external-lib", + "scope": "required", + "type": "library", + "version": "1.0.0" + } + ], + "dependencies": [ + { + "ref": "external-lib-1.0.0" + } + ], + "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_citations_1_7-1.5.xml.bin b/tests/_data/snapshots/get_bom_with_citations_1_7-1.5.xml.bin new file mode 100644 index 00000000..f06f8a0b --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_citations_1_7-1.5.xml.bin @@ -0,0 +1,20 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + + + external-lib + 1.0.0 + required + + + + + + + val1 + val2 + + diff --git a/tests/_data/snapshots/get_bom_with_citations_1_7-1.6.json.bin b/tests/_data/snapshots/get_bom_with_citations_1_7-1.6.json.bin new file mode 100644 index 00000000..bf66e848 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_citations_1_7-1.6.json.bin @@ -0,0 +1,34 @@ +{ + "components": [ + { + "bom-ref": "external-lib-1.0.0", + "name": "external-lib", + "scope": "required", + "type": "library", + "version": "1.0.0" + } + ], + "dependencies": [ + { + "ref": "external-lib-1.0.0" + } + ], + "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_citations_1_7-1.6.xml.bin b/tests/_data/snapshots/get_bom_with_citations_1_7-1.6.xml.bin new file mode 100644 index 00000000..c81104a7 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_citations_1_7-1.6.xml.bin @@ -0,0 +1,20 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + + + external-lib + 1.0.0 + required + + + + + + + val1 + val2 + + diff --git a/tests/_data/snapshots/get_bom_with_citations_1_7-1.7.json.bin b/tests/_data/snapshots/get_bom_with_citations_1_7-1.7.json.bin new file mode 100644 index 00000000..3f8cbb41 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_citations_1_7-1.7.json.bin @@ -0,0 +1,64 @@ +{ + "citations": [ + { + "attributedTo": "external-lib-1.0.0", + "bom-ref": "citation-pointers", + "note": "Name and version supplied by the component author.", + "pointers": [ + "/components/0/name", + "/components/0/version" + ], + "timestamp": "2025-10-01T12:00:00+00:00" + }, + { + "expressions": [ + "$.components[0].scope" + ], + "process": "some-formula-step", + "timestamp": "2025-10-02T08:30:00+00:00" + }, + { + "attributedTo": "external-lib-1.0.0", + "bom-ref": "citation-both", + "note": "Type supplied by the component author via a documented process.", + "pointers": [ + "/components/0/type" + ], + "process": "some-formula-step", + "timestamp": "2025-10-03T09:15:00+00:00" + } + ], + "components": [ + { + "bom-ref": "external-lib-1.0.0", + "isExternal": true, + "name": "external-lib", + "scope": "required", + "type": "library", + "version": "1.0.0" + } + ], + "dependencies": [ + { + "ref": "external-lib-1.0.0" + } + ], + "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.7.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.7" +} \ No newline at end of file diff --git a/tests/_data/snapshots/get_bom_with_citations_1_7-1.7.xml.bin b/tests/_data/snapshots/get_bom_with_citations_1_7-1.7.xml.bin new file mode 100644 index 00000000..c4e8afb9 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_citations_1_7-1.7.xml.bin @@ -0,0 +1,47 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + + + external-lib + 1.0.0 + required + + + + + + + val1 + val2 + + + + + /components/0/name + /components/0/version + + 2025-10-01T12:00:00+00:00 + external-lib-1.0.0 + Name and version supplied by the component author. + + + + $.components[0].scope + + 2025-10-02T08:30:00+00:00 + some-formula-step + + + + /components/0/type + + 2025-10-03T09:15:00+00:00 + external-lib-1.0.0 + some-formula-step + Type supplied by the component author via a documented process. + + + diff --git a/tests/test_model_citation.py b/tests/test_model_citation.py new file mode 100644 index 00000000..976cf613 --- /dev/null +++ b/tests/test_model_citation.py @@ -0,0 +1,80 @@ +# 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 datetime import datetime, timezone +from unittest import TestCase + +from cyclonedx.exception.model import MutuallyExclusivePropertiesException, NoPropertiesProvidedException +from cyclonedx.model.bom_ref import BomRef +from cyclonedx.model.citation import Citation + +_TS = datetime(2025, 10, 1, 12, 0, 0, tzinfo=timezone.utc) + + +class TestModelCitation(TestCase): + + def test_create_with_pointers(self) -> None: + c = Citation(timestamp=_TS, pointers=['/b', '/a'], attributed_to='author', bom_ref='c1', note='n') + self.assertListEqual(['/a', '/b'], list(c.pointers)) + self.assertEqual(0, len(c.expressions)) + self.assertEqual(_TS, c.timestamp) + self.assertIsInstance(c.attributed_to, BomRef) + self.assertEqual('author', c.attributed_to.value) + self.assertIsNone(c.process) + self.assertEqual('c1', c.bom_ref.value) + self.assertEqual('n', c.note) + + def test_create_with_expressions_and_process(self) -> None: + c = Citation(timestamp=_TS, expressions=['$.a'], process=BomRef('p1')) + self.assertListEqual(['$.a'], list(c.expressions)) + self.assertIsNone(c.attributed_to) + self.assertEqual('p1', c.process.value) + self.assertIsNone(c.bom_ref.value) + + def test_requires_pointers_or_expressions(self) -> None: + with self.assertRaises(NoPropertiesProvidedException): + Citation(timestamp=_TS, attributed_to='a') + + def test_pointers_and_expressions_are_exclusive(self) -> None: + with self.assertRaises(MutuallyExclusivePropertiesException): + Citation(timestamp=_TS, pointers=['/a'], expressions=['$.a'], attributed_to='a') + + def test_requires_attributed_to_or_process(self) -> None: + with self.assertRaises(NoPropertiesProvidedException): + Citation(timestamp=_TS, pointers=['/a']) + + def test_equal(self) -> None: + c1 = Citation(timestamp=_TS, pointers=['/a'], attributed_to='a', note='x') + c2 = Citation(timestamp=_TS, pointers=['/a'], attributed_to='a', note='x') + self.assertEqual(c1, c2) + self.assertEqual(hash(c1), hash(c2)) + + def test_not_equal(self) -> None: + c1 = Citation(timestamp=_TS, pointers=['/a'], attributed_to='a') + c2 = Citation(timestamp=_TS, pointers=['/a'], attributed_to='b') + self.assertNotEqual(c1, c2) + self.assertNotEqual(c1, 'foo') + + def test_sort(self) -> None: + later = datetime(2025, 10, 2, tzinfo=timezone.utc) + expected = [ + Citation(timestamp=_TS, pointers=['/a'], attributed_to='a'), + Citation(timestamp=_TS, pointers=['/b'], attributed_to='a'), + Citation(timestamp=later, expressions=['$.a'], process='p'), + ] + self.assertListEqual(expected, sorted(reversed(expected)))