Skip to content

Commit 1fd7fee

Browse files
authored
fix: model.BomRef no longer equal to unset peers (#543)
fixes [#539](#539) --------- Signed-off-by: Jan Kowalleck <[email protected]>
1 parent 52ef01c commit 1fd7fee

File tree

7 files changed

+156
-16
lines changed

7 files changed

+156
-16
lines changed

.flake8

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,7 @@ ignore =
1818
ANN101,ANN102
1919
# ignore ANN401 for dynamically typed *args and **kwargs
2020
ANN401
21+
# See https://www.flake8rules.com/rules/W503.html
22+
# > Despite being in the best practice section, this will soon be considered an anti-pattern.
23+
# So lets ignore this "suggestion" that is actually an anti-pattern already!
24+
W503

cyclonedx/model/bom_ref.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class BomRef:
2323
"""
2424
An identifier that can be used to reference objects elsewhere in the BOM.
2525
26-
This copies a similar pattern used in the CycloneDX Python Library.
26+
This copies a similar pattern used in the CycloneDX PHP Library.
2727
2828
.. note::
2929
See https://github.com/CycloneDX/cyclonedx-php-library/blob/master/docs/dev/decisions/BomDependencyDataModel.md
@@ -38,23 +38,29 @@ def value(self) -> Optional[str]:
3838

3939
@value.setter
4040
def value(self, value: Optional[str]) -> None:
41+
# empty strings become `None`
4142
self._value = value or None
4243

4344
def __eq__(self, other: object) -> bool:
44-
if isinstance(other, BomRef):
45-
return other._value == self._value
46-
return False
45+
return (self is other) or (
46+
isinstance(other, BomRef)
47+
# `None` value is not discriminative in this domain
48+
# see also: `BomRefDiscriminator`
49+
and other._value is not None
50+
and self._value is not None
51+
and other._value == self._value
52+
)
4753

4854
def __lt__(self, other: Any) -> bool:
4955
if isinstance(other, BomRef):
5056
return str(self) < str(other)
5157
return NotImplemented
5258

5359
def __hash__(self) -> int:
54-
return hash(str(self))
60+
return hash(self._value or f'__id__{id(self)}')
5561

5662
def __repr__(self) -> str:
57-
return f'<BomRef {self._value!r}>'
63+
return f'<BomRef {self._value!r} id={id(self)}>'
5864

5965
def __str__(self) -> str:
6066
return self._value or ''

cyclonedx/model/component.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1438,5 +1438,5 @@ def __hash__(self) -> int:
14381438
))
14391439

14401440
def __repr__(self) -> str:
1441-
return f'<Component bom-ref={self.bom_ref}, group={self.group}, name={self.name}, ' \
1441+
return f'<Component bom-ref={self.bom_ref!r}, group={self.group}, name={self.name}, ' \
14421442
f'version={self.version}, type={self.type}>'

cyclonedx/model/dependency.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ class _DependencyRepositorySerializationHelper(serializable.helpers.BaseHelper):
3333

3434
@classmethod
3535
def serialize(cls, o: Any) -> List[str]:
36-
if isinstance(o, SortedSet):
37-
return list(map(lambda i: str(i.ref), o))
36+
if isinstance(o, (SortedSet, set)):
37+
return [str(i.ref) for i in o]
3838
raise SerializationOfUnexpectedValueException(
3939
f'Attempt to serialize a non-DependencyRepository: {o!r}')
4040

@@ -102,7 +102,7 @@ def __hash__(self) -> int:
102102
return hash((self.ref, tuple(self.dependencies)))
103103

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

107107

108108
class Dependable(ABC):

tests/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
from sortedcontainers import SortedSet
2525

26+
from cyclonedx.output import BomRefDiscriminator as _BomRefDiscriminator
2627
from cyclonedx.schema import OutputFormat, SchemaVersion
2728

2829
if TYPE_CHECKING:
@@ -145,6 +146,14 @@ def uuid_generator(offset: int = 0, version: int = 4) -> Generator[UUID, None, N
145146
yield UUID(int=v, version=version)
146147

147148

149+
class BomRefDiscriminator(_BomRefDiscriminator):
150+
__uiter = 0
151+
152+
def _make_unique(self) -> str:
153+
self.__uiter += 1
154+
return f'TESTING_{self._prefix}{self.__uiter}'
155+
156+
148157
_SNAME_EXT = {
149158
OutputFormat.JSON: 'json',
150159
OutputFormat.XML: 'xml',

tests/test_model_bom.py

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

1818

19-
from typing import Callable
19+
from typing import Callable, Tuple
2020
from unittest import TestCase
2121
from uuid import uuid4
2222

@@ -209,3 +209,79 @@ def test_get_component_by_purl(self) -> None:
209209

210210
self.assertIs(result, setuptools_simple)
211211
self.assertIsNone(bom.get_component_by_purl(get_component_setuptools_simple_no_version().purl))
212+
213+
@named_data(
214+
('none', tuple()),
215+
# a = anonymous - bom-ref auto-set
216+
# k = known - has bom-ref.value set
217+
# d = known duplicate - has bom-ref.value set same as another
218+
('A(a), B(a)', ((Component(name='A'), tuple()),
219+
(Component(name='B'), tuple()))),
220+
('A(k), B(k)', ((Component(name='A', bom_ref='A'), tuple()),
221+
(Component(name='B', bom_ref='B'), tuple()))),
222+
('A(a) {A1(a)}, B(a) {B1(a)}', ((Component(name='A'), (Component(name='A1'),)),
223+
(Component(name='B'), (Component(name='B1'),)))),
224+
('A(k) {A1(a)}', ((Component(name='A', bom_ref='A'), (Component(name='1'),)),)),
225+
('A(a) {A1(a), A2(a)}', ((Component(name='A'), (Component(name='A1'), Component(name='A2'))),)),
226+
('A(a) {A1(k)}', ((Component(name='A'), (Component(name='B', bom_ref='A1'),)),)),
227+
('A(k) {A1(k)}', ((Component(name='A', bom_ref='A'), (Component(name='A1', bom_ref='A1'),)),)),
228+
('A(d) {A1(d)}', ((Component(name='A', bom_ref='D'), (Component(name='B', bom_ref='D'),)),)),
229+
('duplicate name(a)', ((Component(name='A'), tuple()),
230+
(Component(name='A'), tuple()),)),
231+
('duplicate name(k)', ((Component(name='A', bom_ref='A1'), tuple()),
232+
(Component(name='A', bom_ref='A2'), tuple()))),
233+
)
234+
def test_register_dependency(self, dependencies: Tuple[Tuple[Component, Tuple[Component, ...]], ...]) -> None:
235+
bom = Bom()
236+
for d1, d2 in dependencies:
237+
bom.components.update((d1,), d2)
238+
bom.register_dependency(d1, d2)
239+
bom_deps = tuple(bom.dependencies)
240+
for d1, d2 in dependencies:
241+
bom_dep = next((bd for bd in bom_deps if bd.ref is d1.bom_ref), None)
242+
self.assertIsNotNone(bom_dep, f'missing {d1.bom_ref!r} in {bom_deps!r}')
243+
self.assertEqual(len(d2), len(bom_dep.dependencies))
244+
for dd in d2:
245+
self.assertIn(dd.bom_ref, bom_dep.dependencies_as_bom_refs())
246+
247+
def test_regression_issue_539(self) -> None:
248+
"""regression test for issue #539
249+
see https://github.com/CycloneDX/cyclonedx-python-lib/issues/539
250+
"""
251+
# for showcasing purposes, bom-ref values MUST NOT be set
252+
bom = Bom()
253+
bom.metadata.component = root_component = Component(
254+
name='myApp',
255+
type=ComponentType.APPLICATION,
256+
)
257+
component1 = Component(
258+
type=ComponentType.LIBRARY,
259+
name='some-component',
260+
)
261+
component2 = Component(
262+
type=ComponentType.LIBRARY,
263+
name='some-library',
264+
)
265+
component3 = Component(
266+
type=ComponentType.LIBRARY,
267+
name='another-library',
268+
)
269+
bom.components.add(component1)
270+
bom.components.add(component2)
271+
bom.components.add(component3)
272+
bom.register_dependency(root_component, [component1])
273+
bom.register_dependency(component1, [component2])
274+
bom.register_dependency(root_component, [component3])
275+
# region assert root_component
276+
d = next((d for d in bom.dependencies if d.ref is root_component.bom_ref), None)
277+
self.assertIsNotNone(d, f'missing {root_component.bom_ref!r} in {bom.dependencies!r}')
278+
self.assertEqual(2, len(d.dependencies))
279+
self.assertIn(d.dependencies[0].ref, (component1.bom_ref, component3.bom_ref))
280+
self.assertIn(d.dependencies[1].ref, (component1.bom_ref, component3.bom_ref))
281+
# endregion assert root_component
282+
# region assert component1
283+
d = next((d for d in bom.dependencies if d.ref is component1.bom_ref), None)
284+
self.assertIsNotNone(d, f'missing {component1.bom_ref!r} in {bom.dependencies!r}')
285+
self.assertEqual(1, len(d.dependencies))
286+
self.assertIs(component2.bom_ref, d.dependencies[0].ref)
287+
# endregion assert component1

tests/test_model_bom_ref.py

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,67 @@
1717

1818
from unittest import TestCase
1919

20+
from ddt import ddt, named_data
21+
2022
from cyclonedx.model.bom_ref import BomRef
2123
from tests import reorder
2224

25+
_BomRefNoneValue = BomRef(None)
26+
2327

28+
@ddt
2429
class TestBomRef(TestCase):
2530

2631
def test_sort(self) -> None:
2732
# expected sort order: (value)
2833
expected_order = [0, 1, 2, 4, 3]
2934
refs = [
30-
BomRef(value='a'),
31-
BomRef(value='b'),
32-
BomRef(value='c'),
33-
BomRef(value='f'),
34-
BomRef(value='d'),
35+
BomRef('a'),
36+
BomRef('b'),
37+
BomRef('c'),
38+
BomRef('f'),
39+
BomRef('d'),
3540
]
3641
sorted_refs = sorted(refs)
3742
expected_refs = reorder(refs, expected_order)
3843
self.assertListEqual(sorted_refs, expected_refs)
44+
45+
@named_data(
46+
('A-A', BomRef('A'), BomRef('A')),
47+
('self-BomRefNoneValue', _BomRefNoneValue, _BomRefNoneValue),
48+
)
49+
def test_equal(self, a: BomRef, b: BomRef) -> None:
50+
self.assertEqual(a, b)
51+
52+
@named_data(
53+
('other-BomRefNoneValue', BomRef(None), _BomRefNoneValue),
54+
('None-None', BomRef(None), BomRef(None)),
55+
('X-None', BomRef('X'), BomRef(None)),
56+
('None-X', BomRef(None), BomRef('X')),
57+
('A-B', BomRef('A'), BomRef('B')),
58+
)
59+
def test_unequal(self, a: BomRef, b: BomRef) -> None:
60+
self.assertNotEqual(a, b)
61+
62+
@named_data(
63+
('A-A', BomRef('A'), BomRef('A')),
64+
('self-BomRefNoneValue', _BomRefNoneValue, _BomRefNoneValue),
65+
)
66+
def test_hashes_equal(self, a: BomRef, b: BomRef) -> None:
67+
self.assertEqual(hash(a), hash(b))
68+
# internal usage of hash
69+
self.assertEqual(1, len({a, b})) # set
70+
self.assertEqual(1, len({a: 1, b: 2})) # dict
71+
72+
@named_data(
73+
('other-BomRefNoneValue', BomRef(None), _BomRefNoneValue),
74+
('None-None', BomRef(), BomRef()),
75+
('X-None', BomRef('X'), BomRef()),
76+
('None-X', BomRef(), BomRef('X')),
77+
('A-B', BomRef('A'), BomRef('B')),
78+
)
79+
def test_hashes_differ(self, a: BomRef, b: BomRef) -> None:
80+
self.assertNotEqual(hash(a), hash(b))
81+
# internal usage of hash
82+
self.assertEqual(2, len({a, b})) # set
83+
self.assertEqual(2, len({a: 1, b: 2})) # dict

0 commit comments

Comments
 (0)