diff --git a/cyclonedx/model/bom.py b/cyclonedx/model/bom.py index 130074ee..d32c9047 100644 --- a/cyclonedx/model/bom.py +++ b/cyclonedx/model/bom.py @@ -649,23 +649,36 @@ def has_vulnerabilities(self) -> bool: """ return bool(self.vulnerabilities) - def register_dependency(self, target: Dependable, depends_on: Optional[Iterable[Dependable]] = None) -> None: + def register_dependency( + self, + target: Dependable, + depends_on: Optional[Iterable[Dependable]] = None, + provides: Optional[Iterable[Dependable]] = None, + ) -> None: _d = next(filter(lambda _d: _d.ref == target.bom_ref, self.dependencies), None) if _d: # Dependency Target already registered - but it might have new dependencies to add if depends_on: _d.dependencies.update(map(lambda _d: Dependency(ref=_d.bom_ref), depends_on)) + if provides: + _d.provides.update(map(lambda _p: Dependency(ref=_p.bom_ref), provides)) else: # First time we are seeing this target as a Dependency - self._dependencies.add(Dependency( - ref=target.bom_ref, - dependencies=map(lambda _dep: Dependency(ref=_dep.bom_ref), depends_on) if depends_on else [] - )) + self._dependencies.add( + Dependency( + ref=target.bom_ref, + dependencies=map(lambda _dep: Dependency(ref=_dep.bom_ref), depends_on) if depends_on else [], + provides=map(lambda _prov: Dependency(ref=_prov.bom_ref), provides) if provides else [], + ) + ) if depends_on: # Ensure dependents are registered with no further dependents in the DependencyGraph for _d2 in depends_on: self.register_dependency(target=_d2, depends_on=None) + if provides: + for _p2 in provides: + self.register_dependency(target=_p2, depends_on=None, provides=None) def urn(self) -> str: return f'{_BOM_LINK_PREFIX}{self.serial_number}/{self.version}' @@ -686,12 +699,14 @@ def validate(self) -> bool: for _s in self.services: self.register_dependency(target=_s) - # 1. Make sure dependencies are all in this Bom. + # 1. Make sure dependencies and provides are all in this Bom. component_bom_refs = set(map(lambda c: c.bom_ref, self._get_all_components())) | set( map(lambda s: s.bom_ref, self.services)) + dependency_bom_refs = set(chain( (d.ref for d in self.dependencies), - chain.from_iterable(d.dependencies_as_bom_refs() for d in self.dependencies) + chain.from_iterable(d.dependencies_as_bom_refs() for d in self.dependencies), + chain.from_iterable(d.provides_as_bom_refs() for d in self.dependencies) # Include provides refs here )) dependency_diff = dependency_bom_refs - component_bom_refs if len(dependency_diff) > 0: diff --git a/cyclonedx/model/dependency.py b/cyclonedx/model/dependency.py index 8241fdfc..f9ef8198 100644 --- a/cyclonedx/model/dependency.py +++ b/cyclonedx/model/dependency.py @@ -22,6 +22,8 @@ import py_serializable as serializable from sortedcontainers import SortedSet +from cyclonedx.schema.schema import SchemaVersion1Dot6 + from .._internal.compare import ComparableTuple as _ComparableTuple from ..exception.serialization import SerializationOfUnexpectedValueException from .bom_ref import BomRef @@ -52,12 +54,20 @@ class Dependency: Models a Dependency within a BOM. .. note:: - See https://cyclonedx.org/docs/1.6/xml/#type_dependencyType + See: + 1. https://cyclonedx.org/docs/1.6/xml/#type_dependencyType + 2. https://cyclonedx.org/docs/1.6/json/#dependencies """ - def __init__(self, ref: BomRef, dependencies: Optional[Iterable['Dependency']] = None) -> None: + def __init__( + self, + ref: BomRef, + dependencies: Optional[Iterable['Dependency']] = None, + provides: Optional[Iterable['Dependency']] = None + ) -> None: self.ref = ref self.dependencies = dependencies or [] # type:ignore[assignment] + self.provides = provides or [] # type:ignore[assignment] @property @serializable.type_mapping(BomRef) @@ -80,14 +90,29 @@ def dependencies(self) -> 'SortedSet[Dependency]': def dependencies(self, dependencies: Iterable['Dependency']) -> None: self._dependencies = SortedSet(dependencies) + @property + @serializable.view(SchemaVersion1Dot6) + @serializable.json_name('provides') + @serializable.type_mapping(_DependencyRepositorySerializationHelper) + @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, 'provides') + def provides(self) -> 'SortedSet[Dependency]': + return self._provides + + @provides.setter + def provides(self, provides: Iterable['Dependency']) -> None: + self._provides = SortedSet(provides) + def dependencies_as_bom_refs(self) -> Set[BomRef]: return set(map(lambda d: d.ref, self.dependencies)) def __comparable_tuple(self) -> _ComparableTuple: return _ComparableTuple(( - self.ref, _ComparableTuple(self.dependencies) + self.ref, _ComparableTuple(self.dependencies), _ComparableTuple(self.provides) )) + def provides_as_bom_refs(self) -> Set[BomRef]: + return set(map(lambda d: d.ref, self.provides)) + def __eq__(self, other: object) -> bool: if isinstance(other, Dependency): return self.__comparable_tuple() == other.__comparable_tuple() @@ -102,7 +127,11 @@ def __hash__(self) -> int: return hash(self.__comparable_tuple()) def __repr__(self) -> str: - return f'' + return ( + f'' + ) class Dependable(ABC): diff --git a/tests/_data/models.py b/tests/_data/models.py index 6a25c552..f07ca8c2 100644 --- a/tests/_data/models.py +++ b/tests/_data/models.py @@ -1406,6 +1406,29 @@ def get_bom_with_definitions_and_detailed_standards() -> Bom: ])) +def get_bom_with_provides() -> Bom: + bom = _make_bom() + bom.metadata.component = root_component = Component(name='app A', bom_ref='A', type=ComponentType.APPLICATION) + bom.components.add( + c1 := Component(name='device B', bom_ref='B', type=ComponentType.DEVICE)) + bom.components.add( + c2 := Component(name='device C', bom_ref='C', type=ComponentType.DEVICE)) + bom.dependencies = [ + Dependency( + ref=c2.bom_ref + ), + Dependency( + ref=c1.bom_ref, + provides=[Dependency(ref=c2.bom_ref)] + ), + Dependency( + ref=root_component.bom_ref, + dependencies=[Dependency(ref=c2.bom_ref)] + ), + ] + return bom + + def get_bom_for_issue540_duplicate_components() -> Bom: # tests https://github.com/CycloneDX/cyclonedx-python-lib/issues/540 bom = _make_bom() diff --git a/tests/_data/snapshots/get_bom_with_provides-1.0.xml.bin b/tests/_data/snapshots/get_bom_with_provides-1.0.xml.bin new file mode 100644 index 00000000..ec2a2753 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_provides-1.0.xml.bin @@ -0,0 +1,15 @@ + + + + + device B + + false + + + device C + + false + + + diff --git a/tests/_data/snapshots/get_bom_with_provides-1.1.xml.bin b/tests/_data/snapshots/get_bom_with_provides-1.1.xml.bin new file mode 100644 index 00000000..c1fc58db --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_provides-1.1.xml.bin @@ -0,0 +1,13 @@ + + + + + device B + + + + device C + + + + diff --git a/tests/_data/snapshots/get_bom_with_provides-1.2.json.bin b/tests/_data/snapshots/get_bom_with_provides-1.2.json.bin new file mode 100644 index 00000000..e811633a --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_provides-1.2.json.bin @@ -0,0 +1,44 @@ +{ + "components": [ + { + "bom-ref": "B", + "name": "device B", + "type": "device", + "version": "" + }, + { + "bom-ref": "C", + "name": "device C", + "type": "device", + "version": "" + } + ], + "dependencies": [ + { + "dependsOn": [ + "C" + ], + "ref": "A" + }, + { + "ref": "B" + }, + { + "ref": "C" + } + ], + "metadata": { + "component": { + "bom-ref": "A", + "name": "app A", + "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_provides-1.2.xml.bin b/tests/_data/snapshots/get_bom_with_provides-1.2.xml.bin new file mode 100644 index 00000000..bcbe4d3c --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_provides-1.2.xml.bin @@ -0,0 +1,27 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + app A + + + + + + device B + + + + device C + + + + + + + + + + + diff --git a/tests/_data/snapshots/get_bom_with_provides-1.3.json.bin b/tests/_data/snapshots/get_bom_with_provides-1.3.json.bin new file mode 100644 index 00000000..e36390d1 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_provides-1.3.json.bin @@ -0,0 +1,44 @@ +{ + "components": [ + { + "bom-ref": "B", + "name": "device B", + "type": "device", + "version": "" + }, + { + "bom-ref": "C", + "name": "device C", + "type": "device", + "version": "" + } + ], + "dependencies": [ + { + "dependsOn": [ + "C" + ], + "ref": "A" + }, + { + "ref": "B" + }, + { + "ref": "C" + } + ], + "metadata": { + "component": { + "bom-ref": "A", + "name": "app A", + "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_provides-1.3.xml.bin b/tests/_data/snapshots/get_bom_with_provides-1.3.xml.bin new file mode 100644 index 00000000..6dbfe0ac --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_provides-1.3.xml.bin @@ -0,0 +1,27 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + app A + + + + + + device B + + + + device C + + + + + + + + + + + diff --git a/tests/_data/snapshots/get_bom_with_provides-1.4.json.bin b/tests/_data/snapshots/get_bom_with_provides-1.4.json.bin new file mode 100644 index 00000000..ac399a6b --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_provides-1.4.json.bin @@ -0,0 +1,41 @@ +{ + "components": [ + { + "bom-ref": "B", + "name": "device B", + "type": "device" + }, + { + "bom-ref": "C", + "name": "device C", + "type": "device" + } + ], + "dependencies": [ + { + "dependsOn": [ + "C" + ], + "ref": "A" + }, + { + "ref": "B" + }, + { + "ref": "C" + } + ], + "metadata": { + "component": { + "bom-ref": "A", + "name": "app A", + "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_provides-1.4.xml.bin b/tests/_data/snapshots/get_bom_with_provides-1.4.xml.bin new file mode 100644 index 00000000..90c727eb --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_provides-1.4.xml.bin @@ -0,0 +1,24 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + app A + + + + + device B + + + device C + + + + + + + + + + diff --git a/tests/_data/snapshots/get_bom_with_provides-1.5.json.bin b/tests/_data/snapshots/get_bom_with_provides-1.5.json.bin new file mode 100644 index 00000000..484b469b --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_provides-1.5.json.bin @@ -0,0 +1,51 @@ +{ + "components": [ + { + "bom-ref": "B", + "name": "device B", + "type": "device" + }, + { + "bom-ref": "C", + "name": "device C", + "type": "device" + } + ], + "dependencies": [ + { + "dependsOn": [ + "C" + ], + "ref": "A" + }, + { + "ref": "B" + }, + { + "ref": "C" + } + ], + "metadata": { + "component": { + "bom-ref": "A", + "name": "app A", + "type": "application" + }, + "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_provides-1.5.xml.bin b/tests/_data/snapshots/get_bom_with_provides-1.5.xml.bin new file mode 100644 index 00000000..da72fa10 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_provides-1.5.xml.bin @@ -0,0 +1,28 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + app A + + + + + device B + + + device C + + + + + + + + + + + val1 + val2 + + diff --git a/tests/_data/snapshots/get_bom_with_provides-1.6.json.bin b/tests/_data/snapshots/get_bom_with_provides-1.6.json.bin new file mode 100644 index 00000000..01f0a807 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_provides-1.6.json.bin @@ -0,0 +1,54 @@ +{ + "components": [ + { + "bom-ref": "B", + "name": "device B", + "type": "device" + }, + { + "bom-ref": "C", + "name": "device C", + "type": "device" + } + ], + "dependencies": [ + { + "dependsOn": [ + "C" + ], + "ref": "A" + }, + { + "provides": [ + "C" + ], + "ref": "B" + }, + { + "ref": "C" + } + ], + "metadata": { + "component": { + "bom-ref": "A", + "name": "app A", + "type": "application" + }, + "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_provides-1.6.xml.bin b/tests/_data/snapshots/get_bom_with_provides-1.6.xml.bin new file mode 100644 index 00000000..d6e85b0f --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_provides-1.6.xml.bin @@ -0,0 +1,30 @@ + + + + 2023-01-07T13:44:32.312678+00:00 + + app A + + + + + device B + + + device C + + + + + + + + + + + + + val1 + val2 + + diff --git a/tests/test_model_dependency.py b/tests/test_model_dependency.py index 77f68b79..33e8d518 100644 --- a/tests/test_model_dependency.py +++ b/tests/test_model_dependency.py @@ -41,3 +41,26 @@ def test_sort(self) -> None: sorted_deps = sorted(deps) expected_deps = reorder(deps, expected_order) self.assertEqual(sorted_deps, expected_deps) + + def test_dependency_with_provides(self) -> None: + # Create test data + ref1 = BomRef(value='be2c6502-7e9a-47db-9a66-e34f729810a3') + ref2 = BomRef(value='0b049d09-64c0-4490-a0f5-c84d9aacf857') + provides_ref1 = BomRef(value='cd3e9c95-9d41-49e7-9924-8cf0465ae789') + provides_ref2 = BomRef(value='17e3b199-dc0b-42ef-bfdd-1fa81a1e3eda') + + # Create dependencies with provides + dep1 = Dependency(ref=ref1, provides=[Dependency(ref=provides_ref1)]) + dep2 = Dependency(ref=ref2, provides=[Dependency(ref=provides_ref2)]) + + # Verify provides field + self.assertEqual(len(dep1.provides), 1) + self.assertEqual(len(dep2.provides), 1) + + # Check provides_as_bom_refs + self.assertEqual(dep1.provides_as_bom_refs(), {provides_ref1}) + self.assertEqual(dep2.provides_as_bom_refs(), {provides_ref2}) + + # Verify comparison and hashing + self.assertNotEqual(hash(dep1), hash(dep2)) + self.assertNotEqual(dep1, dep2)