From 548d66ec1ef52d95245ed414dc9e050d3e93c5e5 Mon Sep 17 00:00:00 2001 From: Quentin Kaiser Date: Mon, 20 Oct 2025 11:00:56 +0200 Subject: [PATCH 1/2] fix: implement __lt__ for models still missing it Since cyclonedx-python-lib has the tendency to use SortedSets to store deserialized instances, this can lead to errors like these: TypeError: '<' not supported between instances of 'ComponentEvidence' and 'ComponentEvidence' When loading a CycloneDX JSON SBOM using Bom.from_json(). This is due to the lack of 'less-than' operator, which is now implemented with __lt__ for all models that were still missing it. We implemented it this way since that's how it's done for all the existing models, but an easier implementation would propably to add functools' @total_ordering decorator to those classes. Signed-off-by: Quentin Kaiser --- cyclonedx/model/bom.py | 5 + cyclonedx/model/component.py | 10 ++ cyclonedx/model/component_evidence.py | 10 ++ cyclonedx/model/crypto.py | 30 ++++++ cyclonedx/model/release_note.py | 5 + cyclonedx/model/tool.py | 5 + tests/test_model_bom.py | 20 ++++ tests/test_model_component.py | 22 ++++ tests/test_model_component_evidence.py | 22 ++++ tests/test_model_crypto.py | 136 +++++++++++++++++++++++++ tests/test_model_release_note.py | 11 ++ tests/test_model_tool_repository.py | 16 +++ 12 files changed, 292 insertions(+) create mode 100644 tests/test_model_crypto.py 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 c23acac9..0ccf4961 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/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 f4561cbb..1aaabf40 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) From f0ff62aacbc6580dd7b0c039302724b9db1b0f12 Mon Sep 17 00:00:00 2001 From: Jan Kowalleck Date: Wed, 22 Oct 2025 11:25:34 +0200 Subject: [PATCH 2/2] tests: test case from PR#899 Signed-off-by: Jan Kowalleck --- tests/_data/own/json/1.6/pr899.json | 170 ++++++++++++++++++++++++++++ tests/test_deserialize_json.py | 12 ++ 2 files changed, 182 insertions(+) create mode 100644 tests/_data/own/json/1.6/pr899.json 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)