Skip to content
Open
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
49 changes: 28 additions & 21 deletions cyclonedx/model/bom.py
Original file line number Diff line number Diff line change
Expand Up @@ -805,7 +805,7 @@ def urn(self) -> str:
# idea: have 'serial_number' be a string, and use it instead of this method
return f'{_BOM_LINK_PREFIX}{self.serial_number}/{self.version}'

def validate(self) -> bool:
def validate(self) -> bool: # noqa: C901
"""
Perform data-model level validations to make sure we have some known data integrity prior to attempting output
of this `Bom`
Expand All @@ -818,20 +818,28 @@ def validate(self) -> bool:
"""
# !! deprecated function. have this as an part of the normalization process, like the BomRefDiscrimator
# 0. Make sure all Dependable have a Dependency entry
if self.metadata.component:
self.register_dependency(target=self.metadata.component)
# Cache and track existing refs to avoid redundant linear scans in dependencies
existing_refs = {d.ref for d in self.dependencies}

if self.metadata.component and self.metadata.component.bom_ref not in existing_refs:
self._dependencies.add(Dependency(ref=self.metadata.component.bom_ref))
existing_refs.add(self.metadata.component.bom_ref)
for _c in self.components:
self.register_dependency(target=_c)
if _c.bom_ref not in existing_refs:
self._dependencies.add(Dependency(ref=_c.bom_ref))
existing_refs.add(_c.bom_ref)
for _s in self.services:
self.register_dependency(target=_s)
if _s.bom_ref not in existing_refs:
self._dependencies.add(Dependency(ref=_s.bom_ref))
existing_refs.add(_s.bom_ref)

# 1. Make sure dependencies 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)
))
component_bom_refs = {c.bom_ref for c in self._get_all_components()}
component_bom_refs.update(s.bom_ref for s in self.services)
dependency_bom_refs = set()
for d in self.dependencies:
dependency_bom_refs.add(d.ref)
dependency_bom_refs.update(d.dependencies_as_bom_refs())
dependency_diff = dependency_bom_refs - component_bom_refs
if len(dependency_diff) > 0:
raise UnknownComponentDependencyException(
Expand All @@ -840,16 +848,15 @@ def validate(self) -> bool:

# 2. if root component is set and there are other components: dependencies should exist for the Component
# this BOM is describing
if self.metadata.component and len(self.components) > 0 and not any(map(
lambda d: d.ref == self.metadata.component.bom_ref and len(d.dependencies) > 0, # type:ignore[union-attr]
self.dependencies
)):
warn(
f'The Component this BOM is describing {self.metadata.component.purl} has no defined dependencies '
'which means the Dependency Graph is incomplete - you should add direct dependencies to this '
'"root" Component to complete the Dependency Graph data.',
category=UserWarning, stacklevel=1
)
if self.metadata.component and self.components:
has_deps = any(d.ref == self.metadata.component.bom_ref and d.dependencies for d in self.dependencies)
if not has_deps:
warn(
f'The Component this BOM is describing {self.metadata.component.purl} has no defined dependencies '
'which means the Dependency Graph is incomplete - you should add direct dependencies to this '
'"root" Component to complete the Dependency Graph data.',
category=UserWarning, stacklevel=1
)

# 3. If a LicenseExpression is set, then there must be no other license.
# see https://github.com/CycloneDX/specification/pull/205
Expand Down
15 changes: 15 additions & 0 deletions tests/test_model_bom.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from cyclonedx.model.contact import OrganizationalContact, OrganizationalEntity
from cyclonedx.model.license import DisjunctiveLicense
from cyclonedx.model.lifecycle import LifecyclePhase, NamedLifecycle, PredefinedLifecycle
from cyclonedx.model.service import Service
from cyclonedx.model.tool import Tool
from cyclonedx.output.json import JsonV1Dot7
from tests import reorder
Expand Down Expand Up @@ -269,6 +270,20 @@ def test_validate_with_invalid_license_constellation_throws(self, get_bom: Calla
with self.assertRaises(LicenseExpressionAlongWithOthersException):
bom.validate()

def test_validate_registers_dependencies_for_components_and_services(self) -> None:
c1 = Component(name='c1', bom_ref='ref1')
c2 = Component(name='c2', bom_ref='ref2')
s1 = Service(name='s1', bom_ref='ref3')
s2 = Service(name='s2', bom_ref='ref2') # shares ref with c2
bom = Bom()
bom.components.update([c1, c2])
bom.services.update([s1, s2])
self.assertEqual(len(bom.dependencies), 0)
self.assertTrue(bom.validate())
dep_refs = {d.ref.value for d in bom.dependencies}
self.assertEqual(dep_refs, {'ref1', 'ref2', 'ref3'})
self.assertEqual(len(bom.dependencies), 3)

# def test_bom_nested_services_issue_275(self) -> None:
# """regression test for issue #275
# see https://github.com/CycloneDX/cyclonedx-python-lib/issues/275
Expand Down