diff --git a/cyclonedx/contrib/bom/utils.py b/cyclonedx/contrib/bom/utils.py index 9e6712ab..bdc5fdf4 100644 --- a/cyclonedx/contrib/bom/utils.py +++ b/cyclonedx/contrib/bom/utils.py @@ -154,7 +154,7 @@ def reset(self) -> None: @staticmethod def _flatten_merge(deps: Iterable[Dependency]) -> Iterable[Dependency]: - flat: dict['BomRef', list['BomRef']] = {} + flat: dict['BomRef', tuple[list['BomRef'], list['BomRef']]] = {} todos = list(deps) seen = set() while todos: @@ -162,12 +162,15 @@ def _flatten_merge(deps: Iterable[Dependency]) -> Iterable[Dependency]: if (todo_id := id(todo)) in seen: continue seen.add(todo_id) - ds = flat.setdefault(todo.ref, []) + ds, ps = flat.setdefault(todo.ref, ([], [])) if todo_deps := todo.dependencies: ds.extend(d.ref for d in todo_deps) todos.extend(todo_deps) + if todo_provides := todo.provides: + ps.extend(p.ref for p in todo_provides) + todos.extend(todo_provides) return ( - Dependency(br, (Dependency(d) for d in ds)) - for br, ds + Dependency(br, (Dependency(d) for d in ds), (Dependency(p) for p in ps)) + for br, (ds, ps) in flat.items() ) diff --git a/cyclonedx/model/bom.py b/cyclonedx/model/bom.py index 7cb0081e..4ac54ca4 100644 --- a/cyclonedx/model/bom.py +++ b/cyclonedx/model/bom.py @@ -778,23 +778,34 @@ 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 [] + 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: """ @@ -824,12 +835,13 @@ 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) )) 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 de3ca326..1e60ac47 100644 --- a/cyclonedx/model/dependency.py +++ b/cyclonedx/model/dependency.py @@ -23,6 +23,8 @@ import py_serializable as serializable from sortedcontainers import SortedSet +from cyclonedx.schema.schema import SchemaVersion1Dot6, SchemaVersion1Dot7 + from .._internal.compare import ComparableTuple as _ComparableTuple from ..exception.serialization import SerializationOfUnexpectedValueException from .bom_ref import BomRef @@ -34,6 +36,8 @@ class _DependencyRepositorySerializationHelper(serializable.helpers.BaseHelper): @classmethod def serialize(cls, o: Any) -> list[str]: if isinstance(o, (SortedSet, set)): + if not o: + return [] return [str(i.ref) for i in o] raise SerializationOfUnexpectedValueException( f'Attempt to serialize a non-DependencyRepository: {o!r}') @@ -56,9 +60,15 @@ class Dependency: See https://cyclonedx.org/docs/1.7/xml/#type_dependencyType """ - 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 [] + self.provides = provides or [] @property @serializable.type_mapping(BomRef) @@ -84,9 +94,25 @@ def dependencies(self, dependencies: Iterable['Dependency']) -> None: def dependencies_as_bom_refs(self) -> set[BomRef]: return set(map(lambda d: d.ref, self.dependencies)) + @property + @serializable.view(SchemaVersion1Dot6) + @serializable.view(SchemaVersion1Dot7) + @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 provides_as_bom_refs(self) -> set[BomRef]: + return set(map(lambda d: d.ref, self.provides)) + def __comparable_tuple(self) -> _ComparableTuple: return _ComparableTuple(( - self.ref, _ComparableTuple(self.dependencies) + self.ref, _ComparableTuple(self.dependencies), _ComparableTuple(self.provides) )) def __eq__(self, other: object) -> bool: @@ -103,7 +129,7 @@ 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 e2052878..c1228d13 100644 --- a/tests/_data/models.py +++ b/tests/_data/models.py @@ -1573,6 +1573,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() @@ -1716,6 +1739,7 @@ def get_bom_for_issue941_nested_dependencies_irreversible_migrate() -> Bom: get_bom_with_services_complex, get_bom_with_services_simple, get_bom_with_licenses, + get_bom_with_provides, get_bom_with_multiple_licenses, get_bom_for_issue_497_urls, get_bom_with_external_component_1_7, 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/_data/snapshots/get_bom_with_provides-1.7.json.bin b/tests/_data/snapshots/get_bom_with_provides-1.7.json.bin new file mode 100644 index 00000000..72e87b84 --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_provides-1.7.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.7.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.7" +} \ No newline at end of file diff --git a/tests/_data/snapshots/get_bom_with_provides-1.7.xml.bin b/tests/_data/snapshots/get_bom_with_provides-1.7.xml.bin new file mode 100644 index 00000000..08ef4cab --- /dev/null +++ b/tests/_data/snapshots/get_bom_with_provides-1.7.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_contrib/test_bom_utils.py b/tests/test_contrib/test_bom_utils.py index 013d7070..1e71443f 100644 --- a/tests/test_contrib/test_bom_utils.py +++ b/tests/test_contrib/test_bom_utils.py @@ -188,3 +188,20 @@ def test_flatten_merge_and_reset_with(self) -> None: }, bom.dependencies) self.assertIs(bom_dependencies, bom.dependencies) self.assertSetEqual(bom_dependencies, bom.dependencies) + + def test_flatten_merge_preserves_provides(self) -> None: + ref_b = BomRef('B') + ref_c = BomRef('C') + bom = Bom(dependencies=[ + Dependency(ref_b, provides=[Dependency(ref_c)]), + Dependency(ref_c), + ]) + bom_dependencies = bom.dependencies + merger = BomDependencyGraphFlatMerger(bom) + with merger: + flat = {d.ref.value: d for d in bom.dependencies} + self.assertIn('B', flat) + self.assertIn('C', flat) + self.assertEqual({ref_c}, flat['B'].provides_as_bom_refs()) + self.assertEqual(set(), flat['C'].provides_as_bom_refs()) + self.assertIs(bom_dependencies, bom.dependencies) diff --git a/tests/test_model_dependency.py b/tests/test_model_dependency.py index 77f68b79..df594c06 100644 --- a/tests/test_model_dependency.py +++ b/tests/test_model_dependency.py @@ -41,3 +41,33 @@ def test_sort(self) -> None: sorted_deps = sorted(deps) expected_deps = reorder(deps, expected_order) self.assertEqual(sorted_deps, expected_deps) + + def test_provides_default_empty(self) -> None: + dep = Dependency(ref=BomRef(value='a')) + self.assertEqual(len(dep.provides), 0) + self.assertEqual(dep.provides_as_bom_refs(), set()) + + def test_provides_set_and_retrieved(self) -> None: + ref_b = BomRef(value='B') + ref_c = BomRef(value='C') + dep = Dependency(ref=ref_b, provides=[Dependency(ref=ref_c)]) + self.assertEqual(len(dep.provides), 1) + self.assertEqual(dep.provides_as_bom_refs(), {ref_c}) + + def test_provides_included_in_hash_and_equality(self) -> None: + ref_b = BomRef(value='B') + ref_c = BomRef(value='C') + dep_with = Dependency(ref=ref_b, provides=[Dependency(ref=ref_c)]) + dep_without = Dependency(ref=ref_b) + self.assertNotEqual(dep_with, dep_without) + self.assertNotEqual(hash(dep_with), hash(dep_without)) + + def test_sort_with_provides(self) -> None: + # Deps with different provides should sort deterministically + ref_a = BomRef(value='0b049d09-64c0-4490-a0f5-c84d9aacf857') + ref_b = BomRef(value='be2c6502-7e9a-47db-9a66-e34f729810a3') + dep_a = Dependency(ref=ref_a, provides=[Dependency(ref=ref_b)]) + dep_b = Dependency(ref=ref_b) + sorted_result = sorted([dep_b, dep_a]) + self.assertEqual(sorted_result[0].ref, ref_a) + self.assertEqual(sorted_result[1].ref, ref_b)