Skip to content

Commit 745d773

Browse files
committed
feat: refactor Bom.validate() to ModelValidator...
Signed-off-by: Saquib Saifee <saquibsaifee2@gmail.com>
1 parent f311b61 commit 745d773

3 files changed

Lines changed: 157 additions & 46 deletions

File tree

cyclonedx/model/bom.py

Lines changed: 10 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -815,56 +815,24 @@ def validate(self) -> bool:
815815
`bool`
816816
817817
.. deprecated:: next
818-
Deprecated without any replacement.
818+
Use :class:`cyclonedx.validation.model.ModelValidator` instead.
819819
"""
820-
# !! deprecated function. have this as an part of the normalization process, like the BomRefDiscrimator
821-
# 0. Make sure all Dependable have a Dependency entry
820+
from ..validation.model import ModelValidator
821+
warn('`Bom.validate()` is deprecated. Use `cyclonedx.validation.model.ModelValidator` instead.',
822+
category=DeprecationWarning, stacklevel=2)
823+
824+
# Maintain backward compatibility: perform side effects (normalization)
822825
if self.metadata.component:
823826
self.register_dependency(target=self.metadata.component)
824827
for _c in self.components:
825828
self.register_dependency(target=_c)
826829
for _s in self.services:
827830
self.register_dependency(target=_s)
828831

829-
# 1. Make sure dependencies are all in this Bom.
830-
component_bom_refs = set(map(lambda c: c.bom_ref, self._get_all_components())) | set(
831-
map(lambda s: s.bom_ref, self.services))
832-
dependency_bom_refs = set(chain(
833-
(d.ref for d in self.dependencies),
834-
chain.from_iterable(d.dependencies_as_bom_refs() for d in self.dependencies)
835-
))
836-
dependency_diff = dependency_bom_refs - component_bom_refs
837-
if len(dependency_diff) > 0:
838-
raise UnknownComponentDependencyException(
839-
'One or more Components have Dependency references to Components/Services that are not known in this '
840-
f'BOM. They are: {dependency_diff}')
841-
842-
# 2. if root component is set and there are other components: dependencies should exist for the Component
843-
# this BOM is describing
844-
if self.metadata.component and len(self.components) > 0 and not any(map(
845-
lambda d: d.ref == self.metadata.component.bom_ref and len(d.dependencies) > 0, # type:ignore[union-attr]
846-
self.dependencies
847-
)):
848-
warn(
849-
f'The Component this BOM is describing {self.metadata.component.purl} has no defined dependencies '
850-
'which means the Dependency Graph is incomplete - you should add direct dependencies to this '
851-
'"root" Component to complete the Dependency Graph data.',
852-
category=UserWarning, stacklevel=1
853-
)
854-
855-
# 3. If a LicenseExpression is set, then there must be no other license.
856-
# see https://github.com/CycloneDX/specification/pull/205
857-
elem: Union[BomMetaData, Component, Service]
858-
for elem in chain( # type:ignore[assignment]
859-
[self.metadata],
860-
self.metadata.component.get_all_nested_components(include_self=True) if self.metadata.component else [],
861-
chain.from_iterable(c.get_all_nested_components(include_self=True) for c in self.components),
862-
self.services
863-
):
864-
if len(elem.licenses) > 1 and any(isinstance(li, LicenseExpression) for li in elem.licenses):
865-
raise LicenseExpressionAlongWithOthersException(
866-
f'Found LicenseExpression along with others licenses in: {elem!r}')
867-
832+
errors = ModelValidator().validate(self)
833+
first_error = next(iter(errors), None)
834+
if first_error:
835+
raise first_error.data
868836
return True
869837

870838
def __comparable_tuple(self) -> _ComparableTuple:

cyclonedx/validation/model.py

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,74 @@
1616
# Copyright (c) OWASP Foundation. All Rights Reserved.
1717

1818

19-
# nothing here, yet.
20-
# in the future this could be the place where model validation is done.
21-
# like the current `model.bom.Bom.validate()`
22-
# see also: https://github.com/CycloneDX/cyclonedx-python-lib/issues/455
19+
__all__ = ['ModelValidator', 'ModelValidationError']
20+
21+
import warnings
22+
from collections.abc import Iterable
23+
from itertools import chain
24+
from typing import TYPE_CHECKING, Set, Union
25+
26+
from ..exception.model import LicenseExpressionAlongWithOthersException, UnknownComponentDependencyException
27+
from . import ValidationError
28+
29+
# REMOVED: from ..model.license import LicenseExpression
30+
31+
if TYPE_CHECKING: # pragma: no cover
32+
from ..model.bom import Bom, BomMetaData
33+
from ..model.component import Component
34+
from ..model.service import Service
35+
36+
37+
class ModelValidationError(ValidationError):
38+
"""Validation failed with this specific error.
39+
40+
Use :attr:`~data` to access the content.
41+
"""
42+
pass
43+
44+
45+
class ModelValidator:
46+
"""Perform data-model level validations to make sure we have some known data integrity."""
47+
48+
def validate(self, bom: 'Bom') -> Iterable[ModelValidationError]:
49+
"""
50+
Perform data-model level validations to make sure we have some known data integrity
51+
prior to attempting output of a `Bom`.
52+
53+
:param bom: The `Bom` to validate.
54+
:return: An iterable of `ModelValidationError` if any issues are found.
55+
"""
56+
# 1. Make sure dependencies are all in this Bom.
57+
all_components: set['Component'] = set(chain.from_iterable(
58+
c.get_all_nested_components(include_self=True) for c in bom.components))
59+
if bom.metadata.component:
60+
all_components.add(bom.metadata.component)
61+
62+
all_dependable_bom_refs = {e.bom_ref for e in chain(all_components, bom.services)}
63+
all_dependency_bom_refs = set(chain.from_iterable(d.dependencies_as_bom_refs() for d in bom.dependencies))
64+
dependency_diff = all_dependency_bom_refs - all_dependable_bom_refs
65+
if len(dependency_diff) > 0:
66+
yield ModelValidationError(UnknownComponentDependencyException(
67+
'One or more Components have Dependency references to Components/Services that are not known in this '
68+
f'BOM. They are: {dependency_diff}'))
69+
70+
# 2. if root component is set: dependencies should exist for the Component this BOM is describing
71+
meta_bom_ref = bom.metadata.component.bom_ref if bom.metadata.component else None
72+
if meta_bom_ref and len(bom.components) > 0 and not any(
73+
len(d.dependencies) > 0 for d in bom.dependencies if d.ref == meta_bom_ref
74+
):
75+
warnings.warn(
76+
f'The Component this BOM is describing {bom.metadata.component.purl} has no defined dependencies '
77+
'which means the Dependency Graph is incomplete - you should add direct dependencies to this '
78+
'"root" Component to complete the Dependency Graph data.',
79+
category=UserWarning, stacklevel=2
80+
)
81+
82+
# 3. If a LicenseExpression is set, then there must be no other license.
83+
# see https://github.com/CycloneDX/specification/pull/205
84+
from ..model.license import LicenseExpression
85+
elem: Union['BomMetaData', 'Component', 'Service']
86+
for elem in chain([bom.metadata], all_components, bom.services): # type: ignore[assignment]
87+
if len(elem.licenses) > 1 and any(isinstance(li, LicenseExpression) for li in elem.licenses):
88+
yield ModelValidationError(LicenseExpressionAlongWithOthersException(
89+
f'Found LicenseExpression along with others licenses in: {elem!r}'))

tests/test_validation_model.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# This file is part of CycloneDX Python Library
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
# SPDX-License-Identifier: Apache-2.0
16+
# Copyright (c) OWASP Foundation. All Rights Reserved.
17+
18+
from unittest import TestCase
19+
20+
from cyclonedx.exception.model import LicenseExpressionAlongWithOthersException, UnknownComponentDependencyException
21+
from cyclonedx.model.bom import Bom
22+
from cyclonedx.model.component import Component
23+
from cyclonedx.model.dependency import Dependency
24+
from cyclonedx.model.license import DisjunctiveLicense, LicenseExpression
25+
from cyclonedx.validation.model import ModelValidator
26+
27+
28+
class TestModelValidator(TestCase):
29+
def test_validate_multiple_errors(self) -> None:
30+
bom = Bom()
31+
# Error 1: Component with multiple licenses including expression
32+
comp = Component(name='test', version='1.0', bom_ref='test-comp')
33+
comp.licenses.update([
34+
DisjunctiveLicense(id='MIT'),
35+
LicenseExpression(value='Apache-2.0 OR MIT')
36+
])
37+
bom.components.add(comp)
38+
39+
# Error 2: Unknown dependency reference
40+
bom.dependencies.add(Dependency('test-comp', dependencies=[Dependency('non-existent-ref')]))
41+
42+
validator = ModelValidator()
43+
errors = list(validator.validate(bom))
44+
45+
self.assertEqual(len(errors), 2)
46+
error_types = [type(e.data) for e in errors]
47+
self.assertIn(UnknownComponentDependencyException, error_types)
48+
self.assertIn(LicenseExpressionAlongWithOthersException, error_types)
49+
50+
def test_validate_clean_bom(self) -> None:
51+
bom = Bom()
52+
bom.metadata.component = Component(name='root', version='1.0', bom_ref='root')
53+
validator = ModelValidator()
54+
errors = list(validator.validate(bom))
55+
self.assertEqual(len(errors), 0)
56+
57+
def test_bom_validate_deprecated_behavior(self) -> None:
58+
bom = Bom()
59+
bom.metadata.component = Component(name='root', version='1.0', bom_ref='root')
60+
61+
# Verify side effect: register_dependency is called by Bom.validate
62+
self.assertEqual(len(bom.dependencies), 0)
63+
with self.assertWarns(DeprecationWarning):
64+
bom.validate()
65+
self.assertEqual(len(bom.dependencies), 1)
66+
self.assertEqual(next(iter(bom.dependencies)).ref.value, 'root')
67+
68+
def test_model_validator_no_side_effects(self) -> None:
69+
bom = Bom()
70+
bom.metadata.component = Component(name='root', version='1.0', bom_ref='root')
71+
72+
# Verify NO side effect: ModelValidator should not call register_dependency
73+
self.assertEqual(len(bom.dependencies), 0)
74+
validator = ModelValidator()
75+
list(validator.validate(bom))
76+
self.assertEqual(len(bom.dependencies), 0)

0 commit comments

Comments
 (0)