Skip to content

Commit 8df488c

Browse files
jkowalleckmadpah
andauthored
fix: properly sort components based on all properties (#599)
reverts #587 - as this one introduced errors fixes #598 fixes #586 --------- Signed-off-by: Jan Kowalleck <[email protected]> Signed-off-by: Paul Horton <[email protected]> Co-authored-by: Paul Horton <[email protected]>
1 parent 25ea611 commit 8df488c

27 files changed

+835
-23
lines changed

.github/workflows/python.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ jobs:
115115
strategy:
116116
fail-fast: false
117117
matrix:
118-
os: ['ubuntu-latest', 'windows-latest', 'macos-latest']
118+
os: ['ubuntu-latest', 'windows-latest', 'macos-13']
119119
python-version:
120120
- "3.12" # highest supported
121121
- "3.11"

cyclonedx/_internal/compare.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@
1919
Everything might change without any notice.
2020
"""
2121

22-
2322
from itertools import zip_longest
24-
from typing import Any, Optional, Tuple
23+
from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple
24+
25+
if TYPE_CHECKING: # pragma: no cover
26+
from packageurl import PackageURL
2527

2628

2729
class ComparableTuple(Tuple[Optional[Any], ...]):
@@ -52,3 +54,42 @@ def __gt__(self, other: Any) -> bool:
5254
return False
5355
return True if s > o else False
5456
return False
57+
58+
59+
class ComparableDict:
60+
"""
61+
Allows comparison of dictionaries, allowing for missing/None values.
62+
"""
63+
64+
def __init__(self, dict_: Dict[Any, Any]) -> None:
65+
self._dict = dict_
66+
67+
def __lt__(self, other: Any) -> bool:
68+
if not isinstance(other, ComparableDict):
69+
return True
70+
keys = sorted(self._dict.keys() | other._dict.keys())
71+
return ComparableTuple(self._dict.get(k) for k in keys) \
72+
< ComparableTuple(other._dict.get(k) for k in keys)
73+
74+
def __gt__(self, other: Any) -> bool:
75+
if not isinstance(other, ComparableDict):
76+
return False
77+
keys = sorted(self._dict.keys() | other._dict.keys())
78+
return ComparableTuple(self._dict.get(k) for k in keys) \
79+
> ComparableTuple(other._dict.get(k) for k in keys)
80+
81+
82+
class ComparablePackageURL(ComparableTuple):
83+
"""
84+
Allows comparison of PackageURL, allowing for qualifiers.
85+
"""
86+
87+
def __new__(cls, purl: 'PackageURL') -> 'ComparablePackageURL':
88+
return super().__new__(
89+
ComparablePackageURL, (
90+
purl.type,
91+
purl.namespace,
92+
purl.version,
93+
ComparableDict(purl.qualifiers) if isinstance(purl.qualifiers, dict) else purl.qualifiers,
94+
purl.subpath
95+
))

cyclonedx/model/component.py

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
#
1515
# SPDX-License-Identifier: Apache-2.0
1616
# Copyright (c) OWASP Foundation. All Rights Reserved.
17+
1718
import re
1819
from enum import Enum
1920
from os.path import exists
@@ -25,7 +26,7 @@
2526
from packageurl import PackageURL
2627
from sortedcontainers import SortedSet
2728

28-
from .._internal.compare import ComparableTuple as _ComparableTuple
29+
from .._internal.compare import ComparablePackageURL as _ComparablePackageURL, ComparableTuple as _ComparableTuple
2930
from .._internal.hash import file_sha1sum as _file_sha1sum
3031
from ..exception.model import InvalidOmniBorIdException, InvalidSwhidException, NoPropertiesProvidedException
3132
from ..exception.serialization import (
@@ -42,7 +43,7 @@
4243
SchemaVersion1Dot5,
4344
SchemaVersion1Dot6,
4445
)
45-
from ..serialization import BomRefHelper, LicenseRepositoryHelper, PackageUrl
46+
from ..serialization import BomRefHelper, LicenseRepositoryHelper, PackageUrl as PackageUrlSH
4647
from . import (
4748
AttachedText,
4849
Copyright,
@@ -1406,7 +1407,7 @@ def cpe(self, cpe: Optional[str]) -> None:
14061407
self._cpe = cpe
14071408

14081409
@property
1409-
@serializable.type_mapping(PackageUrl)
1410+
@serializable.type_mapping(PackageUrlSH)
14101411
@serializable.xml_sequence(15)
14111412
def purl(self) -> Optional[PackageURL]:
14121413
"""
@@ -1699,29 +1700,42 @@ def __eq__(self, other: object) -> bool:
16991700
def __lt__(self, other: Any) -> bool:
17001701
if isinstance(other, Component):
17011702
return _ComparableTuple((
1702-
self.type, self.mime_type, self.supplier, self.author, self.publisher, self.group, self.name,
1703-
self.version, self.description, self.scope, _ComparableTuple(self.hashes),
1704-
_ComparableTuple(self.licenses), self.copyright, self.cpe, self.purl, self.swid, self.pedigree,
1703+
self.type, self.group, self.name, self.version,
1704+
self.mime_type, self.supplier, self.author, self.publisher,
1705+
self.description, self.scope, _ComparableTuple(self.hashes),
1706+
_ComparableTuple(self.licenses), self.copyright, self.cpe,
1707+
None if self.purl is None else _ComparablePackageURL(self.purl),
1708+
self.swid, self.pedigree,
17051709
_ComparableTuple(self.external_references), _ComparableTuple(self.properties),
17061710
_ComparableTuple(self.components), self.evidence, self.release_notes, self.modified,
1707-
_ComparableTuple(self.authors), _ComparableTuple(self.omnibor_ids),
1711+
_ComparableTuple(self.authors), _ComparableTuple(self.omnibor_ids), self.manufacturer,
1712+
_ComparableTuple(self.swhids), self.crypto_properties, _ComparableTuple(self.tags)
17081713
)) < _ComparableTuple((
1709-
other.type, other.mime_type, other.supplier, other.author, other.publisher, other.group, other.name,
1710-
other.version, other.description, other.scope, _ComparableTuple(other.hashes),
1711-
_ComparableTuple(other.licenses), other.copyright, other.cpe, other.purl, other.swid, other.pedigree,
1714+
other.type, other.group, other.name, other.version,
1715+
other.mime_type, other.supplier, other.author, other.publisher,
1716+
other.description, other.scope, _ComparableTuple(other.hashes),
1717+
_ComparableTuple(other.licenses), other.copyright, other.cpe,
1718+
None if other.purl is None else _ComparablePackageURL(other.purl),
1719+
other.swid, other.pedigree,
17121720
_ComparableTuple(other.external_references), _ComparableTuple(other.properties),
17131721
_ComparableTuple(other.components), other.evidence, other.release_notes, other.modified,
1714-
_ComparableTuple(other.authors), _ComparableTuple(other.omnibor_ids),
1722+
_ComparableTuple(other.authors), _ComparableTuple(other.omnibor_ids), other.manufacturer,
1723+
_ComparableTuple(other.swhids), other.crypto_properties, _ComparableTuple(other.tags)
17151724
))
17161725
return NotImplemented
17171726

17181727
def __hash__(self) -> int:
17191728
return hash((
1720-
self.type, self.mime_type, self.supplier, self.author, self.publisher, self.group, self.name,
1721-
self.version, self.description, self.scope, tuple(self.hashes), tuple(self.licenses), self.copyright,
1722-
self.cpe, self.purl, self.swid, self.pedigree, tuple(self.external_references), tuple(self.properties),
1723-
tuple(self.components), self.evidence, self.release_notes, self.modified, tuple(self.authors),
1724-
tuple(self.omnibor_ids),
1729+
self.type, self.group, self.name, self.version,
1730+
self.mime_type, self.supplier, self.author, self.publisher,
1731+
self.description, self.scope, tuple(self.hashes),
1732+
tuple(self.licenses), self.copyright, self.cpe,
1733+
self.purl,
1734+
self.swid, self.pedigree,
1735+
tuple(self.external_references), tuple(self.properties),
1736+
tuple(self.components), self.evidence, self.release_notes, self.modified,
1737+
tuple(self.authors), tuple(self.omnibor_ids), self.manufacturer,
1738+
tuple(self.swhids), self.crypto_properties, tuple(self.tags)
17251739
))
17261740

17271741
def __repr__(self) -> str:

tests/_data/models.py

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -954,8 +954,12 @@ def get_bom_with_licenses() -> Bom:
954954
url=XsUri('https://www.apache.org/licenses/LICENSE-2.0.html'),
955955
acknowledgement=LicenseAcknowledgement.CONCLUDED)]),
956956
Component(name='c-with-name', type=ComponentType.LIBRARY, bom_ref='C3',
957-
licenses=[DisjunctiveLicense(name='some commercial license',
958-
text=AttachedText(content='this is a license text'))]),
957+
licenses=[
958+
DisjunctiveLicense(name='some commercial license',
959+
text=AttachedText(content='this is a license text')),
960+
DisjunctiveLicense(name='some additional',
961+
text=AttachedText(content='this is additional license text')),
962+
]),
959963
],
960964
services=[
961965
Service(name='s-with-expression', bom_ref='S1',
@@ -966,8 +970,12 @@ def get_bom_with_licenses() -> Bom:
966970
url=XsUri('https://www.apache.org/licenses/LICENSE-2.0.html'),
967971
acknowledgement=LicenseAcknowledgement.DECLARED)]),
968972
Service(name='s-with-name', bom_ref='S3',
969-
licenses=[DisjunctiveLicense(name='some commercial license',
970-
text=AttachedText(content='this is a license text'))]),
973+
licenses=[
974+
DisjunctiveLicense(name='some commercial license',
975+
text=AttachedText(content='this is a license text')),
976+
DisjunctiveLicense(name='some additional',
977+
text=AttachedText(content='this is additional license text')),
978+
]),
971979
])
972980

