Skip to content
11 changes: 7 additions & 4 deletions cyclonedx/contrib/bom/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,20 +154,23 @@ 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:
todo = todos.pop()
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()
)
54 changes: 47 additions & 7 deletions cyclonedx/model/bom.py
Original file line number Diff line number Diff line change
Expand Up @@ -779,23 +779,62 @@ def has_vulnerabilities(self) -> bool:
"""
return bool(self.vulnerabilities)

def register_dependency(self, target: Dependable, depends_on: Optional[Iterable[Dependable]] = None) -> None:
_d = next(filter(lambda _d: _d.ref == target.bom_ref, self.dependencies), None)
if _d:
def register_dependency(
self,
target: Dependable,
depends_on: Optional[Iterable[Dependable]] = None,
provides: Optional[Iterable[Dependable]] = None,
) -> None:
"""
Register a dependency relationship for a target.

Args:
target:
The `Dependable` target instance being registered.
depends_on:
Optional iterable of `Dependable` instances that the target depends on.
provides:
Optional iterable of `Dependable` instances that the target provides (CycloneDX 1.6+).

Returns:
None
"""
depends_on = tuple(depends_on or ())
provides = tuple(provides or ())
_idx, _d = next(
((i, dep) for i, dep in enumerate(self._dependencies) if dep.ref == target.bom_ref),
(None, None),
)
if _d is not None and _idx is not None:
# We remove before mutating dependencies/provides to avoid corrupting SortedSet ordering/hash.
# We delete by index (`del self._dependencies[_idx]`) rather than `discard()`/`remove()`
# because dependencies with unset/empty `BomRef` elements may violate strict weak ordering
# required for binary search lookup in `SortedSet`.
# Note: `SortedSet.__delitem__` is supported at runtime, but sortedcontainers type stubs
# are incomplete for index-based deletion.
del self._dependencies[_idx] # type: ignore[attr-defined]

# 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))
_d.dependencies.update(map(lambda _dep: Dependency(ref=_dep.bom_ref), depends_on))
if provides:
_d.provides.update(map(lambda _prov: Dependency(ref=_prov.bom_ref), provides))
self._dependencies.add(_d)
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:
"""
Expand Down Expand Up @@ -825,12 +864,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:
Expand Down
29 changes: 26 additions & 3 deletions cyclonedx/model/dependency.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

from .._internal.compare import ComparableTuple as _ComparableTuple
from ..exception.serialization import SerializationOfUnexpectedValueException
from ..schema.schema import SchemaVersion1Dot6, SchemaVersion1Dot7
from .bom_ref import BomRef


Expand Down Expand Up @@ -56,9 +57,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)
Expand All @@ -84,9 +91,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:
Expand All @@ -103,7 +126,7 @@ def __hash__(self) -> int:
return hash(self.__comparable_tuple())

def __repr__(self) -> str:
return f'<Dependency ref={self.ref!r}, targets={len(self.dependencies)}>'
return f'<Dependency ref={self.ref!r}, targets={len(self.dependencies)}, provides={len(self.provides)}>'


class Dependable(ABC):
Expand Down
2 changes: 1 addition & 1 deletion docs/schema-support.rst
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ This is a snapshot. See the API docs for the latest state.
+----------------------------+---------------+---------------------------------------------------------------------------------------------------+
| ``bom.externalReferences`` | Yes | |
+----------------------------+---------------+---------------------------------------------------------------------------------------------------+
| ``bom.dependencies`` | Yes | Since ``2.3.0`` |
| ``bom.dependencies`` | Yes | Since ``2.3.0``. ``provides`` supported when outputting to Schema Version >= 1.6. |
+----------------------------+---------------+---------------------------------------------------------------------------------------------------+
| ``bom.compositions`` | No | |
+----------------------------+---------------+---------------------------------------------------------------------------------------------------+
Expand Down
24 changes: 24 additions & 0 deletions tests/_data/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1573,6 +1573,30 @@ 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)],
provides=[Dependency(ref=c1.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()
Expand Down
15 changes: 15 additions & 0 deletions tests/_data/snapshots/get_bom_with_provides-1.0.xml.bin
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?xml version="1.0" ?>
<bom xmlns="http://cyclonedx.org/schema/bom/1.0" version="1">
<components>
<component type="device">
<name>device B</name>
<version/>
<modified>false</modified>
</component>
<component type="device">
<name>device C</name>
<version/>
<modified>false</modified>
</component>
</components>
</bom>
13 changes: 13 additions & 0 deletions tests/_data/snapshots/get_bom_with_provides-1.1.xml.bin
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?xml version="1.0" ?>
<bom xmlns="http://cyclonedx.org/schema/bom/1.1" serialNumber="urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac" version="1">
<components>
<component type="device" bom-ref="B">
<name>device B</name>
<version/>
</component>
<component type="device" bom-ref="C">
<name>device C</name>
<version/>
</component>
</components>
</bom>
44 changes: 44 additions & 0 deletions tests/_data/snapshots/get_bom_with_provides-1.2.json.bin
Original file line number Diff line number Diff line change
@@ -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"
}
27 changes: 27 additions & 0 deletions tests/_data/snapshots/get_bom_with_provides-1.2.xml.bin
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?xml version="1.0" ?>
<bom xmlns="http://cyclonedx.org/schema/bom/1.2" serialNumber="urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac" version="1">
<metadata>
<timestamp>2023-01-07T13:44:32.312678+00:00</timestamp>
<component type="application" bom-ref="A">
<name>app A</name>
<version/>
</component>
</metadata>
<components>
<component type="device" bom-ref="B">
<name>device B</name>
<version/>
</component>
<component type="device" bom-ref="C">
<name>device C</name>
<version/>
</component>
</components>
<dependencies>
<dependency ref="A">
<dependency ref="C"/>
</dependency>
<dependency ref="B"/>
<dependency ref="C"/>
</dependencies>
</bom>
44 changes: 44 additions & 0 deletions tests/_data/snapshots/get_bom_with_provides-1.3.json.bin
Original file line number Diff line number Diff line change
@@ -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"
}
27 changes: 27 additions & 0 deletions tests/_data/snapshots/get_bom_with_provides-1.3.xml.bin
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?xml version="1.0" ?>
<bom xmlns="http://cyclonedx.org/schema/bom/1.3" serialNumber="urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac" version="1">
<metadata>
<timestamp>2023-01-07T13:44:32.312678+00:00</timestamp>
<component type="application" bom-ref="A">
<name>app A</name>
<version/>
</component>
</metadata>
<components>
<component type="device" bom-ref="B">
<name>device B</name>
<version/>
</component>
<component type="device" bom-ref="C">
<name>device C</name>
<version/>
</component>
</components>
<dependencies>
<dependency ref="A">
<dependency ref="C"/>
</dependency>
<dependency ref="B"/>
<dependency ref="C"/>
</dependencies>
</bom>
Loading