diff --git a/cyclonedx/model/component.py b/cyclonedx/model/component.py index 09031eef7..518ef8248 100644 --- a/cyclonedx/model/component.py +++ b/cyclonedx/model/component.py @@ -51,7 +51,7 @@ SchemaVersion1Dot6, SchemaVersion1Dot7, ) -from ..serialization import PackageUrl as PackageUrlSH +from ..serialization import PackageUrl as PackageUrlSH, XmlBoolAttribute as _XmlBoolAttributeSH from . import ( AttachedText, ExternalReference, @@ -993,6 +993,7 @@ def __init__( version: Optional[str] = None, description: Optional[str] = None, scope: Optional[ComponentScope] = None, + is_external: Optional[bool] = None, hashes: Optional[Iterable[HashType]] = None, licenses: Optional[Iterable[License]] = None, copyright: Optional[str] = None, @@ -1026,6 +1027,7 @@ def __init__( self.name = name self.description = description self.scope = scope + self.is_external = is_external self.hashes = hashes or [] self.licenses = licenses or [] self.copyright = copyright @@ -1304,6 +1306,29 @@ def scope(self) -> Optional[ComponentScope]: def scope(self, scope: Optional[ComponentScope]) -> None: self._scope = scope + @property + @serializable.json_name('isExternal') + @serializable.xml_name('isExternal') + @serializable.xml_attribute() + @serializable.type_mapping(_XmlBoolAttributeSH) + @serializable.view(SchemaVersion1Dot7) + def is_external(self) -> Optional[bool]: + """ + Determine whether this component is external. An external component is one that is not part of an assembly, + but is expected to be provided by the environment, regardless of the component's scope. This setting can be + useful for distinguishing which components are bundled with the product and which can be relied upon to be + present in the deployment environment. This may be set to true for runtime components only. For + metadata.component, it must be set to false. + + Returns: + `bool` if set else `None` + """ + return self._is_external + + @is_external.setter + def is_external(self, is_external: Optional[bool]) -> None: + self._is_external = is_external + @property @serializable.type_mapping(_HashTypeRepositorySerializationHelper) @serializable.xml_sequence(11) @@ -1683,7 +1708,7 @@ def __comparable_tuple(self) -> _ComparableTuple: self.swid, self.cpe, _ComparableTuple(self.swhids), self.supplier, self.author, self.publisher, self.description, - self.mime_type, self.scope, _ComparableTuple(self.hashes), + self.mime_type, self.scope, self.is_external, _ComparableTuple(self.hashes), _ComparableTuple(self.licenses), self.copyright, self.pedigree, _ComparableTuple(self.external_references), _ComparableTuple(self.properties), diff --git a/cyclonedx/serialization/__init__.py b/cyclonedx/serialization/__init__.py index 1fec0026f..a5b946cbc 100644 --- a/cyclonedx/serialization/__init__.py +++ b/cyclonedx/serialization/__init__.py @@ -95,6 +95,63 @@ def deserialize(cls, o: Any) -> UUID: ) from err +class XmlBoolAttribute(BaseHelper): + """Helper for serializing boolean values as XML attribute-compatible 'true'/'false' strings, + while keeping native boolean values for JSON.""" + + @classmethod + def json_serialize(cls, o: Any) -> Optional[bool]: + if o is None: + return None + if isinstance(o, bool): + return o + raise SerializationOfUnexpectedValueException( + f'Attempt to serialize a non-boolean: {o!r}') + + @classmethod + def json_deserialize(cls, o: Any) -> Optional[bool]: + if o is None: + return None + if isinstance(o, bool): + return o + raise CycloneDxDeserializationException( + f'Invalid boolean value: {o!r}' + ) + + @classmethod + def xml_serialize(cls, o: Any) -> Optional[str]: + if o is None: + return None + if isinstance(o, bool): + return 'true' if o else 'false' + raise SerializationOfUnexpectedValueException( + f'Attempt to serialize a non-boolean: {o!r}') + + @classmethod + def xml_deserialize(cls, o: Any) -> Optional[bool]: + if o is None: + return None + if isinstance(o, bool): + return o + if isinstance(o, str): + o_lower = o.lower() + if o_lower in ('1', 'true'): + return True + if o_lower in ('0', 'false'): + return False + raise CycloneDxDeserializationException( + f'Invalid boolean value: {o!r}' + ) + + @classmethod + def serialize(cls, o: Any) -> Any: + return cls.xml_serialize(o) + + @classmethod + def deserialize(cls, o: Any) -> Any: + return cls.xml_deserialize(o) + + @deprecated('No public API planned for replacing this,') class LicenseRepositoryHelper(_LicenseRepositorySerializationHelper): """**DEPRECATED** diff --git a/tests/_data/models.py b/tests/_data/models.py index 55a5cdb9a..edd9f8157 100644 --- a/tests/_data/models.py +++ b/tests/_data/models.py @@ -593,6 +593,11 @@ def get_bom_with_external_references() -> Bom: return bom +def get_bom_with_external_component_1_7() -> Bom: + bom = _make_bom(components=[get_component_external()]) + return bom + + 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'), @@ -853,6 +858,16 @@ def get_component_setuptools_simple( ) +def get_component_external() -> Component: + return Component( + name='external-lib', version='1.0.0', + type=ComponentType.LIBRARY, + is_external=True, + scope=ComponentScope.REQUIRED, + bom_ref='external-lib-1.0.0', + ) + + def get_component_setuptools_simple_no_version(bom_ref: Optional[str] = None) -> Component: return Component( name='setuptools', bom_ref=bom_ref or 'pkg:pypi/setuptools?extension=tar.gz', @@ -1611,6 +1626,7 @@ def get_bom_for_issue540_duplicate_components() -> Bom: get_bom_with_licenses, get_bom_with_multiple_licenses, get_bom_for_issue_497_urls, + get_bom_with_external_component_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_external_component_1_7-1.0.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.0.xml.bin new file mode 100644 index 000000000..aaae83372 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_external_component_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_external_component_1_7-1.1.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.1.xml.bin new file mode 100644 index 000000000..06e044d33 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_external_component_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_external_component_1_7-1.2.json.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.2.json.bin new file mode 100644 index 000000000..fe5a4e0ac --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_external_component_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_external_component_1_7-1.2.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.2.xml.bin new file mode 100644 index 000000000..266020af7 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_external_component_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_external_component_1_7-1.3.json.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.3.json.bin new file mode 100644 index 000000000..8500a9f79 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_external_component_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_external_component_1_7-1.3.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.3.xml.bin new file mode 100644 index 000000000..120d5d288 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_external_component_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_external_component_1_7-1.4.json.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.4.json.bin new file mode 100644 index 000000000..c2c3bbea0 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_external_component_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_external_component_1_7-1.4.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.4.xml.bin new file mode 100644 index 000000000..2d85c7dec --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_external_component_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_external_component_1_7-1.5.json.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.5.json.bin new file mode 100644 index 000000000..f3d168966 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_external_component_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_external_component_1_7-1.5.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.5.xml.bin new file mode 100644 index 000000000..f06f8a0b5 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_external_component_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_external_component_1_7-1.6.json.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.6.json.bin new file mode 100644 index 000000000..bf66e8484 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_external_component_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_external_component_1_7-1.6.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.6.xml.bin new file mode 100644 index 000000000..c81104a76 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_external_component_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_external_component_1_7-1.7.json.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.7.json.bin new file mode 100644 index 000000000..c3c2f85b3 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.7.json.bin @@ -0,0 +1,35 @@ +{ + "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_external_component_1_7-1.7.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.7.xml.bin new file mode 100644 index 000000000..b16d837f9 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.7.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/test_model_component.py b/tests/test_model_component.py index 44f59a121..ca37e59e9 100644 --- a/tests/test_model_component.py +++ b/tests/test_model_component.py @@ -264,6 +264,45 @@ def test_sort(self) -> None: expected_components = reorder(components, expected_order) self.assertListEqual(sorted_components, expected_components) + def test_is_external_default_value(self) -> None: + c = Component(name='test-component') + self.assertIsNone(c.is_external) + + def test_is_external_set_true(self) -> None: + c = Component(name='test-component', is_external=True) + self.assertTrue(c.is_external) + + def test_is_external_set_false(self) -> None: + c = Component(name='test-component', is_external=False) + self.assertFalse(c.is_external) + + def test_is_external_equality_same(self) -> None: + c1 = Component(name='test-component', is_external=True) + c2 = Component(name='test-component', is_external=True) + self.assertEqual(c1, c2) + + def test_is_external_equality_different(self) -> None: + c1 = Component(name='test-component', is_external=True) + c2 = Component(name='test-component', is_external=False) + c3 = Component(name='test-component') + self.assertNotEqual(c1, c2) + self.assertNotEqual(c1, c3) + self.assertNotEqual(c2, c3) + + def test_is_external_sorting(self) -> None: + # expected sort order: (type, group, name, version, is_external) + # ComparableTuple treats None as greater than any value + # so order is: False < True < None + expected_order = [1, 0, 2] + components = [ + Component(name='component-a', is_external=True), + Component(name='component-a', is_external=False), + Component(name='component-a'), # is_external=None + ] + sorted_components = sorted(components) + expected_components = reorder(components, expected_order) + self.assertListEqual(sorted_components, expected_components) + def test_nested_components_1(self) -> None: comp_b = Component(name='comp_b') comp_c = Component(name='comp_c')