973981

@@ -1064,6 +1072,30 @@ def get_bom_for_issue_497_urls() -> Bom:
10641072
])
10651073

10661074

1075+
def get_bom_for_issue_598_multiple_components_with_purl_qualifiers() -> Bom:
1076+
"""regression test for issue #598
1077+
see https://github.com/CycloneDX/cyclonedx-python-lib/issues/598
1078+
"""
1079+
return _make_bom(components=[
1080+
Component(
1081+
name='dummy', version='2.3.5', bom_ref='dummy-a',
1082+
purl=PackageURL(
1083+
type='pypi', namespace=None, name='pathlib2', version='2.3.5', subpath=None,
1084+
qualifiers={}
1085+
)
1086+
),
1087+
Component(
1088+
name='dummy', version='2.3.5', bom_ref='dummy-b',
1089+
purl=PackageURL(
1090+
type='pypi', namespace=None, name='pathlib2', version='2.3.5', subpath=None,
1091+
qualifiers={
1092+
'vcs_url': 'git+https://github.com/jazzband/pathlib2.git@5a6a88db3cc1d08dbc86fbe15edfb69fb5f5a3d6'
1093+
}
1094+
)
1095+
)
1096+
])
1097+
1098+
10671099
def bom_all_same_bomref() -> Tuple[Bom, int]:
10681100
bom = Bom()
10691101
bom.metadata.component = Component(name='root', bom_ref='foo', components=[
@@ -1113,5 +1145,6 @@ def bom_all_same_bomref() -> Tuple[Bom, int]:
11131145
get_bom_with_licenses,
11141146
get_bom_with_multiple_licenses,
11151147
get_bom_for_issue_497_urls,
1148+
get_bom_for_issue_598_multiple_components_with_purl_qualifiers,
11161149
get_bom_with_component_setuptools_with_v16_fields,
11171150
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?xml version="1.0" ?>
2+
<bom xmlns="http://cyclonedx.org/schema/bom/1.0" version="1">
3+
<components>
4+
<component type="library">
5+
<name>dummy</name>
6+
<version>2.3.5</version>
7+
<purl>pkg:pypi/[email protected]?vcs_url=git%2Bhttps://github.com/jazzband/pathlib2.git%405a6a88db3cc1d08dbc86fbe15edfb69fb5f5a3d6</purl>
8+
<modified>false</modified>
9+
</component>
10+
<component type="library">
11+
<name>dummy</name>
12+
<version>2.3.5</version>
13+
<purl>pkg:pypi/[email protected]</purl>
14+
<modified>false</modified>
15+
</component>
16+
</components>
17+
</bom>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?xml version="1.0" ?>
2+
<bom xmlns="http://cyclonedx.org/schema/bom/1.1" serialNumber="urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac" version="1">
3+
<components>
4+
<component type="library" bom-ref="dummy-b">
5+
<name>dummy</name>
6+
<version>2.3.5</version>
7+
<purl>pkg:pypi/[email protected]?vcs_url=git%2Bhttps://github.com/jazzband/pathlib2.git%405a6a88db3cc1d08dbc86fbe15edfb69fb5f5a3d6</purl>
8+
</component>
9+
<component type="library" bom-ref="dummy-a">
10+
<name>dummy</name>
11+
<version>2.3.5</version>
12+
<purl>pkg:pypi/[email protected]</purl>
13+
</component>
14+
</components>
15+
</bom>
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
{
2+
"components": [
3+
{
4+
"bom-ref": "dummy-b",
5+
"name": "dummy",
6+
"purl": "pkg:pypi/[email protected]?vcs_url=git%2Bhttps://github.com/jazzband/pathlib2.git%405a6a88db3cc1d08dbc86fbe15edfb69fb5f5a3d6",
7+
"type": "library",
8+
"version": "2.3.5"
9+
},
10+
{
11+
"bom-ref": "dummy-a",
12+
"name": "dummy",
13+
"purl": "pkg:pypi/[email protected]",
14+
"type": "library",
15+
"version": "2.3.5"
16+
}
17+
],
18+
"dependencies": [
19+
{
20+
"ref": "dummy-a"
21+
},
22+
{
23+
"ref": "dummy-b"
24+
}
25+
],
26+
"metadata": {
27+
"timestamp": "2023-01-07T13:44:32.312678+00:00",
28+
"tools": [
29+
{
30+
"name": "cyclonedx-python-lib",
31+
"vendor": "CycloneDX",
32+
"version": "TESTING"
33+
}
34+
]
35+
},
36+
"serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac",
37+
"version": 1,
38+
"$schema": "http://cyclonedx.org/schema/bom-1.2b.schema.json",
39+
"bomFormat": "CycloneDX",
40+
"specVersion": "1.2"
41+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?xml version="1.0" ?>
2+
<bom xmlns="http://cyclonedx.org/schema/bom/1.2" serialNumber="urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac" version="1">
3+
<metadata>
4+
<timestamp>2023-01-07T13:44:32.312678+00:00</timestamp>
5+
<tools>
6+
<tool>
7+
<vendor>CycloneDX</vendor>
8+
<name>cyclonedx-python-lib</name>
9+
<version>TESTING</version>
10+
</tool>
11+
</tools>
12+
</metadata>
13+
<components>
14+
<component type="library" bom-ref="dummy-b">
15+
<name>dummy</name>
16+
<version>2.3.5</version>
17+
<purl>pkg:pypi/[email protected]?vcs_url=git%2Bhttps://github.com/jazzband/pathlib2.git%405a6a88db3cc1d08dbc86fbe15edfb69fb5f5a3d6</purl>
18+
</component>
19+
<component type="library" bom-ref="dummy-a">
20+
<name>dummy</name>
21+
<version>2.3.5</version>
22+
<purl>pkg:pypi/[email protected]</purl>
23+
</component>
24+
</components>
25+
<dependencies>
26+
<dependency ref="dummy-a"/>
27+
<dependency ref="dummy-b"/>
28+
</dependencies>
29+
</bom>
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
{
2+
"components": [
3+
{
4+
"bom-ref": "dummy-b",
5+
"name": "dummy",
6+
"purl": "pkg:pypi/[email protected]?vcs_url=git%2Bhttps://github.com/jazzband/pathlib2.git%405a6a88db3cc1d08dbc86fbe15edfb69fb5f5a3d6",
7+
"type": "library",
8+
"version": "2.3.5"
9+
},
10+
{
11+
"bom-ref": "dummy-a",
12+
"name": "dummy",
13+
"purl": "pkg:pypi/[email protected]",
14+
"type": "library",
15+
"version": "2.3.5"
16+
}
17+
],
18+
"dependencies": [
19+
{
20+
"ref": "dummy-a"
21+
},
22+
{
23+
"ref": "dummy-b"
24+
}
25+
],
26+
"metadata": {
27+
"timestamp": "2023-01-07T13:44:32.312678+00:00",
28+
"tools": [
29+
{
30+
"name": "cyclonedx-python-lib",
31+
"vendor": "CycloneDX",
32+
"version": "TESTING"
33+
}
34+
]
35+
},
36+
"serialNumber": "urn:uuid:1441d33a-e0fc-45b5-af3b-61ee52a88bac",
37+
"version": 1,
38+
"$schema": "http://cyclonedx.org/schema/bom-1.3a.schema.json",
39+
"bomFormat": "CycloneDX",
40+
"specVersion": "1.3"
41+
}

0 commit comments

Comments
 (0)