diff --git a/cyclonedx/model/bom.py b/cyclonedx/model/bom.py index 5ebb2f95..df3daa56 100644 --- a/cyclonedx/model/bom.py +++ b/cyclonedx/model/bom.py @@ -307,6 +307,11 @@ def __eq__(self, other: object) -> bool: return self.__comparable_tuple() == other.__comparable_tuple() return False + def __lt__(self, other: object) -> bool: + if isinstance(other, BomMetaData): + return self.__comparable_tuple() == other.__comparable_tuple() + return NotImplemented + def __hash__(self) -> int: return hash(self.__comparable_tuple()) diff --git a/cyclonedx/model/component.py b/cyclonedx/model/component.py index 65d2eecf..ada6249a 100644 --- a/cyclonedx/model/component.py +++ b/cyclonedx/model/component.py @@ -654,6 +654,11 @@ def __eq__(self, other: object) -> bool: return self.__comparable_tuple() == other.__comparable_tuple() return False + def __lt__(self, other: object) -> bool: + if isinstance(other, Pedigree): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + def __hash__(self) -> int: return hash(self.__comparable_tuple()) @@ -806,6 +811,11 @@ def __eq__(self, other: object) -> bool: return self.__comparable_tuple() == other.__comparable_tuple() return False + def __lt__(self, other: object) -> bool: + if isinstance(other, Swid): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + def __hash__(self) -> int: return hash(self.__comparable_tuple()) diff --git a/cyclonedx/model/component_evidence.py b/cyclonedx/model/component_evidence.py index e9711f9f..3d54795e 100644 --- a/cyclonedx/model/component_evidence.py +++ b/cyclonedx/model/component_evidence.py @@ -561,6 +561,11 @@ def __eq__(self, other: object) -> bool: return self.__comparable_tuple() == other.__comparable_tuple() return False + def __lt__(self, other: object) -> bool: + if isinstance(other, CallStackFrame): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + def __hash__(self) -> int: return hash(self.__comparable_tuple()) @@ -744,6 +749,11 @@ def __eq__(self, other: object) -> bool: return self.__comparable_tuple() == other.__comparable_tuple() return False + def __lt__(self, other: object) -> bool: + if isinstance(other, ComponentEvidence): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + def __hash__(self) -> int: return hash(self.__comparable_tuple()) diff --git a/cyclonedx/model/crypto.py b/cyclonedx/model/crypto.py index 4a7d1f1a..eeab12a8 100644 --- a/cyclonedx/model/crypto.py +++ b/cyclonedx/model/crypto.py @@ -507,6 +507,11 @@ def __eq__(self, other: object) -> bool: return self.__comparable_tuple() == other.__comparable_tuple() return False + def __lt__(self, other: object) -> bool: + if isinstance(other, AlgorithmProperties): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + def __hash__(self) -> int: return hash(self.__comparable_tuple()) @@ -683,6 +688,11 @@ def __eq__(self, other: object) -> bool: return self.__comparable_tuple() == other.__comparable_tuple() return False + def __lt__(self, other: object) -> bool: + if isinstance(other, CertificateProperties): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + def __hash__(self) -> int: return hash(self.__comparable_tuple()) @@ -810,6 +820,11 @@ def __eq__(self, other: object) -> bool: return self.__comparable_tuple() == other.__comparable_tuple() return False + def __lt__(self, other: object) -> bool: + if isinstance(other, RelatedCryptoMaterialSecuredBy): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + def __hash__(self) -> int: return hash(self.__comparable_tuple()) @@ -1055,6 +1070,11 @@ def __eq__(self, other: object) -> bool: return self.__comparable_tuple() == other.__comparable_tuple() return False + def __lt__(self, other: object) -> bool: + if isinstance(other, RelatedCryptoMaterialProperties): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + def __hash__(self) -> int: return hash(self.__comparable_tuple()) @@ -1314,6 +1334,11 @@ def __eq__(self, other: object) -> bool: return self.__comparable_tuple() == other.__comparable_tuple() return False + def __lt__(self, other: object) -> bool: + if isinstance(other, Ikev2TransformTypes): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + def __hash__(self) -> int: return hash(self.__comparable_tuple()) @@ -1440,6 +1465,11 @@ def __eq__(self, other: object) -> bool: return self.__comparable_tuple() == other.__comparable_tuple() return False + def __lt__(self, other: object) -> bool: + if isinstance(other, ProtocolProperties): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + def __hash__(self) -> int: return hash(self.__comparable_tuple()) diff --git a/cyclonedx/model/release_note.py b/cyclonedx/model/release_note.py index 9f1ff82c..27387f18 100644 --- a/cyclonedx/model/release_note.py +++ b/cyclonedx/model/release_note.py @@ -250,6 +250,11 @@ def __eq__(self, other: object) -> bool: return self.__comparable_tuple() == other.__comparable_tuple() return False + def __lt__(self, other: object) -> bool: + if isinstance(other, ReleaseNotes): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + def __hash__(self) -> int: return hash(self.__comparable_tuple()) diff --git a/cyclonedx/model/tool.py b/cyclonedx/model/tool.py index c547ab92..2d2b7005 100644 --- a/cyclonedx/model/tool.py +++ b/cyclonedx/model/tool.py @@ -266,6 +266,11 @@ def __eq__(self, other: object) -> bool: return self.__comparable_tuple() == other.__comparable_tuple() return False + def __lt__(self, other: object) -> bool: + if isinstance(other, ToolRepository): + return self.__comparable_tuple() < other.__comparable_tuple() + return NotImplemented + def __hash__(self) -> int: return hash(self.__comparable_tuple()) diff --git a/tests/_data/own/json/1.6/pr899.json b/tests/_data/own/json/1.6/pr899.json new file mode 100644 index 00000000..b449dc0d --- /dev/null +++ b/tests/_data/own/json/1.6/pr899.json @@ -0,0 +1,170 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "serialNumber": "urn:uuid:57ef9d7a-89f4-4822-928f-e06e22cacfc3", + "version": 1, + "metadata": { + "timestamp": "2025-09-08T12:45:24Z", + "tools": { + "components": [ + { + "type": "application", + "bom-ref": "pkg:npm/@cyclonedx/cdxgen@11.6.0", + "authors": [ + { + "name": "OWASP Foundation" + } + ], + "publisher": "OWASP Foundation", + "group": "@cyclonedx", + "name": "cdxgen", + "version": "11.6.0", + "purl": "pkg:npm/%40cyclonedx/cdxgen@11.6.0" + } + ] + }, + "component": { + "type": "application", + "bom-ref": "pkg:npm/redacted@1.0.0", + "group": "", + "name": "redacted", + "version": "1.0.0", + "description": "redacted", + "licenses": [ + { + "license": { + "id": "Unlicense", + "url": "https://opensource.org/licenses/Unlicense" + } + } + ], + "purl": "pkg:npm/redacted@1.0.0" + } + }, + "components": [ + { + "type": "application", + "bom-ref": "pkg:npm/redacted@1.0.0", + "group": "", + "name": "redacted", + "version": "1.0.0", + "description": "redacted.", + "licenses": [ + { + "license": { + "id": "Unlicense", + "url": "https://opensource.org/licenses/Unlicense" + } + } + ], + "purl": "pkg:npm/redacted@1.0.0" + }, + { + "type": "library", + "bom-ref": "pkg:npm/jsonwebtoken@9.0.2", + "group": "", + "name": "jsonwebtoken", + "version": "9.0.2", + "scope": "required", + "hashes": [ + { + "alg": "SHA-512", + "content": "3d1a7aeaf27ceb9492a8e960a92f21ba34f953800e80c7e1af0608b8885f29aa12099722aeb980490afc097edc520f9132287e860ce7ae3a7df68f96e2924b1d" + } + ], + "purl": "pkg:npm/jsonwebtoken@9.0.2", + "properties": [ + { + "name": "SrcFile", + "value": "package-lock.json" + }, + { + "name": "ResolvedUrl", + "value": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz" + }, + { + "name": "ImportedModules", + "value": "jsonwebtoken" + } + ], + "evidence": { + "identity": { + "field": "purl", + "confidence": 1, + "concludedValue": "package-lock.json", + "methods": [ + { + "technique": "manifest-analysis", + "confidence": 1, + "value": "package-lock.json" + } + ] + }, + "occurrences": [ + { + "location": "handlers/AuthenticationHandler.js#1" + } + ] + }, + "tags": [ + "registry" + ] + }, + { + "type": "library", + "bom-ref": "pkg:npm/jsonwebtoken@9.0.2", + "group": "", + "name": "jsonwebtoken", + "version": "9.0.2", + "scope": "required", + "hashes": [ + { + "alg": "SHA-512", + "content": "3d1a7aeaf27ceb9492a8e960a92f21ba34f953800e80c7e1af0608b8885f29aa12099722aeb980490afc097edc520f9132287e860ce7ae3a7df68f96e2924b1d" + } + ], + "purl": "pkg:npm/jsonwebtoken@9.0.2", + "properties": [ + { + "name": "SrcFile", + "value": "package-lock.json" + }, + { + "name": "ResolvedUrl", + "value": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz" + }, + { + "name": "ImportedModules", + "value": "jsonwebtoken" + } + ], + "evidence": { + "identity": { + "field": "purl", + "confidence": 1, + "concludedValue": "package-lock.json", + "methods": [ + { + "technique": "manifest-analysis", + "confidence": 1, + "value": "package-lock.json" + } + ] + }, + "occurrences": [ + { + "location": "authorize.js#4" + } + ] + }, + "tags": [ + "registry" + ] + } + ], + "dependencies": [ + ], + "compositions": [ + ] +} diff --git a/tests/test_deserialize_json.py b/tests/test_deserialize_json.py index ff5e7298..958335e6 100644 --- a/tests/test_deserialize_json.py +++ b/tests/test_deserialize_json.py @@ -136,3 +136,15 @@ def test_component_evidence_identity(self) -> None: json = json_loads(f.read()) bom: Bom = Bom.from_json(json) # <<< is expected to not crash self.assertIsNotNone(bom) + + def test_pr899(self) -> None: + """real world case from PR#899 + see https://github.com/CycloneDX/cyclonedx-python-lib/pull/899 + """ + json_file = join(OWN_DATA_DIRECTORY, 'json', + SchemaVersion.V1_6.to_version(), + 'pr899.json') + with open(json_file) as f: + json = json_loads(f.read()) + bom: Bom = Bom.from_json(json) # <<< is expected to not crash + self.assertIsNotNone(bom) diff --git a/tests/test_model_bom.py b/tests/test_model_bom.py index d47e2466..8dd1288b 100644 --- a/tests/test_model_bom.py +++ b/tests/test_model_bom.py @@ -107,6 +107,26 @@ def test_basic_bom_metadata(self) -> None: self.assertTrue(tools[0] in metadata.tools.tools) self.assertTrue(tools[1] in metadata.tools.tools) + def test_bom_metadata_sorting(self) -> None: + """Test that BomMetaData instances can be sorted without triggering TypeError""" + metadata1 = BomMetaData( + tools=[Tool(name='tool_a')], + authors=[OrganizationalContact(name='contact_a')] + ) + metadata2 = BomMetaData( + tools=[Tool(name='tool_b')], + authors=[OrganizationalContact(name='contact_b')] + ) + metadata3 = BomMetaData( + tools=[Tool(name='tool_c')], + authors=[OrganizationalContact(name='contact_c')] + ) + + # This should not raise TypeError: '<' not supported between instances + metadata_list = [metadata3, metadata1, metadata2] + sorted_metadata = sorted(metadata_list) + self.assertEqual(len(sorted_metadata), 3) + @ddt class TestBom(TestCase): diff --git a/tests/test_model_component.py b/tests/test_model_component.py index bc18a10d..9c47fe02 100644 --- a/tests/test_model_component.py +++ b/tests/test_model_component.py @@ -419,6 +419,17 @@ def test_not_same_1(self) -> None: self.assertNotEqual(hash(p1), hash(p2), 'hash') self.assertFalse(p1 == p2, 'equal') + def test_pedigree_sorting(self) -> None: + """Test that Pedigree instances can be sorted without triggering TypeError""" + p1 = Pedigree(notes='Note A') + p2 = Pedigree(notes='Note B') + p3 = Pedigree(notes='Note C') + + # This should not raise TypeError: '<' not supported between instances + pedigree_list = [p3, p1, p2] + sorted_pedigree = sorted(pedigree_list) + self.assertEqual(len(sorted_pedigree), 3) + class TestModelSwid(TestCase): @@ -442,3 +453,14 @@ def test_not_same(self) -> None: self.assertNotEqual(id(sw_1), id(sw_2), 'id') self.assertNotEqual(hash(sw_1), hash(sw_2), 'hash') self.assertFalse(sw_1 == sw_2, 'equal') + + def test_swid_sorting(self) -> None: + """Test that Swid instances can be sorted without triggering TypeError""" + sw_1 = get_swid_1() + sw_2 = get_swid_2() + sw_3 = get_swid_1() + + # This should not raise TypeError: '<' not supported between instances + swid_list = [sw_2, sw_1, sw_3] + sorted_swid = sorted(swid_list) + self.assertEqual(len(sorted_swid), 3) diff --git a/tests/test_model_component_evidence.py b/tests/test_model_component_evidence.py index 2041d28e..fe16aff6 100644 --- a/tests/test_model_component_evidence.py +++ b/tests/test_model_component_evidence.py @@ -201,6 +201,17 @@ def test_not_same_1(self) -> None: self.assertNotEqual(hash(ce_1), hash(ce_2)) self.assertFalse(ce_1 == ce_2) + def test_component_evidence_sorting(self) -> None: + """Test that ComponentEvidence instances can be sorted without triggering TypeError""" + ce_1 = ComponentEvidence(copyright=[Copyright(text='Copyright A')]) + ce_2 = ComponentEvidence(copyright=[Copyright(text='Copyright B')]) + ce_3 = ComponentEvidence(copyright=[Copyright(text='Copyright C')]) + + # This should not raise TypeError: '<' not supported between instances + evidence_list = [ce_3, ce_1, ce_2] + sorted_evidence = sorted(evidence_list) + self.assertEqual(len(sorted_evidence), 3) + class TestModelCallStackFrame(TestCase): @@ -233,3 +244,14 @@ def test_module_required(self) -> None: self.assertIsNone(frame.line) self.assertIsNone(frame.column) self.assertIsNone(frame.full_filename) + + def test_callstack_frame_sorting(self) -> None: + """Test that CallStackFrame instances can be sorted without triggering TypeError""" + frame1 = CallStackFrame(module='app_a', function='func_a') + frame2 = CallStackFrame(module='app_b', function='func_b') + frame3 = CallStackFrame(module='app_c', function='func_c') + + # This should not raise TypeError: '<' not supported between instances + frame_list = [frame3, frame1, frame2] + sorted_frames = sorted(frame_list) + self.assertEqual(len(sorted_frames), 3) diff --git a/tests/test_model_crypto.py b/tests/test_model_crypto.py new file mode 100644 index 00000000..12265ee1 --- /dev/null +++ b/tests/test_model_crypto.py @@ -0,0 +1,136 @@ +# This file is part of CycloneDX Python Library +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) OWASP Foundation. All Rights Reserved. + +from unittest import TestCase + +from cyclonedx.model.bom_ref import BomRef +from cyclonedx.model.crypto import ( + AlgorithmProperties, + CertificateProperties, + CryptoPrimitive, + Ikev2TransformTypes, + ProtocolProperties, + ProtocolPropertiesType, + RelatedCryptoMaterialProperties, + RelatedCryptoMaterialSecuredBy, + RelatedCryptoMaterialType, +) + + +class TestModelAlgorithmProperties(TestCase): + + def test_algorithm_properties_sorting(self) -> None: + """Test that AlgorithmProperties instances can be sorted without triggering TypeError""" + algo1 = AlgorithmProperties(primitive=CryptoPrimitive.HASH, classical_security_level=128) + algo2 = AlgorithmProperties(primitive=CryptoPrimitive.SIGNATURE, classical_security_level=256) + algo3 = AlgorithmProperties(primitive=CryptoPrimitive.BLOCK_CIPHER, classical_security_level=192) + + # This should not raise TypeError: '<' not supported between instances + algo_list = [algo2, algo3, algo1] + sorted_algos = sorted(algo_list) + self.assertEqual(len(sorted_algos), 3) + + +class TestModelCertificateProperties(TestCase): + + def test_certificate_properties_sorting(self) -> None: + """Test that CertificateProperties instances can be sorted without triggering TypeError""" + cert1 = CertificateProperties(subject_name='CN=Test1', certificate_format='X.509') + cert2 = CertificateProperties(subject_name='CN=Test2', certificate_format='PEM') + cert3 = CertificateProperties(subject_name='CN=Test3', certificate_format='DER') + + # This should not raise TypeError: '<' not supported between instances + cert_list = [cert2, cert3, cert1] + sorted_certs = sorted(cert_list) + self.assertEqual(len(sorted_certs), 3) + + +class TestModelRelatedCryptoMaterialSecuredBy(TestCase): + + def test_related_crypto_material_secured_by_sorting(self) -> None: + """Test that RelatedCryptoMaterialSecuredBy instances can be sorted without triggering TypeError""" + secured1 = RelatedCryptoMaterialSecuredBy(mechanism='HSM', algorithm_ref=BomRef('algo1')) + secured2 = RelatedCryptoMaterialSecuredBy(mechanism='TPM', algorithm_ref=BomRef('algo2')) + secured3 = RelatedCryptoMaterialSecuredBy(mechanism='Software', algorithm_ref=BomRef('algo3')) + + # This should not raise TypeError: '<' not supported between instances + secured_list = [secured3, secured1, secured2] + sorted_secured = sorted(secured_list) + self.assertEqual(len(sorted_secured), 3) + + +class TestModelRelatedCryptoMaterialProperties(TestCase): + + def test_related_crypto_material_properties_sorting(self) -> None: + """Test that RelatedCryptoMaterialProperties instances can be sorted without triggering TypeError""" + material1 = RelatedCryptoMaterialProperties( + type=RelatedCryptoMaterialType.KEY, + id='key1', + size=256 + ) + material2 = RelatedCryptoMaterialProperties( + type=RelatedCryptoMaterialType.PRIVATE_KEY, + id='key2', + size=512 + ) + material3 = RelatedCryptoMaterialProperties( + type=RelatedCryptoMaterialType.PUBLIC_KEY, + id='key3', + size=1024 + ) + + # This should not raise TypeError: '<' not supported between instances + material_list = [material3, material1, material2] + sorted_materials = sorted(material_list) + self.assertEqual(len(sorted_materials), 3) + + +class TestModelIkev2TransformTypes(TestCase): + + def test_ikev2_transform_types_sorting(self) -> None: + """Test that Ikev2TransformTypes instances can be sorted without triggering TypeError""" + ikev2_1 = Ikev2TransformTypes( + encr=[BomRef('encr1')], + esn=True + ) + ikev2_2 = Ikev2TransformTypes( + encr=[BomRef('encr2')], + esn=False + ) + ikev2_3 = Ikev2TransformTypes( + encr=[BomRef('encr3')], + esn=True + ) + + # This should not raise TypeError: '<' not supported between instances + ikev2_list = [ikev2_3, ikev2_1, ikev2_2] + sorted_ikev2 = sorted(ikev2_list) + self.assertEqual(len(sorted_ikev2), 3) + + +class TestModelProtocolProperties(TestCase): + + def test_protocol_properties_sorting(self) -> None: + """Test that ProtocolProperties instances can be sorted without triggering TypeError""" + proto1 = ProtocolProperties(type=ProtocolPropertiesType.TLS, version='1.2') + proto2 = ProtocolProperties(type=ProtocolPropertiesType.SSH, version='2.0') + proto3 = ProtocolProperties(type=ProtocolPropertiesType.IPSEC, version='1.0') + + # This should not raise TypeError: '<' not supported between instances + proto_list = [proto3, proto1, proto2] + sorted_protos = sorted(proto_list) + self.assertEqual(len(sorted_protos), 3) diff --git a/tests/test_model_release_note.py b/tests/test_model_release_note.py index 4bf6eca6..bdd1052d 100644 --- a/tests/test_model_release_note.py +++ b/tests/test_model_release_note.py @@ -68,3 +68,14 @@ def test_complete(self) -> None: self.assertSetEqual(rn.resolves, set()) self.assertFalse(rn.notes) self.assertSetEqual(rn.properties, set()) + + def test_release_notes_sorting(self) -> None: + """Test that ReleaseNotes instances can be sorted without triggering TypeError""" + rn1 = ReleaseNotes(type='major', title='Release 1.0') + rn2 = ReleaseNotes(type='minor', title='Release 1.1') + rn3 = ReleaseNotes(type='patch', title='Release 1.1.1') + + # This should not raise TypeError: '<' not supported between instances + rn_list = [rn3, rn1, rn2] + sorted_rn = sorted(rn_list) + self.assertEqual(len(sorted_rn), 3) diff --git a/tests/test_model_tool_repository.py b/tests/test_model_tool_repository.py index fe54c8e6..d50221c6 100644 --- a/tests/test_model_tool_repository.py +++ b/tests/test_model_tool_repository.py @@ -79,3 +79,19 @@ def test_equal(self) -> None: tr2.services.add(s) tr2.tools.add(t) self.assertTrue(tr1 == tr2) + + def test_tool_repository_sorting(self) -> None: + """Test that ToolRepository instances can be sorted without triggering TypeError""" + tr1 = ToolRepository() + tr1.tools.add(Tool(name='tool-a')) + + tr2 = ToolRepository() + tr2.tools.add(Tool(name='tool-b')) + + tr3 = ToolRepository() + tr3.tools.add(Tool(name='tool-c')) + + # This should not raise TypeError: '<' not supported between instances + tr_list = [tr3, tr1, tr2] + sorted_tr = sorted(tr_list) + self.assertEqual(len(sorted_tr), 3)