diff --git a/cyclonedx/contrib/bom/utils.py b/cyclonedx/contrib/bom/utils.py index 767d465d..d03c608f 100644 --- a/cyclonedx/contrib/bom/utils.py +++ b/cyclonedx/contrib/bom/utils.py @@ -17,13 +17,15 @@ """Bom related utilities""" -__all__ = ['BomRefDiscriminator'] +__all__ = ['BomRefDiscriminator', 'BomDependencyGraphFlatMerger'] from collections.abc import Iterable from itertools import chain from random import random from typing import TYPE_CHECKING, Any +from ...model.dependency import Dependency + if TYPE_CHECKING: # pragma: no cover from ...model.bom import Bom from ...model.bom_ref import BomRef @@ -96,3 +98,73 @@ def from_bom(cls, bom: 'Bom', prefix: str = 'BomRef') -> 'BomRefDiscriminator': map(lambda s: s.bom_ref, bom.services), map(lambda v: v.bom_ref, bom.vulnerabilities) ), prefix) + + +class BomDependencyGraphFlatMerger: + """ + Context‑manager utility that temporarily flattens and merges all + :attr:`cyclonedx.model.bom.Bom.dependencies`. + + When used as a context manager, the :class:`cyclonedx.model.bom.Bom`'s + dependency graph is replaced with a flattened, merged representation + for the duration of the ``with`` block and automatically restored + afterward. + """ + + def __init__(self, bom: 'Bom') -> None: + self._bom = bom + # NOTE: do not use the getter - see `reset()` for reasons. + self._deps = self._bom._dependencies + + def __enter__(self) -> None: + self.flatten_merge() + + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + self.reset() + + def flatten_merge(self) -> None: + """ + Flatten and merge all :attr:`cyclonedx.model.bom.Bom.dependencies`. + + This produces a non‑recursive, merged representation of the entire + dependency graph and assigns it to the Bom. + + .. note:: + The original dependency graph is not modified. A new, flattened + dependency structure is assigned to the Bom. + """ + self._bom.dependencies = self._flatten_merge(self._deps) + + def reset(self) -> None: + """ + Restore the :class:`cyclonedx.model.bom.Bom`'s dependency graph to + its original state. + + .. note:: + This does not modify the dependency graph. It simply reassigns + the original dependency collection back to the Bom. + """ + # NOTE: not using the setter, which would create overhead, + # and - most importantly - this could cause deduplication of an existing malformed set. + # Just access the internal field directly! + self._bom._dependencies = self._deps + + @staticmethod + def _flatten_merge(deps: Iterable[Dependency]) -> Iterable[Dependency]: + flat: dict[BomRef, list[BomRef]] = {} + todos = list(deps) + seen: list[int] = [] + while todos: + todo = todos.pop() + if (todo_id := id(todo)) in seen: + continue + seen.append(todo_id) + ds = flat.setdefault(todo.ref, []) + if todo_deps := todo.dependencies: + ds.extend(d.ref for d in todo_deps) + todos.extend(todo_deps) + return ( + Dependency(br, (Dependency(d) for d in ds)) + for br, ds + in flat.items() + ) diff --git a/tests/test_contrib/test_bom_utils.py b/tests/test_contrib/test_bom_utils.py index 288ec9df..013d7070 100644 --- a/tests/test_contrib/test_bom_utils.py +++ b/tests/test_contrib/test_bom_utils.py @@ -18,8 +18,10 @@ from unittest import TestCase -from cyclonedx.contrib.bom.utils import BomRefDiscriminator +from cyclonedx.contrib.bom.utils import BomDependencyGraphFlatMerger, BomRefDiscriminator +from cyclonedx.model.bom import Bom from cyclonedx.model.bom_ref import BomRef +from cyclonedx.model.dependency import Dependency class TestBomRefDiscriminator(TestCase): @@ -46,3 +48,143 @@ def test_discriminate_and_reset_with(self) -> None: self.assertNotEqual(bomref1.value, bomref2.value, 'should be discriminated') self.assertEqual('djdlkfjdslkf', bomref1.value) self.assertEqual('djdlkfjdslkf', bomref2.value) + + +class TestBomDependencyGraphFlatMerger(TestCase): + + def test_flatten_merge_and_reset_manually(self) -> None: + root_bom_ref = BomRef('root_bom_ref') + component1_bom_ref = BomRef('component1_bom_ref') + component2_bom_ref = BomRef('component2_bom_ref') + component3_bom_ref = BomRef('component3_bom_ref') + component4_bom_ref = BomRef('component4_bom_ref') + component5_bom_ref = BomRef('component5_bom_ref') + bom = Bom(dependencies=[ + Dependency( + root_bom_ref, + dependencies=[ + component1_bom_dep := Dependency( + component1_bom_ref, + dependencies=[ + component2_bom_dep := Dependency( + component2_bom_ref, + dependencies=[ + component3_bom_dep := Dependency(component3_bom_ref), + ] + ), + ] + ), + component2_bom_dep2 := Dependency( + component2_bom_ref, + dependencies=[ + component4_bom_dep := Dependency(component4_bom_ref), + ] + ), + ] + ), + component3_bom_dep2 := Dependency( + component3_bom_ref, + dependencies=[ + component4_bom_dep2 := Dependency(component4_bom_ref), + ] + ), + Dependency(component5_bom_ref, ( + component1_bom_dep, + component2_bom_dep, + component2_bom_dep2, + component3_bom_dep, + component3_bom_dep2, + component4_bom_dep, + component4_bom_dep2, + Dependency(root_bom_ref) + )) + ]) + bom_dependencies = bom.dependencies + merger = BomDependencyGraphFlatMerger(bom) + merger.flatten_merge() + self.assertEqual(6, len(bom.dependencies), 'not expected len()') + self.assertSetEqual({ + Dependency(root_bom_ref, (Dependency(component1_bom_ref), Dependency(component2_bom_ref))), + Dependency(component1_bom_ref, (Dependency(component2_bom_ref), )), + Dependency(component2_bom_ref, (Dependency(component3_bom_ref), Dependency(component4_bom_ref), )), + Dependency(component3_bom_ref, (Dependency(component4_bom_ref), )), + Dependency(component4_bom_ref), + Dependency(component5_bom_ref, ( + Dependency(root_bom_ref), + Dependency(component1_bom_ref), + Dependency(component2_bom_ref), + Dependency(component3_bom_ref), + Dependency(component4_bom_ref), + )), + }, bom.dependencies) + merger.reset() + self.assertIs(bom_dependencies, bom.dependencies) + self.assertSetEqual(bom_dependencies, bom.dependencies) + + def test_flatten_merge_and_reset_with(self) -> None: + root_bom_ref = BomRef('root_bom_ref') + component1_bom_ref = BomRef('component1_bom_ref') + component2_bom_ref = BomRef('component2_bom_ref') + component3_bom_ref = BomRef('component3_bom_ref') + component4_bom_ref = BomRef('component4_bom_ref') + component5_bom_ref = BomRef('component5_bom_ref') + bom = Bom(dependencies=[ + Dependency( + root_bom_ref, + dependencies=[ + component1_bom_dep := Dependency( + component1_bom_ref, + dependencies=[ + component2_bom_dep := Dependency( + component2_bom_ref, + dependencies=[ + component3_bom_dep := Dependency(component3_bom_ref), + ] + ), + ] + ), + component2_bom_dep2 := Dependency( + component2_bom_ref, + dependencies=[ + component4_bom_dep := Dependency(component4_bom_ref), + ] + ), + ] + ), + component3_bom_dep2 := Dependency( + component3_bom_ref, + dependencies=[ + component4_bom_dep2 := Dependency(component4_bom_ref), + ] + ), + Dependency(component5_bom_ref, ( + Dependency(root_bom_ref), + component1_bom_dep, + component2_bom_dep, + component2_bom_dep2, + component3_bom_dep, + component3_bom_dep2, + component4_bom_dep, + component4_bom_dep2, + )) + ]) + bom_dependencies = bom.dependencies + merger = BomDependencyGraphFlatMerger(bom) + with merger: + self.assertEqual(6, len(bom.dependencies), 'not expected len()') + self.assertSetEqual({ + Dependency(root_bom_ref, (Dependency(component1_bom_ref), Dependency(component2_bom_ref))), + Dependency(component1_bom_ref, (Dependency(component2_bom_ref), )), + Dependency(component2_bom_ref, (Dependency(component3_bom_ref), Dependency(component4_bom_ref), )), + Dependency(component3_bom_ref, (Dependency(component4_bom_ref), )), + Dependency(component4_bom_ref), + Dependency(component5_bom_ref, ( + Dependency(root_bom_ref), + Dependency(component1_bom_ref), + Dependency(component2_bom_ref), + Dependency(component3_bom_ref), + Dependency(component4_bom_ref), + )), + }, bom.dependencies) + self.assertIs(bom_dependencies, bom.dependencies) + self.assertSetEqual(bom_dependencies, bom.dependencies)