Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 73 additions & 1 deletion cyclonedx/contrib/bom/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
jkowalleck marked this conversation as resolved.
Expand Down Expand Up @@ -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)
Comment thread
jkowalleck marked this conversation as resolved.
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()
)
144 changes: 143 additions & 1 deletion tests/test_contrib/test_bom_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
Loading