diff --git a/cyclonedx/model/service.py b/cyclonedx/model/service.py index 10597370..65ea0f47 100644 --- a/cyclonedx/model/service.py +++ b/cyclonedx/model/service.py @@ -25,13 +25,16 @@ from collections.abc import Iterable +from json import loads as json_loads from typing import Any, Optional, Union +from xml.etree.ElementTree import Element as XmlElement, SubElement # nosec B405 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 ..schema import SchemaVersion from ..schema.schema import ( SchemaVersion1Dot3, SchemaVersion1Dot4, @@ -39,14 +42,461 @@ SchemaVersion1Dot6, SchemaVersion1Dot7, ) -from . import DataClassification, ExternalReference, Property, XsUri +from . import DataClassification, DataFlow, ExternalReference, Property, XsUri from .bom_ref import BomRef -from .contact import OrganizationalEntity +from .contact import OrganizationalContact, OrganizationalEntity from .dependency import Dependable from .license import License, LicenseRepository, _LicenseRepositorySerializationHelper from .release_note import ReleaseNotes +@serializable.serializable_class +class OrganizationOrIndividualType: + """ + This is our internal representation of the organizationOrIndividualType complex type within the CycloneDX standard. + + .. note:: + See the CycloneDX Schema: https://cyclonedx.org/docs/1.6/xml/#type_organizationOrIndividualType + """ + + def __init__( + self, *, + organization: Optional[OrganizationalEntity] = None, + individual: Optional[OrganizationalContact] = None, + ) -> None: + self.organization = organization + self.individual = individual + + @property + @serializable.xml_sequence(1) + @serializable.xml_name('organization') + def organization(self) -> Optional[OrganizationalEntity]: + return self._organization + + @organization.setter + def organization(self, organization: Optional[OrganizationalEntity]) -> None: + self._organization = organization + + @property + @serializable.json_name('contact') + @serializable.xml_sequence(2) + @serializable.xml_name('individual') + def individual(self) -> Optional[OrganizationalContact]: + return self._individual + + @individual.setter + def individual(self, individual: Optional[OrganizationalContact]) -> None: + self._individual = individual + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple(( + self.organization, self.individual + )) + + def __eq__(self, other: object) -> bool: + if isinstance(other, OrganizationOrIndividualType): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, OrganizationOrIndividualType): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + +@serializable.serializable_class +class DataGovernance: + """ + This is our internal representation of the dataGovernance complex type within the CycloneDX standard. + + .. note:: + See the CycloneDX Schema: https://cyclonedx.org/docs/1.6/xml/#type_dataGovernance + """ + + def __init__( + self, *, + custodians: Optional[Iterable[OrganizationOrIndividualType]] = None, + stewards: Optional[Iterable[OrganizationOrIndividualType]] = None, + owners: Optional[Iterable[OrganizationOrIndividualType]] = None, + ) -> None: + self.custodians = custodians or [] + self.stewards = stewards or [] + self.owners = owners or [] + + @property + @serializable.xml_sequence(1) + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'custodian') + def custodians(self) -> 'SortedSet[OrganizationOrIndividualType]': + return self._custodians + + @custodians.setter + def custodians(self, custodians: Iterable[OrganizationOrIndividualType]) -> None: + self._custodians = SortedSet(custodians) + + @property + @serializable.xml_sequence(2) + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'steward') + def stewards(self) -> 'SortedSet[OrganizationOrIndividualType]': + return self._stewards + + @stewards.setter + def stewards(self, stewards: Iterable[OrganizationOrIndividualType]) -> None: + self._stewards = SortedSet(stewards) + + @property + @serializable.xml_sequence(3) + @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'owner') + def owners(self) -> 'SortedSet[OrganizationOrIndividualType]': + return self._owners + + @owners.setter + def owners(self, owners: Iterable[OrganizationOrIndividualType]) -> None: + self._owners = SortedSet(owners) + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple(( + _ComparableTuple(self.custodians), _ComparableTuple(self.stewards), _ComparableTuple(self.owners) + )) + + def __eq__(self, other: object) -> bool: + if isinstance(other, DataGovernance): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, DataGovernance): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + +class Data: + """ + This is our internal representation of the ``serviceData`` complex type within the CycloneDX standard. + + .. note:: + See the CycloneDX Schema: https://cyclonedx.org/docs/1.6/xml/#type_service + """ + + def __init__( + self, *, + flow: DataFlow, + classification: str, + name: Optional[str] = None, + description: Optional[str] = None, + governance: Optional[DataGovernance] = None, + source: Optional[Iterable[XsUri]] = None, + destination: Optional[Iterable[XsUri]] = None + ) -> None: + self.flow = flow + self.classification = classification + self.name = name + self.description = description + self.governance = governance + self.source = source or [] + self.destination = destination or [] + + @property + def flow(self) -> DataFlow: + """ + Specifies the flow direction of the data. Direction is relative to the service. + + Returns: + `DataFlow` + """ + return self._flow + + @flow.setter + def flow(self, flow: DataFlow) -> None: + self._flow = flow + + @property + def classification(self) -> str: + """ + Data classification tags data according to its type, sensitivity, and value if altered, stolen, or destroyed. + + Returns: + `str` + """ + return self._classification + + @classification.setter + def classification(self, classification: str) -> None: + self._classification = classification + + @property + def name(self) -> Optional[str]: + """ + Name for the defined data. + + Returns: + `str` if set else `None` + """ + return self._name + + @name.setter + def name(self, name: Optional[str]) -> None: + self._name = name + + @property + def description(self) -> Optional[str]: + """ + Short description of the data content and usage. + + Returns: + `str` if set else `None` + """ + return self._description + + @description.setter + def description(self, description: Optional[str]) -> None: + self._description = description + + @property + def governance(self) -> Optional[DataGovernance]: + """ + Data governance information. + + Returns: + `DataGovernance` if set else `None` + """ + return self._governance + + @governance.setter + def governance(self, governance: Optional[DataGovernance]) -> None: + self._governance = governance + + @property + def source(self) -> 'SortedSet[XsUri]': + """ + The URI, URL, or BOM-Link of the components or services the data came in from. + + Returns: + Set of `XsUri` + """ + return self._source + + @source.setter + def source(self, source: Iterable[XsUri]) -> None: + self._source = SortedSet(source) + + @property + def destination(self) -> 'SortedSet[XsUri]': + """ + The URI, URL, or BOM-Link of the components or services the data is sent to. + + Returns: + Set of `XsUri` + """ + return self._destination + + @destination.setter + def destination(self, destination: Iterable[XsUri]) -> None: + self._destination = SortedSet(destination) + + def __comparable_tuple(self) -> _ComparableTuple: + return _ComparableTuple(( + self.flow, self.classification, self.name, self.description, self.governance, + _ComparableTuple(self.source), _ComparableTuple(self.destination) + )) + + def __eq__(self, other: object) -> bool: + if isinstance(other, Data): + return self.__comparable_tuple() == other.__comparable_tuple() + return False + + def __lt__(self, other: Any) -> bool: + if isinstance(other, Data): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + + def __hash__(self) -> int: + return hash(self.__comparable_tuple()) + + def __repr__(self) -> str: + return f'' + + +class _DataRepositorySerializationHelper(serializable.helpers.BaseHelper): + """ THIS CLASS IS NON-PUBLIC API """ + + @staticmethod + def __supports_service_data(view: Any) -> bool: + try: + return view is not None and view().schema_version_enum >= SchemaVersion.V1_5 + except Exception: # pragma: no cover + return False + + @classmethod + def json_normalize(cls, o: 'SortedSet[Data]', *, + view: Optional[type[serializable.ViewType]], + **__: Any) -> Optional[list[Any]]: + if not o: + return None + # CDX 1.5+ supports the full serviceData type; 1.2–1.4 only supports + # the deprecated dataClassification (flow + classification string only). + use_service_data = cls.__supports_service_data(view) + result = [] + for d in o: + if use_service_data: + item: dict[str, Any] = { + 'flow': d.flow.value, + 'classification': d.classification, + } + if d.name is not None: + item['name'] = d.name + if d.description is not None: + item['description'] = d.description + if d.governance is not None: + item['governance'] = json_loads( + d.governance.as_json(view_=view) # type:ignore[attr-defined] + ) + if d.source: + item['source'] = [str(u) for u in d.source] + if d.destination: + item['destination'] = [str(u) for u in d.destination] + else: + # CDX 1.2–1.4: only flow + classification + item = { + 'flow': d.flow.value, + 'classification': d.classification, + } + result.append(item) + return result + + @classmethod + def json_denormalize(cls, o: list[dict[str, Any]], **__: Any) -> 'SortedSet[Data]': + result: SortedSet[Data] = SortedSet() + for item in o: + governance = None + if 'governance' in item: + governance = DataGovernance.from_json( # type:ignore[attr-defined] + item['governance']) + result.add(Data( + flow=DataFlow(item['flow']), + classification=item['classification'], + name=item.get('name'), + description=item.get('description'), + governance=governance, + source=[XsUri(u) for u in item.get('source', [])], + destination=[XsUri(u) for u in item.get('destination', [])], + )) + return result + + @classmethod + def _xml_single_dataflow( + cls, d: 'Data', *, + pfx: str, + view: Optional[type[serializable.ViewType]], + xmlns: Optional[str], + ) -> XmlElement: + """Build a CDX 1.5+ ```` element for a single Data item.""" + dataflow_elem = XmlElement(f'{pfx}dataflow') + if d.name is not None: + dataflow_elem.set(f'{pfx}name', d.name) + if d.description is not None: + dataflow_elem.set(f'{pfx}description', d.description) + dataflow_elem.append( + DataClassification( + flow=d.flow, classification=d.classification + ).as_xml( # type:ignore[attr-defined] + view_=view, as_string=False, element_name='classification', xmlns=xmlns + ) + ) + if d.governance is not None: + gov_elem = d.governance.as_xml( # type:ignore[attr-defined] + view_=view, as_string=False, element_name='governance', xmlns=xmlns) + dataflow_elem.append(gov_elem) + if d.source: + src_elem = SubElement(dataflow_elem, f'{pfx}source') + for u in d.source: + SubElement(src_elem, f'{pfx}url').text = str(u) + if d.destination: + dst_elem = SubElement(dataflow_elem, f'{pfx}destination') + for u in d.destination: + SubElement(dst_elem, f'{pfx}url').text = str(u) + return dataflow_elem + + @classmethod + def xml_normalize(cls, o: 'SortedSet[Data]', *, + element_name: str, + view: Optional[type[serializable.ViewType]], + xmlns: Optional[str], + **__: Any) -> Optional[XmlElement]: + if not o: + return None + # element_name is already namespace-qualified by py_serializable when xmlns is set. + # Build a prefix for child elements we create manually. + pfx = f'{{{xmlns}}}' if xmlns else '' + wrapper = XmlElement(element_name) + # CDX 1.5+ uses elements; 1.2–1.4 uses the deprecated flat + use_dataflow = cls.__supports_service_data(view) + for d in o: + if use_dataflow: + wrapper.append(cls._xml_single_dataflow(d, pfx=pfx, view=view, xmlns=xmlns)) + else: + # CDX 1.2–1.4 (deprecated): text + wrapper.append( + DataClassification( + flow=d.flow, classification=d.classification + ).as_xml( # type:ignore[attr-defined] + view_=view, as_string=False, element_name='classification', xmlns=xmlns + ) + ) + return wrapper + + @classmethod + def xml_denormalize(cls, o: XmlElement, *, + default_ns: Optional[str], + **__: Any) -> 'SortedSet[Data]': + result: SortedSet[Data] = SortedSet() + ns = f'{{{default_ns}}}' if default_ns else '' + for elem in o: + tag = elem.tag.replace(f'{{{default_ns}}}', '') if default_ns else elem.tag + if tag == 'dataflow': + # CDX 1.5+ element + cls_elem = elem.find(f'{ns}classification') + # flow attribute may be namespace-qualified or plain + flow_val = (cls_elem.get(f'{ns}flow') or cls_elem.get('flow')) if cls_elem is not None else None + classification_text = cls_elem.text or '' if cls_elem is not None else '' + flow = DataFlow(flow_val) if flow_val else DataFlow.UNKNOWN + gov_elem = elem.find(f'{ns}governance') + governance = None + if gov_elem is not None: + governance = DataGovernance.from_xml( # type:ignore[attr-defined] + gov_elem, default_ns) + src_elem = elem.find(f'{ns}source') + source = [XsUri(u.text or '') for u in src_elem.findall(f'{ns}url')] if src_elem is not None else [] + dst_elem = elem.find(f'{ns}destination') + destination = [XsUri(u.text or '') + for u in dst_elem.findall(f'{ns}url')] if dst_elem is not None else [] + # name and description may be namespace-qualified or plain attributes + name = elem.get(f'{ns}name') or elem.get('name') + description = elem.get(f'{ns}description') or elem.get('description') + result.add(Data( + flow=flow, + classification=classification_text, + name=name, + description=description, + governance=governance, + source=source, + destination=destination, + )) + elif tag == 'classification': + # CDX 1.2–1.4 deprecated text + flow_val = elem.get(f'{ns}flow') or elem.get('flow') + result.add(Data( + flow=DataFlow(flow_val) if flow_val else DataFlow.UNKNOWN, + classification=elem.text or '', + )) + return result + + @serializable.serializable_class(ignore_unknown_during_deserialization=True) class Service(Dependable): """ @@ -67,7 +517,7 @@ def __init__( endpoints: Optional[Iterable[XsUri]] = None, authenticated: Optional[bool] = None, x_trust_boundary: Optional[bool] = None, - data: Optional[Iterable[DataClassification]] = None, + data: Optional[Iterable['Data']] = None, licenses: Optional[Iterable[License]] = None, external_references: Optional[Iterable[ExternalReference]] = None, properties: Optional[Iterable[Property]] = None, @@ -251,20 +701,19 @@ def x_trust_boundary(self, x_trust_boundary: Optional[bool]) -> None: # ... # since CDX1.5 @property - @serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'classification') + @serializable.type_mapping(_DataRepositorySerializationHelper) @serializable.xml_sequence(10) - def data(self) -> 'SortedSet[DataClassification]': + def data(self) -> 'SortedSet[Data]': """ - Specifies the data classification. + Specifies the data flow and classification. Returns: - Set of `DataClassification` + Set of `Data` """ - # TODO since CDX1.5 also supports `dataflow`, not only `DataClassification` return self._data @data.setter - def data(self, data: Iterable[DataClassification]) -> None: + def data(self, data: Iterable['Data']) -> None: self._data = SortedSet(data) @property @@ -277,7 +726,6 @@ def licenses(self) -> LicenseRepository: Returns: Set of `LicenseChoice` """ - # TODO since CDX1.5 also supports `dataflow`, not only `DataClassification` return self._licenses @licenses.setter diff --git a/tests/_data/models.py b/tests/_data/models.py index e2052878..b38c39a7 100644 --- a/tests/_data/models.py +++ b/tests/_data/models.py @@ -31,7 +31,6 @@ from cyclonedx.model import ( AttachedText, Copyright, - DataClassification, DataFlow, Encoding, ExternalReference, @@ -106,7 +105,7 @@ ) from cyclonedx.model.lifecycle import LifecyclePhase, NamedLifecycle, PredefinedLifecycle from cyclonedx.model.release_note import ReleaseNotes -from cyclonedx.model.service import Service +from cyclonedx.model.service import Data, DataGovernance, OrganizationOrIndividualType, Service from cyclonedx.model.tool import Tool, ToolRepository from cyclonedx.model.vulnerability import ( BomTarget, @@ -626,7 +625,19 @@ def get_bom_with_services_complex() -> Bom: XsUri('/api/thing/2') ], authenticated=False, x_trust_boundary=True, data=[ - DataClassification(flow=DataFlow.OUTBOUND, classification='public') + Data( + flow=DataFlow.OUTBOUND, + classification='public', + name='Credit card reporting', + description='Credit card information exchanged between the web app and the database', + governance=DataGovernance( + custodians=[OrganizationOrIndividualType(organization=get_org_entity_1())], + stewards=[OrganizationOrIndividualType(individual=get_org_contact_1())], + owners=[OrganizationOrIndividualType(organization=get_org_entity_1())], + ), + source=[XsUri('https://0.0.0.0/source')], + destination=[XsUri('https://0.0.0.0/destination')], + ) ], licenses=[DisjunctiveLicense(name='Commercial')], external_references=[ @@ -654,7 +665,7 @@ def get_bom_with_nested_services() -> Bom: XsUri('/api/thing/2') ], authenticated=False, x_trust_boundary=True, data=[ - DataClassification(flow=DataFlow.OUTBOUND, classification='public') + Data(flow=DataFlow.OUTBOUND, classification='public') ], licenses=[DisjunctiveLicense(name='Commercial')], external_references=[ diff --git a/tests/_data/snapshots/enum_DataFlow-1.2.json.bin b/tests/_data/snapshots/enum_DataFlow-1.2.json.bin index cfd8d34e..66866cf9 100644 --- a/tests/_data/snapshots/enum_DataFlow-1.2.json.bin +++ b/tests/_data/snapshots/enum_DataFlow-1.2.json.bin @@ -13,20 +13,20 @@ "bom-ref": "dummy", "data": [ { - "classification": "BI_DIRECTIONAL", - "flow": "bi-directional" + "flow": "bi-directional", + "classification": "BI_DIRECTIONAL" }, { - "classification": "INBOUND", - "flow": "inbound" + "flow": "inbound", + "classification": "INBOUND" }, { - "classification": "OUTBOUND", - "flow": "outbound" + "flow": "outbound", + "classification": "OUTBOUND" }, { - "classification": "UNKNOWN", - "flow": "unknown" + "flow": "unknown", + "classification": "UNKNOWN" } ], "name": "dummy" diff --git a/tests/_data/snapshots/enum_DataFlow-1.3.json.bin b/tests/_data/snapshots/enum_DataFlow-1.3.json.bin index ec868cdf..dd862b12 100644 --- a/tests/_data/snapshots/enum_DataFlow-1.3.json.bin +++ b/tests/_data/snapshots/enum_DataFlow-1.3.json.bin @@ -13,20 +13,20 @@ "bom-ref": "dummy", "data": [ { - "classification": "BI_DIRECTIONAL", - "flow": "bi-directional" + "flow": "bi-directional", + "classification": "BI_DIRECTIONAL" }, { - "classification": "INBOUND", - "flow": "inbound" + "flow": "inbound", + "classification": "INBOUND" }, { - "classification": "OUTBOUND", - "flow": "outbound" + "flow": "outbound", + "classification": "OUTBOUND" }, { - "classification": "UNKNOWN", - "flow": "unknown" + "flow": "unknown", + "classification": "UNKNOWN" } ], "name": "dummy" diff --git a/tests/_data/snapshots/enum_DataFlow-1.4.json.bin b/tests/_data/snapshots/enum_DataFlow-1.4.json.bin index e90aec28..626688de 100644 --- a/tests/_data/snapshots/enum_DataFlow-1.4.json.bin +++ b/tests/_data/snapshots/enum_DataFlow-1.4.json.bin @@ -13,20 +13,20 @@ "bom-ref": "dummy", "data": [ { - "classification": "BI_DIRECTIONAL", - "flow": "bi-directional" + "flow": "bi-directional", + "classification": "BI_DIRECTIONAL" }, { - "classification": "INBOUND", - "flow": "inbound" + "flow": "inbound", + "classification": "INBOUND" }, { - "classification": "OUTBOUND", - "flow": "outbound" + "flow": "outbound", + "classification": "OUTBOUND" }, { - "classification": "UNKNOWN", - "flow": "unknown" + "flow": "unknown", + "classification": "UNKNOWN" } ], "name": "dummy" diff --git a/tests/_data/snapshots/enum_DataFlow-1.5.json.bin b/tests/_data/snapshots/enum_DataFlow-1.5.json.bin index 7ee12db9..d7873534 100644 --- a/tests/_data/snapshots/enum_DataFlow-1.5.json.bin +++ b/tests/_data/snapshots/enum_DataFlow-1.5.json.bin @@ -23,20 +23,20 @@ "bom-ref": "dummy", "data": [ { - "classification": "BI_DIRECTIONAL", - "flow": "bi-directional" + "flow": "bi-directional", + "classification": "BI_DIRECTIONAL" }, { - "classification": "INBOUND", - "flow": "inbound" + "flow": "inbound", + "classification": "INBOUND" }, { - "classification": "OUTBOUND", - "flow": "outbound" + "flow": "outbound", + "classification": "OUTBOUND" }, { - "classification": "UNKNOWN", - "flow": "unknown" + "flow": "unknown", + "classification": "UNKNOWN" } ], "name": "dummy" diff --git a/tests/_data/snapshots/enum_DataFlow-1.5.xml.bin b/tests/_data/snapshots/enum_DataFlow-1.5.xml.bin index d7fb1d16..d7883d42 100644 --- a/tests/_data/snapshots/enum_DataFlow-1.5.xml.bin +++ b/tests/_data/snapshots/enum_DataFlow-1.5.xml.bin @@ -7,10 +7,18 @@ dummy - BI_DIRECTIONAL - INBOUND - OUTBOUND - UNKNOWN + + BI_DIRECTIONAL + + + INBOUND + + + OUTBOUND + + + UNKNOWN + diff --git a/tests/_data/snapshots/enum_DataFlow-1.6.json.bin b/tests/_data/snapshots/enum_DataFlow-1.6.json.bin index 063107b3..27f8f0e3 100644 --- a/tests/_data/snapshots/enum_DataFlow-1.6.json.bin +++ b/tests/_data/snapshots/enum_DataFlow-1.6.json.bin @@ -23,20 +23,20 @@ "bom-ref": "dummy", "data": [ { - "classification": "BI_DIRECTIONAL", - "flow": "bi-directional" + "flow": "bi-directional", + "classification": "BI_DIRECTIONAL" }, { - "classification": "INBOUND", - "flow": "inbound" + "flow": "inbound", + "classification": "INBOUND" }, { - "classification": "OUTBOUND", - "flow": "outbound" + "flow": "outbound", + "classification": "OUTBOUND" }, { - "classification": "UNKNOWN", - "flow": "unknown" + "flow": "unknown", + "classification": "UNKNOWN" } ], "name": "dummy" diff --git a/tests/_data/snapshots/enum_DataFlow-1.6.xml.bin b/tests/_data/snapshots/enum_DataFlow-1.6.xml.bin index f7fad953..44e601e5 100644 --- a/tests/_data/snapshots/enum_DataFlow-1.6.xml.bin +++ b/tests/_data/snapshots/enum_DataFlow-1.6.xml.bin @@ -7,10 +7,18 @@ dummy - BI_DIRECTIONAL - INBOUND - OUTBOUND - UNKNOWN + + BI_DIRECTIONAL + + + INBOUND + + + OUTBOUND + + + UNKNOWN + diff --git a/tests/_data/snapshots/enum_DataFlow-1.7.json.bin b/tests/_data/snapshots/enum_DataFlow-1.7.json.bin index fd5eb866..58e71a8e 100644 --- a/tests/_data/snapshots/enum_DataFlow-1.7.json.bin +++ b/tests/_data/snapshots/enum_DataFlow-1.7.json.bin @@ -23,20 +23,20 @@ "bom-ref": "dummy", "data": [ { - "classification": "BI_DIRECTIONAL", - "flow": "bi-directional" + "flow": "bi-directional", + "classification": "BI_DIRECTIONAL" }, { - "classification": "INBOUND", - "flow": "inbound" + "flow": "inbound", + "classification": "INBOUND" }, { - "classification": "OUTBOUND", - "flow": "outbound" + "flow": "outbound", + "classification": "OUTBOUND" }, { - "classification": "UNKNOWN", - "flow": "unknown" + "flow": "unknown", + "classification": "UNKNOWN" } ], "name": "dummy" diff --git a/tests/_data/snapshots/enum_DataFlow-1.7.xml.bin b/tests/_data/snapshots/enum_DataFlow-1.7.xml.bin index 04c4f013..757e41c3 100644 --- a/tests/_data/snapshots/enum_DataFlow-1.7.xml.bin +++ b/tests/_data/snapshots/enum_DataFlow-1.7.xml.bin @@ -7,10 +7,18 @@ dummy - BI_DIRECTIONAL - INBOUND - OUTBOUND - UNKNOWN + + BI_DIRECTIONAL + + + INBOUND + + + OUTBOUND + + + UNKNOWN + diff --git a/tests/_data/snapshots/get_bom_with_nested_services-1.2.json.bin b/tests/_data/snapshots/get_bom_with_nested_services-1.2.json.bin index 8a17945d..138f9887 100644 --- a/tests/_data/snapshots/get_bom_with_nested_services-1.2.json.bin +++ b/tests/_data/snapshots/get_bom_with_nested_services-1.2.json.bin @@ -26,8 +26,8 @@ "bom-ref": "my-specific-bom-ref-for-my-first-service", "data": [ { - "classification": "public", - "flow": "outbound" + "flow": "outbound", + "classification": "public" } ], "description": "Description goes here", diff --git a/tests/_data/snapshots/get_bom_with_nested_services-1.3.json.bin b/tests/_data/snapshots/get_bom_with_nested_services-1.3.json.bin index 5e480c8c..f839757d 100644 --- a/tests/_data/snapshots/get_bom_with_nested_services-1.3.json.bin +++ b/tests/_data/snapshots/get_bom_with_nested_services-1.3.json.bin @@ -26,8 +26,8 @@ "bom-ref": "my-specific-bom-ref-for-my-first-service", "data": [ { - "classification": "public", - "flow": "outbound" + "flow": "outbound", + "classification": "public" } ], "description": "Description goes here", diff --git a/tests/_data/snapshots/get_bom_with_nested_services-1.4.json.bin b/tests/_data/snapshots/get_bom_with_nested_services-1.4.json.bin index 13797a13..9282a73f 100644 --- a/tests/_data/snapshots/get_bom_with_nested_services-1.4.json.bin +++ b/tests/_data/snapshots/get_bom_with_nested_services-1.4.json.bin @@ -26,8 +26,8 @@ "bom-ref": "my-specific-bom-ref-for-my-first-service", "data": [ { - "classification": "public", - "flow": "outbound" + "flow": "outbound", + "classification": "public" } ], "description": "Description goes here", diff --git a/tests/_data/snapshots/get_bom_with_nested_services-1.5.json.bin b/tests/_data/snapshots/get_bom_with_nested_services-1.5.json.bin index 11b52897..4a2c2e03 100644 --- a/tests/_data/snapshots/get_bom_with_nested_services-1.5.json.bin +++ b/tests/_data/snapshots/get_bom_with_nested_services-1.5.json.bin @@ -36,8 +36,8 @@ "bom-ref": "my-specific-bom-ref-for-my-first-service", "data": [ { - "classification": "public", - "flow": "outbound" + "flow": "outbound", + "classification": "public" } ], "description": "Description goes here", diff --git a/tests/_data/snapshots/get_bom_with_nested_services-1.5.xml.bin b/tests/_data/snapshots/get_bom_with_nested_services-1.5.xml.bin index 570fba7f..305c94bb 100644 --- a/tests/_data/snapshots/get_bom_with_nested_services-1.5.xml.bin +++ b/tests/_data/snapshots/get_bom_with_nested_services-1.5.xml.bin @@ -34,7 +34,9 @@ false true - public + + public + diff --git a/tests/_data/snapshots/get_bom_with_nested_services-1.6.json.bin b/tests/_data/snapshots/get_bom_with_nested_services-1.6.json.bin index e1469324..0e0e3d88 100644 --- a/tests/_data/snapshots/get_bom_with_nested_services-1.6.json.bin +++ b/tests/_data/snapshots/get_bom_with_nested_services-1.6.json.bin @@ -36,8 +36,8 @@ "bom-ref": "my-specific-bom-ref-for-my-first-service", "data": [ { - "classification": "public", - "flow": "outbound" + "flow": "outbound", + "classification": "public" } ], "description": "Description goes here", diff --git a/tests/_data/snapshots/get_bom_with_nested_services-1.6.xml.bin b/tests/_data/snapshots/get_bom_with_nested_services-1.6.xml.bin index 24ce8e39..53dcd3e4 100644 --- a/tests/_data/snapshots/get_bom_with_nested_services-1.6.xml.bin +++ b/tests/_data/snapshots/get_bom_with_nested_services-1.6.xml.bin @@ -40,7 +40,9 @@ false true - public + + public + diff --git a/tests/_data/snapshots/get_bom_with_nested_services-1.7.json.bin b/tests/_data/snapshots/get_bom_with_nested_services-1.7.json.bin index edb0cc6c..2bdf49dd 100644 --- a/tests/_data/snapshots/get_bom_with_nested_services-1.7.json.bin +++ b/tests/_data/snapshots/get_bom_with_nested_services-1.7.json.bin @@ -36,8 +36,8 @@ "bom-ref": "my-specific-bom-ref-for-my-first-service", "data": [ { - "classification": "public", - "flow": "outbound" + "flow": "outbound", + "classification": "public" } ], "description": "Description goes here", diff --git a/tests/_data/snapshots/get_bom_with_nested_services-1.7.xml.bin b/tests/_data/snapshots/get_bom_with_nested_services-1.7.xml.bin index f166a3a9..531922b3 100644 --- a/tests/_data/snapshots/get_bom_with_nested_services-1.7.xml.bin +++ b/tests/_data/snapshots/get_bom_with_nested_services-1.7.xml.bin @@ -40,7 +40,9 @@ false true - public + + public + diff --git a/tests/_data/snapshots/get_bom_with_services_complex-1.2.json.bin b/tests/_data/snapshots/get_bom_with_services_complex-1.2.json.bin index 50a81b63..a9e8945c 100644 --- a/tests/_data/snapshots/get_bom_with_services_complex-1.2.json.bin +++ b/tests/_data/snapshots/get_bom_with_services_complex-1.2.json.bin @@ -26,8 +26,8 @@ "bom-ref": "my-specific-bom-ref-for-my-first-service", "data": [ { - "classification": "public", - "flow": "outbound" + "flow": "outbound", + "classification": "public" } ], "description": "Description goes here", diff --git a/tests/_data/snapshots/get_bom_with_services_complex-1.3.json.bin b/tests/_data/snapshots/get_bom_with_services_complex-1.3.json.bin index c677d7b6..bef37aa7 100644 --- a/tests/_data/snapshots/get_bom_with_services_complex-1.3.json.bin +++ b/tests/_data/snapshots/get_bom_with_services_complex-1.3.json.bin @@ -26,8 +26,8 @@ "bom-ref": "my-specific-bom-ref-for-my-first-service", "data": [ { - "classification": "public", - "flow": "outbound" + "flow": "outbound", + "classification": "public" } ], "description": "Description goes here", diff --git a/tests/_data/snapshots/get_bom_with_services_complex-1.4.json.bin b/tests/_data/snapshots/get_bom_with_services_complex-1.4.json.bin index 02bd8ecf..009d6045 100644 --- a/tests/_data/snapshots/get_bom_with_services_complex-1.4.json.bin +++ b/tests/_data/snapshots/get_bom_with_services_complex-1.4.json.bin @@ -26,8 +26,8 @@ "bom-ref": "my-specific-bom-ref-for-my-first-service", "data": [ { - "classification": "public", - "flow": "outbound" + "flow": "outbound", + "classification": "public" } ], "description": "Description goes here", diff --git a/tests/_data/snapshots/get_bom_with_services_complex-1.5.json.bin b/tests/_data/snapshots/get_bom_with_services_complex-1.5.json.bin index 7672db57..928927f5 100644 --- a/tests/_data/snapshots/get_bom_with_services_complex-1.5.json.bin +++ b/tests/_data/snapshots/get_bom_with_services_complex-1.5.json.bin @@ -36,8 +36,70 @@ "bom-ref": "my-specific-bom-ref-for-my-first-service", "data": [ { + "flow": "outbound", "classification": "public", - "flow": "outbound" + "name": "Credit card reporting", + "description": "Credit card information exchanged between the web app and the database", + "governance": { + "custodians": [ + { + "organization": { + "contact": [ + { + "email": "someone@somewhere.tld", + "name": "A N Other", + "phone": "+44 (0)1234 567890" + }, + { + "email": "paul.horton@owasp.org", + "name": "Paul Horton" + } + ], + "name": "CycloneDX", + "url": [ + "https://cyclonedx.org", + "https://cyclonedx.org/docs" + ] + } + } + ], + "owners": [ + { + "organization": { + "contact": [ + { + "email": "someone@somewhere.tld", + "name": "A N Other", + "phone": "+44 (0)1234 567890" + }, + { + "email": "paul.horton@owasp.org", + "name": "Paul Horton" + } + ], + "name": "CycloneDX", + "url": [ + "https://cyclonedx.org", + "https://cyclonedx.org/docs" + ] + } + } + ], + "stewards": [ + { + "contact": { + "email": "paul.horton@owasp.org", + "name": "Paul Horton" + } + } + ] + }, + "source": [ + "https://0.0.0.0/source" + ], + "destination": [ + "https://0.0.0.0/destination" + ] } ], "description": "Description goes here", diff --git a/tests/_data/snapshots/get_bom_with_services_complex-1.5.xml.bin b/tests/_data/snapshots/get_bom_with_services_complex-1.5.xml.bin index 7fb7fc50..54e9e59f 100644 --- a/tests/_data/snapshots/get_bom_with_services_complex-1.5.xml.bin +++ b/tests/_data/snapshots/get_bom_with_services_complex-1.5.xml.bin @@ -34,7 +34,61 @@ false true - public + + public + + + + + CycloneDX + https://cyclonedx.org + https://cyclonedx.org/docs + + A N Other + someone@somewhere.tld + +44 (0)1234 567890 + + + Paul Horton + paul.horton@owasp.org + + + + + + + + Paul Horton + paul.horton@owasp.org + + + + + + + CycloneDX + https://cyclonedx.org + https://cyclonedx.org/docs + + A N Other + someone@somewhere.tld + +44 (0)1234 567890 + + + Paul Horton + paul.horton@owasp.org + + + + + + + https://0.0.0.0/source + + + https://0.0.0.0/destination + + diff --git a/tests/_data/snapshots/get_bom_with_services_complex-1.6.json.bin b/tests/_data/snapshots/get_bom_with_services_complex-1.6.json.bin index 45b78218..18e5fe74 100644 --- a/tests/_data/snapshots/get_bom_with_services_complex-1.6.json.bin +++ b/tests/_data/snapshots/get_bom_with_services_complex-1.6.json.bin @@ -36,8 +36,82 @@ "bom-ref": "my-specific-bom-ref-for-my-first-service", "data": [ { + "flow": "outbound", "classification": "public", - "flow": "outbound" + "name": "Credit card reporting", + "description": "Credit card information exchanged between the web app and the database", + "governance": { + "custodians": [ + { + "organization": { + "address": { + "country": "GB", + "locality": "Cheshire", + "region": "England", + "streetAddress": "100 Main Street" + }, + "contact": [ + { + "email": "someone@somewhere.tld", + "name": "A N Other", + "phone": "+44 (0)1234 567890" + }, + { + "email": "paul.horton@owasp.org", + "name": "Paul Horton" + } + ], + "name": "CycloneDX", + "url": [ + "https://cyclonedx.org", + "https://cyclonedx.org/docs" + ] + } + } + ], + "owners": [ + { + "organization": { + "address": { + "country": "GB", + "locality": "Cheshire", + "region": "England", + "streetAddress": "100 Main Street" + }, + "contact": [ + { + "email": "someone@somewhere.tld", + "name": "A N Other", + "phone": "+44 (0)1234 567890" + }, + { + "email": "paul.horton@owasp.org", + "name": "Paul Horton" + } + ], + "name": "CycloneDX", + "url": [ + "https://cyclonedx.org", + "https://cyclonedx.org/docs" + ] + } + } + ], + "stewards": [ + { + "contact": { + "email": "paul.horton@owasp.org", + "name": "Paul Horton" + } + } + ] + }, + "source": [ + "https://0.0.0.0/source" + ], + "destination": [ + "https://0.0.0.0/destination" + ] } ], "description": "Description goes here", diff --git a/tests/_data/snapshots/get_bom_with_services_complex-1.6.xml.bin b/tests/_data/snapshots/get_bom_with_services_complex-1.6.xml.bin index 7a054cfa..06d1cbfe 100644 --- a/tests/_data/snapshots/get_bom_with_services_complex-1.6.xml.bin +++ b/tests/_data/snapshots/get_bom_with_services_complex-1.6.xml.bin @@ -40,7 +40,73 @@ false true - public + + public + + + + + CycloneDX +
+ GB + England + Cheshire + 100 Main Street +
+ https://cyclonedx.org + https://cyclonedx.org/docs + + A N Other + someone@somewhere.tld + +44 (0)1234 567890 + + + Paul Horton + paul.horton@owasp.org + +
+
+
+ + + + Paul Horton + paul.horton@owasp.org + + + + + + + CycloneDX +
+ GB + England + Cheshire + 100 Main Street +
+ https://cyclonedx.org + https://cyclonedx.org/docs + + A N Other + someone@somewhere.tld + +44 (0)1234 567890 + + + Paul Horton + paul.horton@owasp.org + +
+
+
+
+ + https://0.0.0.0/source + + + https://0.0.0.0/destination + +
diff --git a/tests/_data/snapshots/get_bom_with_services_complex-1.7.json.bin b/tests/_data/snapshots/get_bom_with_services_complex-1.7.json.bin index 9aa33fa2..3ca03aef 100644 --- a/tests/_data/snapshots/get_bom_with_services_complex-1.7.json.bin +++ b/tests/_data/snapshots/get_bom_with_services_complex-1.7.json.bin @@ -36,8 +36,82 @@ "bom-ref": "my-specific-bom-ref-for-my-first-service", "data": [ { + "flow": "outbound", "classification": "public", - "flow": "outbound" + "name": "Credit card reporting", + "description": "Credit card information exchanged between the web app and the database", + "governance": { + "custodians": [ + { + "organization": { + "address": { + "country": "GB", + "locality": "Cheshire", + "region": "England", + "streetAddress": "100 Main Street" + }, + "contact": [ + { + "email": "someone@somewhere.tld", + "name": "A N Other", + "phone": "+44 (0)1234 567890" + }, + { + "email": "paul.horton@owasp.org", + "name": "Paul Horton" + } + ], + "name": "CycloneDX", + "url": [ + "https://cyclonedx.org", + "https://cyclonedx.org/docs" + ] + } + } + ], + "owners": [ + { + "organization": { + "address": { + "country": "GB", + "locality": "Cheshire", + "region": "England", + "streetAddress": "100 Main Street" + }, + "contact": [ + { + "email": "someone@somewhere.tld", + "name": "A N Other", + "phone": "+44 (0)1234 567890" + }, + { + "email": "paul.horton@owasp.org", + "name": "Paul Horton" + } + ], + "name": "CycloneDX", + "url": [ + "https://cyclonedx.org", + "https://cyclonedx.org/docs" + ] + } + } + ], + "stewards": [ + { + "contact": { + "email": "paul.horton@owasp.org", + "name": "Paul Horton" + } + } + ] + }, + "source": [ + "https://0.0.0.0/source" + ], + "destination": [ + "https://0.0.0.0/destination" + ] } ], "description": "Description goes here", diff --git a/tests/_data/snapshots/get_bom_with_services_complex-1.7.xml.bin b/tests/_data/snapshots/get_bom_with_services_complex-1.7.xml.bin index 770f7a84..1fb8c756 100644 --- a/tests/_data/snapshots/get_bom_with_services_complex-1.7.xml.bin +++ b/tests/_data/snapshots/get_bom_with_services_complex-1.7.xml.bin @@ -40,7 +40,73 @@ false true - public + + public + + + + + CycloneDX +
+ GB + England + Cheshire + 100 Main Street +
+ https://cyclonedx.org + https://cyclonedx.org/docs + + A N Other + someone@somewhere.tld + +44 (0)1234 567890 + + + Paul Horton + paul.horton@owasp.org + +
+
+
+ + + + Paul Horton + paul.horton@owasp.org + + + + + + + CycloneDX +
+ GB + England + Cheshire + 100 Main Street +
+ https://cyclonedx.org + https://cyclonedx.org/docs + + A N Other + someone@somewhere.tld + +44 (0)1234 567890 + + + Paul Horton + paul.horton@owasp.org + +
+
+
+
+ + https://0.0.0.0/source + + + https://0.0.0.0/destination + +
diff --git a/tests/test_enums.py b/tests/test_enums.py index 88ac8e71..551d8b42 100644 --- a/tests/test_enums.py +++ b/tests/test_enums.py @@ -45,7 +45,7 @@ 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.service import Data, Service from cyclonedx.model.vulnerability import ( BomTarget, BomTargetVersionRange, @@ -207,7 +207,7 @@ def test_knows_value(self, value: str) -> None: @named_data(*NAMED_OF_SV) def test_cases_render_valid(self, of: OutputFormat, sv: SchemaVersion, *_: Any, **__: Any) -> None: bom = _make_bom(services=[Service(name='dummy', bom_ref='dummy', data=( - DataClassification(flow=df, classification=df.name) + Data(flow=df, classification=df.name) for df in DataFlow ))]) super()._test_cases_render(bom, of, sv) diff --git a/tests/test_model_service.py b/tests/test_model_service.py index c66c2521..dd87c13c 100644 --- a/tests/test_model_service.py +++ b/tests/test_model_service.py @@ -18,7 +18,18 @@ from unittest import TestCase -from cyclonedx.model.service import Service +from defusedxml.ElementTree import fromstring as xml_fromstring # type:ignore[import-untyped] +from sortedcontainers import SortedSet + +from cyclonedx.model import DataFlow, XsUri +from cyclonedx.model.contact import OrganizationalContact, OrganizationalEntity +from cyclonedx.model.service import ( + Data, + DataGovernance, + OrganizationOrIndividualType, + Service, + _DataRepositorySerializationHelper, +) from tests import reorder @@ -80,3 +91,289 @@ def test_sort(self) -> None: sorted_services = sorted(services) expected_services = reorder(services, expected_order) self.assertListEqual(sorted_services, expected_services) + + def test_service_eq_non_service(self) -> None: + """Service.__eq__ returns False when compared to a non-Service.""" + s = Service(name='svc') + self.assertNotEqual(s, 'not-a-service') + self.assertNotEqual(s, 42) + self.assertNotEqual(s, None) + + def test_service_lt_non_service(self) -> None: + """Service.__lt__ returns NotImplemented for incompatible types.""" + s = Service(name='svc') + result = s.__lt__('not-a-service') # type: ignore[arg-type] + self.assertIs(result, NotImplemented) + + def test_service_repr(self) -> None: + s = Service(name='my-svc', group='my-group', version='1.0') + self.assertIn('my-svc', repr(s)) + self.assertIn('my-group', repr(s)) + self.assertIn('1.0', repr(s)) + + +class TestModelOrganizationOrIndividualType(TestCase): + + def _make_org(self) -> OrganizationalEntity: + return OrganizationalEntity(name='Acme Corp') + + def _make_contact(self) -> OrganizationalContact: + return OrganizationalContact(name='Jane Doe') + + def test_with_organization(self) -> None: + org = self._make_org() + t = OrganizationOrIndividualType(organization=org) + self.assertEqual(t.organization, org) + self.assertIsNone(t.individual) + + def test_with_individual(self) -> None: + contact = self._make_contact() + t = OrganizationOrIndividualType(individual=contact) + self.assertIsNone(t.organization) + self.assertEqual(t.individual, contact) + + def test_empty(self) -> None: + t = OrganizationOrIndividualType() + self.assertIsNone(t.organization) + self.assertIsNone(t.individual) + + def test_eq_same(self) -> None: + org = self._make_org() + a = OrganizationOrIndividualType(organization=org) + b = OrganizationOrIndividualType(organization=org) + self.assertEqual(a, b) + + def test_eq_different(self) -> None: + a = OrganizationOrIndividualType(organization=self._make_org()) + b = OrganizationOrIndividualType(individual=self._make_contact()) + self.assertNotEqual(a, b) + + def test_eq_non_type(self) -> None: + """__eq__ returns False for non-OrganizationOrIndividualType objects.""" + t = OrganizationOrIndividualType(organization=self._make_org()) + self.assertNotEqual(t, 'not-an-org') + self.assertNotEqual(t, None) + + def test_lt(self) -> None: + """OrganizationOrIndividualType.__lt__ orders by comparable tuple.""" + a = OrganizationOrIndividualType(organization=OrganizationalEntity(name='AAA')) + b = OrganizationOrIndividualType(organization=OrganizationalEntity(name='ZZZ')) + self.assertLess(a, b) + + def test_lt_non_type(self) -> None: + """__lt__ returns NotImplemented for incompatible types.""" + t = OrganizationOrIndividualType(organization=self._make_org()) + result = t.__lt__('not-an-org') # type: ignore[arg-type] + self.assertIs(result, NotImplemented) + + def test_hash_consistency(self) -> None: + """Equal objects must have equal hashes.""" + org = self._make_org() + a = OrganizationOrIndividualType(organization=org) + b = OrganizationOrIndividualType(organization=org) + self.assertEqual(hash(a), hash(b)) + + def test_sort(self) -> None: + items = [ + OrganizationOrIndividualType(organization=OrganizationalEntity(name='ZZZ')), + OrganizationOrIndividualType(organization=OrganizationalEntity(name='AAA')), + ] + self.assertEqual(sorted(items)[0].organization.name, 'AAA') # type: ignore[union-attr] + + +class TestModelDataGovernance(TestCase): + + def _make_party(self, name: str = 'Acme') -> OrganizationOrIndividualType: + return OrganizationOrIndividualType( + organization=OrganizationalEntity(name=name) + ) + + def test_empty(self) -> None: + g = DataGovernance() + self.assertFalse(g.custodians) + self.assertFalse(g.stewards) + self.assertFalse(g.owners) + + def test_with_all_roles(self) -> None: + p = self._make_party() + g = DataGovernance(custodians=[p], stewards=[p], owners=[p]) + self.assertEqual(len(g.custodians), 1) + self.assertEqual(len(g.stewards), 1) + self.assertEqual(len(g.owners), 1) + + def test_eq_same(self) -> None: + p = self._make_party() + a = DataGovernance(custodians=[p]) + b = DataGovernance(custodians=[p]) + self.assertEqual(a, b) + + def test_eq_different(self) -> None: + a = DataGovernance(custodians=[self._make_party('A')]) + b = DataGovernance(custodians=[self._make_party('B')]) + self.assertNotEqual(a, b) + + def test_eq_non_type(self) -> None: + """__eq__ returns False for non-DataGovernance objects.""" + g = DataGovernance(custodians=[self._make_party()]) + self.assertNotEqual(g, 'not-governance') + self.assertNotEqual(g, None) + + def test_lt(self) -> None: + """DataGovernance.__lt__ orders by comparable tuple.""" + a = DataGovernance(custodians=[self._make_party('AAA')]) + b = DataGovernance(custodians=[self._make_party('ZZZ')]) + self.assertLess(a, b) + + def test_lt_non_type(self) -> None: + """__lt__ returns NotImplemented for incompatible types.""" + g = DataGovernance() + result = g.__lt__('not-governance') # type: ignore[arg-type] + self.assertIs(result, NotImplemented) + + def test_hash_consistency(self) -> None: + p = self._make_party() + a = DataGovernance(custodians=[p]) + b = DataGovernance(custodians=[p]) + self.assertEqual(hash(a), hash(b)) + + +class TestModelData(TestCase): + + def test_minimal(self) -> None: + d = Data(flow=DataFlow.INBOUND, classification='public') + self.assertEqual(d.flow, DataFlow.INBOUND) + self.assertEqual(d.classification, 'public') + self.assertIsNone(d.name) + self.assertIsNone(d.description) + self.assertIsNone(d.governance) + self.assertFalse(d.source) + self.assertFalse(d.destination) + + def test_full(self) -> None: + gov = DataGovernance( + custodians=[OrganizationOrIndividualType(organization=OrganizationalEntity(name='Org'))] + ) + d = Data( + flow=DataFlow.OUTBOUND, + classification='confidential', + name='Credit cards', + description='PCI data', + governance=gov, + source=[XsUri('https://source.example.com')], + destination=[XsUri('https://dest.example.com')], + ) + self.assertEqual(d.flow, DataFlow.OUTBOUND) + self.assertEqual(d.classification, 'confidential') + self.assertEqual(d.name, 'Credit cards') + self.assertEqual(d.description, 'PCI data') + self.assertEqual(d.governance, gov) + self.assertEqual(len(d.source), 1) + self.assertEqual(len(d.destination), 1) + + def test_eq_same(self) -> None: + a = Data(flow=DataFlow.INBOUND, classification='public') + b = Data(flow=DataFlow.INBOUND, classification='public') + self.assertEqual(a, b) + + def test_eq_different_flow(self) -> None: + a = Data(flow=DataFlow.INBOUND, classification='public') + b = Data(flow=DataFlow.OUTBOUND, classification='public') + self.assertNotEqual(a, b) + + def test_eq_non_type(self) -> None: + """Data.__eq__ returns False for non-Data objects.""" + d = Data(flow=DataFlow.INBOUND, classification='public') + self.assertNotEqual(d, 'not-data') + self.assertNotEqual(d, None) + + def test_lt(self) -> None: + a = Data(flow=DataFlow.INBOUND, classification='aaa') + b = Data(flow=DataFlow.INBOUND, classification='zzz') + self.assertLess(a, b) + + def test_lt_non_type(self) -> None: + """Data.__lt__ returns NotImplemented for incompatible types.""" + d = Data(flow=DataFlow.INBOUND, classification='public') + result = d.__lt__('not-data') # type: ignore[arg-type] + self.assertIs(result, NotImplemented) + + def test_hash_consistency(self) -> None: + a = Data(flow=DataFlow.INBOUND, classification='public') + b = Data(flow=DataFlow.INBOUND, classification='public') + self.assertEqual(hash(a), hash(b)) + + def test_repr(self) -> None: + d = Data(flow=DataFlow.OUTBOUND, classification='restricted') + r = repr(d) + # The repr includes the flow enum and classification; the exact string for the enum + # varies by Python version (e.g. 'outbound' on 3.9, 'DataFlow.OUTBOUND' on 3.12+), + # but 'OUTBOUND' is always a substring of either representation. + self.assertIn('OUTBOUND', r.upper()) + self.assertIn('restricted', r) + + def test_sort(self) -> None: + items = [ + Data(flow=DataFlow.INBOUND, classification='zzz'), + Data(flow=DataFlow.INBOUND, classification='aaa'), + ] + self.assertEqual(sorted(items)[0].classification, 'aaa') + + def test_setters(self) -> None: + d = Data(flow=DataFlow.INBOUND, classification='public') + gov = DataGovernance() + d.flow = DataFlow.OUTBOUND + d.classification = 'private' + d.name = 'new name' + d.description = 'new desc' + d.governance = gov + d.source = [XsUri('https://a.example.com')] + d.destination = [XsUri('https://b.example.com')] + self.assertEqual(d.flow, DataFlow.OUTBOUND) + self.assertEqual(d.classification, 'private') + self.assertEqual(d.name, 'new name') + self.assertEqual(d.description, 'new desc') + self.assertEqual(d.governance, gov) + self.assertEqual(len(d.source), 1) + self.assertEqual(len(d.destination), 1) + + +class TestDataRepositorySerializationHelper(TestCase): + """Direct tests for the non-public serialization helper.""" + + def test_json_normalize_empty_returns_none(self) -> None: + """json_normalize returns None for an empty set (defensive early-exit).""" + result = _DataRepositorySerializationHelper.json_normalize(SortedSet(), view=None) + self.assertIsNone(result) + + def test_xml_normalize_empty_returns_none(self) -> None: + """xml_normalize returns None for an empty set (defensive early-exit).""" + result = _DataRepositorySerializationHelper.xml_normalize( + SortedSet(), element_name='data', view=None, xmlns=None + ) + self.assertIsNone(result) + + def test_xml_denormalize_legacy_classification_tag(self) -> None: + """xml_denormalize handles CDX 1.2-1.4 flat elements.""" + # CDX 1.2-1.4 format: public + xml_str = 'public' + elem = xml_fromstring(xml_str) + result = _DataRepositorySerializationHelper.xml_denormalize(elem, default_ns=None) + self.assertEqual(len(result), 1) + item = next(iter(result)) + self.assertEqual(item.flow, DataFlow.OUTBOUND) + self.assertEqual(item.classification, 'public') + + def test_xml_denormalize_legacy_classification_tag_with_namespace(self) -> None: + """xml_denormalize handles CDX 1.2-1.4 flat with namespace.""" + ns = 'http://cyclonedx.org/schema/bom/1.3' + xml_str = ( + f'' + f'confidential' + f'' + ) + elem = xml_fromstring(xml_str) + result = _DataRepositorySerializationHelper.xml_denormalize(elem, default_ns=ns) + self.assertEqual(len(result), 1) + item = next(iter(result)) + self.assertEqual(item.flow, DataFlow.OUTBOUND) + self.assertEqual(item.classification, 'confidential')