From 35b001aee44dbfce168a0ac1428df9958dc2a362 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ondrej=20Baranovi=C4=8D?= Date: Fri, 28 Aug 2026 17:17:53 +0200 Subject: [PATCH] feat: Add version_range property to Component for CycloneDX v1.7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the versionRange property for Compoments as specified in the CycloneDX v1.7 schema. The versionRange can be specified instead of the version for external components to specify the accepted version range. * Add version_range property to Component with serialization decorators. * Adjust Component.__repr__ to show the version_range if provided. * Extend Bom.validate with validation for CycloneDX v1.7 requirements regarding versionRange and isExternal. * Add unit tests for Component.version_range proprety and Bom.validate changes. * Add serialization snapshot tests for various kinds of external components. Implements part of #903 Signed-off-by: Ondrej Baranovič --- cyclonedx/model/bom.py | 14 +++- cyclonedx/model/component.py | 71 ++++++++++++++++- tests/_data/models.py | 77 ++++++++++++++++++- ...om_with_external_component_1_7-1.0.xml.bin | 12 +++ ...om_with_external_component_1_7-1.1.xml.bin | 10 +++ ...m_with_external_component_1_7-1.2.json.bin | 20 +++++ ...om_with_external_component_1_7-1.2.xml.bin | 12 +++ ...m_with_external_component_1_7-1.3.json.bin | 20 +++++ ...om_with_external_component_1_7-1.3.xml.bin | 12 +++ ...m_with_external_component_1_7-1.4.json.bin | 18 +++++ ...om_with_external_component_1_7-1.4.xml.bin | 10 +++ ...m_with_external_component_1_7-1.5.json.bin | 18 +++++ ...om_with_external_component_1_7-1.5.xml.bin | 10 +++ ...m_with_external_component_1_7-1.6.json.bin | 18 +++++ ...om_with_external_component_1_7-1.6.xml.bin | 10 +++ ...m_with_external_component_1_7-1.7.json.bin | 21 +++++ ...om_with_external_component_1_7-1.7.xml.bin | 11 +++ tests/test_model_bom.py | 7 ++ tests/test_model_component.py | 21 +++++ tests/test_output_json.py | 2 + tests/test_output_xml.py | 2 + 21 files changed, 388 insertions(+), 8 deletions(-) diff --git a/cyclonedx/model/bom.py b/cyclonedx/model/bom.py index 3f76c94a5..e8eee911f 100644 --- a/cyclonedx/model/bom.py +++ b/cyclonedx/model/bom.py @@ -44,7 +44,7 @@ from ..serialization import UrnUuidHelper from . import _BOM_LINK_PREFIX, ExternalReference, Property from .bom_ref import BomRef -from .component import Component +from .component import Component, _ComponentValidationHelper from .contact import OrganizationalContact, OrganizationalEntity from .definition import Definitions from .dependency import Dependable, Dependency @@ -864,6 +864,18 @@ def validate(self) -> bool: raise LicenseExpressionAlongWithOthersException( f'Found LicenseExpression along with others licenses in: {elem!r}') + # 4. Validates that each component conforms to CycloneDX 1.7 constraints: + # - root component may not have is_external=true + # - version and version_range are mutually exclusive + # - version_range requires is_external=true + if self.metadata.component and self.metadata.component.is_external: + warn( + f'The Component this BOM is describing {self.metadata.component.purl} must not have is_external=true.', + category=UserWarning, stacklevel=1 + ) + for _c in self._get_all_components(): + _ComponentValidationHelper.validate(_c) + return True def __comparable_tuple(self) -> _ComparableTuple: diff --git a/cyclonedx/model/component.py b/cyclonedx/model/component.py index a297e4ef4..6ab7180d8 100644 --- a/cyclonedx/model/component.py +++ b/cyclonedx/model/component.py @@ -34,7 +34,7 @@ from .._internal.bom_ref import bom_ref_from_str as _bom_ref_from_str from .._internal.compare import ComparablePackageURL as _ComparablePackageURL, ComparableTuple as _ComparableTuple -from ..exception.model import InvalidOmniBorIdException, InvalidSwhidException +from ..exception.model import InvalidOmniBorIdException, InvalidSwhidException, MutuallyExclusivePropertiesException from ..exception.serialization import ( CycloneDxDeserializationException, SerializationOfUnexpectedValueException, @@ -991,6 +991,7 @@ def __init__( publisher: Optional[str] = None, group: Optional[str] = None, version: Optional[str] = None, + version_range: Optional[str] = None, description: Optional[str] = None, scope: Optional[ComponentScope] = None, is_external: Optional[bool] = None, @@ -1025,6 +1026,8 @@ def __init__( self.publisher = publisher self.group = group self.name = name + self.version = version + self.version_range = version_range self.description = description self.scope = scope self.is_external = is_external @@ -1047,7 +1050,6 @@ def __init__( # spec-deprecated properties below self.author = author self.modified = modified - self.version = version @property @serializable.type_mapping(_ComponentTypeSerializationHelper) @@ -1272,6 +1274,34 @@ def version(self, version: Optional[str]) -> None: warn('`@.version`has a maximum length of 1024 from CycloneDX v1.6 onwards.', UserWarning) self._version = version + @property + @serializable.view(SchemaVersion1Dot7) + @serializable.json_name('versionRange') + @serializable.xml_name('versionRange') + @serializable.xml_sequence(8) + @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) + def version_range(self) -> Optional[str]: + """ + For an external component, this specifies the accepted version range. + + The value must adhere to the Package URL Version Range syntax (vers), as defined at + https://github.com/package-url/vers-spec + + May only be used if .isExternal is set to true. + + Must be used exclusively, either 'version' or 'version_range', but not both. + + Returns: + `str` if set, else `None` + """ + return self._version_range + + @version_range.setter + def version_range(self, version_range: Optional[str]) -> None: + if version_range is not None and not 1 <= len(version_range) <= 4096: + warn('`@.version_range`has a minimum length of 1 and a maximum length of 4096 characters.', UserWarning) + self._version_range = version_range + @property @serializable.xml_sequence(9) @serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING) @@ -1729,7 +1759,7 @@ def __comparable_tuple(self) -> _ComparableTuple: _ComparableTuple(self.components), self.evidence, self.release_notes, self.modified, _ComparableTuple(self.authors), _ComparableTuple(self.omnibor_ids), self.manufacturer, self.crypto_properties, _ComparableTuple(self.tags), - self.is_external, + self.is_external, self.version_range, )) def __eq__(self, other: object) -> bool: @@ -1746,5 +1776,38 @@ def __hash__(self) -> int: return hash(self.__comparable_tuple()) def __repr__(self) -> str: + version = f'versionRange={self.version_range}' \ + if self.version_range is not None \ + else f'version={self.version}' return f'' + f'{version}, type={self.type}>' + + +class _ComponentValidationHelper: + """ THIS CLASS IS NON-PUBLIC API """ + + @staticmethod + def validate_version_choice(component: Component) -> None: + """ Validates that version and version_range are not both set. """ + if component.version is not None and component.version_range is not None: + raise MutuallyExclusivePropertiesException( + f'Component cannot have both `version` and `version_range` set. Component: {component.name}' + ) + + @staticmethod + def validate_version_range_requirements(component: Component) -> None: + """ Validates that version_range is used correctly with is_external. """ + if component.version_range is not None and not component.is_external: + raise MutuallyExclusivePropertiesException( + f'Component cannot have `version_range` set unless `is_external=true`. Component: {component.name}' + ) + + @staticmethod + def validate(component: Component) -> None: + """ + Validates that the component conforms to CycloneDX 1.7 constraints: + - version and version_range are mutually exclusive + - version_range requires is_external=true + """ + _ComponentValidationHelper.validate_version_choice(component) + _ComponentValidationHelper.validate_version_range_requirements(component) diff --git a/tests/_data/models.py b/tests/_data/models.py index e2052878f..722a3b883 100644 --- a/tests/_data/models.py +++ b/tests/_data/models.py @@ -600,7 +600,26 @@ def get_bom_with_external_references() -> Bom: def get_bom_with_external_component_1_7() -> Bom: - bom = _make_bom(components=[get_component_external()]) + bom = _make_bom(components=[ + get_component_external_without_version(), + get_component_external_with_version(), + get_component_external_with_version_range(), + ]) + return bom + + +def get_bom_with_external_component_with_version_and_version_range_invalid() -> Bom: + bom = _make_bom(components=[get_component_external_with_version_and_version_range_invalid()]) + return bom + + +def get_bom_with_non_external_explicit_component_with_version_range_invalid() -> Bom: + bom = _make_bom(components=[get_component_non_external_explicit_with_version_range_invalid()]) + return bom + + +def get_bom_with_non_external_implicit_component_with_version_range_invalid() -> Bom: + bom = _make_bom(components=[get_component_non_external_implicit_with_version_range_invalid()]) return bom @@ -864,9 +883,20 @@ def get_component_setuptools_simple( ) -def get_component_external() -> Component: +def get_component_external_without_version() -> Component: return Component( - name='external-lib', version='1.0.0', + name='external-lib', + type=ComponentType.LIBRARY, + is_external=True, + scope=ComponentScope.REQUIRED, + bom_ref='external-lib', + ) + + +def get_component_external_with_version() -> Component: + return Component( + name='external-lib', + version='1.0.0', type=ComponentType.LIBRARY, is_external=True, scope=ComponentScope.REQUIRED, @@ -874,6 +904,47 @@ def get_component_external() -> Component: ) +def get_component_external_with_version_range() -> Component: + return Component( + name='external-lib', + version_range='vers:all/*', + type=ComponentType.LIBRARY, + is_external=True, + scope=ComponentScope.REQUIRED, + bom_ref='external-lib-with-range', + ) + + +def get_component_external_with_version_and_version_range_invalid() -> Component: + return Component( + name='external-lib', + version='1.0.0', + version_range='vers:all/*', + type=ComponentType.LIBRARY, + is_external=True, + bom_ref='external-lib-with-version-and-version-range', + ) + + +def get_component_non_external_explicit_with_version_range_invalid() -> Component: + return Component( + name='internal-lib', + version_range='vers:all/*', + type=ComponentType.LIBRARY, + is_external=False, + bom_ref='internal-lib-with-range', + ) + + +def get_component_non_external_implicit_with_version_range_invalid() -> Component: + return Component( + name='internal-lib', + version_range='vers:all/*', + type=ComponentType.LIBRARY, + bom_ref='internal-lib-with-range', + ) + + def get_component_setuptools_simple_no_version(bom_ref: Optional[str] = None) -> Component: return Component( name='setuptools', bom_ref=bom_ref or 'pkg:pypi/setuptools?extension=tar.gz', diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.0.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.0.xml.bin index aaae83372..d7c68502e 100644 --- a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.0.xml.bin +++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.0.xml.bin @@ -7,5 +7,17 @@ required false + + external-lib + + required + false + + + external-lib + + required + false + diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.1.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.1.xml.bin index 06e044d33..729fb705e 100644 --- a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.1.xml.bin +++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.1.xml.bin @@ -6,5 +6,15 @@ 1.0.0 required + + external-lib + + required + + + external-lib + + required + diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.2.json.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.2.json.bin index fe5a4e0ac..fe5167371 100644 --- a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.2.json.bin +++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.2.json.bin @@ -6,11 +6,31 @@ "scope": "required", "type": "library", "version": "1.0.0" + }, + { + "bom-ref": "external-lib", + "name": "external-lib", + "scope": "required", + "type": "library", + "version": "" + }, + { + "bom-ref": "external-lib-with-range", + "name": "external-lib", + "scope": "required", + "type": "library", + "version": "" } ], "dependencies": [ + { + "ref": "external-lib" + }, { "ref": "external-lib-1.0.0" + }, + { + "ref": "external-lib-with-range" } ], "metadata": { diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.2.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.2.xml.bin index 266020af7..f3201d9de 100644 --- a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.2.xml.bin +++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.2.xml.bin @@ -9,8 +9,20 @@ 1.0.0 required + + external-lib + + required + + + external-lib + + required + + + diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.3.json.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.3.json.bin index 8500a9f79..fd2b3578f 100644 --- a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.3.json.bin +++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.3.json.bin @@ -6,11 +6,31 @@ "scope": "required", "type": "library", "version": "1.0.0" + }, + { + "bom-ref": "external-lib", + "name": "external-lib", + "scope": "required", + "type": "library", + "version": "" + }, + { + "bom-ref": "external-lib-with-range", + "name": "external-lib", + "scope": "required", + "type": "library", + "version": "" } ], "dependencies": [ + { + "ref": "external-lib" + }, { "ref": "external-lib-1.0.0" + }, + { + "ref": "external-lib-with-range" } ], "metadata": { diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.3.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.3.xml.bin index 120d5d288..87012c026 100644 --- a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.3.xml.bin +++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.3.xml.bin @@ -9,8 +9,20 @@ 1.0.0 required + + external-lib + + required + + + external-lib + + required + + + diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.4.json.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.4.json.bin index c2c3bbea0..be5c2271a 100644 --- a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.4.json.bin +++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.4.json.bin @@ -6,11 +6,29 @@ "scope": "required", "type": "library", "version": "1.0.0" + }, + { + "bom-ref": "external-lib", + "name": "external-lib", + "scope": "required", + "type": "library" + }, + { + "bom-ref": "external-lib-with-range", + "name": "external-lib", + "scope": "required", + "type": "library" } ], "dependencies": [ + { + "ref": "external-lib" + }, { "ref": "external-lib-1.0.0" + }, + { + "ref": "external-lib-with-range" } ], "metadata": { diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.4.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.4.xml.bin index 2d85c7dec..7b4dea53a 100644 --- a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.4.xml.bin +++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.4.xml.bin @@ -9,8 +9,18 @@ 1.0.0 required + + external-lib + required + + + external-lib + required + + + diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.5.json.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.5.json.bin index f3d168966..17875278f 100644 --- a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.5.json.bin +++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.5.json.bin @@ -6,11 +6,29 @@ "scope": "required", "type": "library", "version": "1.0.0" + }, + { + "bom-ref": "external-lib", + "name": "external-lib", + "scope": "required", + "type": "library" + }, + { + "bom-ref": "external-lib-with-range", + "name": "external-lib", + "scope": "required", + "type": "library" } ], "dependencies": [ + { + "ref": "external-lib" + }, { "ref": "external-lib-1.0.0" + }, + { + "ref": "external-lib-with-range" } ], "metadata": { diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.5.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.5.xml.bin index f06f8a0b5..56036b2a2 100644 --- a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.5.xml.bin +++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.5.xml.bin @@ -9,9 +9,19 @@ 1.0.0 required + + external-lib + required + + + external-lib + required + + + val1 diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.6.json.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.6.json.bin index bf66e8484..3ac794bb8 100644 --- a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.6.json.bin +++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.6.json.bin @@ -6,11 +6,29 @@ "scope": "required", "type": "library", "version": "1.0.0" + }, + { + "bom-ref": "external-lib", + "name": "external-lib", + "scope": "required", + "type": "library" + }, + { + "bom-ref": "external-lib-with-range", + "name": "external-lib", + "scope": "required", + "type": "library" } ], "dependencies": [ + { + "ref": "external-lib" + }, { "ref": "external-lib-1.0.0" + }, + { + "ref": "external-lib-with-range" } ], "metadata": { diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.6.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.6.xml.bin index c81104a76..c3a5bea0c 100644 --- a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.6.xml.bin +++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.6.xml.bin @@ -9,9 +9,19 @@ 1.0.0 required + + external-lib + required + + + external-lib + required + + + val1 diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.7.json.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.7.json.bin index c3c2f85b3..3756d89f2 100644 --- a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.7.json.bin +++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.7.json.bin @@ -7,11 +7,32 @@ "scope": "required", "type": "library", "version": "1.0.0" + }, + { + "bom-ref": "external-lib", + "isExternal": true, + "name": "external-lib", + "scope": "required", + "type": "library" + }, + { + "bom-ref": "external-lib-with-range", + "isExternal": true, + "name": "external-lib", + "scope": "required", + "type": "library", + "versionRange": "vers:all/*" } ], "dependencies": [ + { + "ref": "external-lib" + }, { "ref": "external-lib-1.0.0" + }, + { + "ref": "external-lib-with-range" } ], "metadata": { diff --git a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.7.xml.bin b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.7.xml.bin index b16d837f9..e516611ed 100644 --- a/tests/_data/snapshots/get_bom_with_external_component_1_7-1.7.xml.bin +++ b/tests/_data/snapshots/get_bom_with_external_component_1_7-1.7.xml.bin @@ -9,9 +9,20 @@ 1.0.0 required + + external-lib + required + + + external-lib + vers:all/* + required + + + val1 diff --git a/tests/test_model_bom.py b/tests/test_model_bom.py index 030ee4a56..fe3193453 100644 --- a/tests/test_model_bom.py +++ b/tests/test_model_bom.py @@ -217,6 +217,13 @@ def test_warning_missing_dependency(self) -> None: self.assertEqual(len(w.warnings), 1) self.assertIn('has no defined dependencies ', str(w.warnings[0])) + def test_warning_root_component_is_external(self) -> None: + with self.assertWarns(expected_warning=UserWarning) as w: + bom = Bom(metadata=BomMetaData(component=Component(name='root_component', is_external=True))) + _ = JsonV1Dot7(bom).output_as_string() + self.assertEqual(len(w.warnings), 1) + self.assertIn('must not have is_external=true', str(w.warnings[0])) + def test_empty_bom_defined_serial(self) -> None: serial_number = uuid4() bom = Bom(serial_number=serial_number) diff --git a/tests/test_model_component.py b/tests/test_model_component.py index dc7704d7e..77d340bdd 100644 --- a/tests/test_model_component.py +++ b/tests/test_model_component.py @@ -305,6 +305,27 @@ def test_is_external_sorting(self) -> None: expected_components = reorder(components, expected_order) self.assertListEqual(sorted_components, expected_components) + def test_version_range_comparison(self) -> None: + component_all = Component(name='test-component', is_external=True, version_range='vers:all/*') + component_none = Component(name='test-component', is_external=True, version_range='vers:none/*') + self.assertEqual(component_all, component_all) + self.assertEqual(component_none, component_none) + self.assertNotEqual(component_all, component_none) + self.assertLess(component_all, component_none) + self.assertNotEqual(hash(component_all), hash(component_none)) + + def test_version_range_sorting(self) -> None: + # version is compared before version_range, None sorts as greater than any string + expected_order = [1, 2, 0] + components = [ + Component(name='test_component', is_external=True), + Component(name='test_component', is_external=True, version='1.0.0'), + Component(name='test_component', is_external=True, version_range='vers:all/*'), + ] + sorted_components = sorted(components) + expected_components = reorder(components, expected_order) + self.assertListEqual(sorted_components, expected_components) + def test_nested_components_1(self) -> None: comp_b = Component(name='comp_b') comp_c = Component(name='comp_c') diff --git a/tests/test_output_json.py b/tests/test_output_json.py index b9340a4e2..062f38cfe 100644 --- a/tests/test_output_json.py +++ b/tests/test_output_json.py @@ -30,6 +30,7 @@ InvalidOmniBorIdException, InvalidSwhidException, LicenseExpressionAlongWithOthersException, + MutuallyExclusivePropertiesException, UnknownComponentDependencyException, ) from cyclonedx.exception.output import FormatNotSupportedException @@ -93,6 +94,7 @@ def test_invalid(self, get_bom: Callable[[], Bom], sv: SchemaVersion) -> None: LicenseExpressionAlongWithOthersException, InvalidOmniBorIdException, InvalidSwhidException, + MutuallyExclusivePropertiesException, UnknownComponentDependencyException, )): return None # expected diff --git a/tests/test_output_xml.py b/tests/test_output_xml.py index 6e887dedb..2cb685857 100644 --- a/tests/test_output_xml.py +++ b/tests/test_output_xml.py @@ -29,6 +29,7 @@ InvalidOmniBorIdException, InvalidSwhidException, LicenseExpressionAlongWithOthersException, + MutuallyExclusivePropertiesException, UnknownComponentDependencyException, ) from cyclonedx.model.bom import Bom @@ -81,6 +82,7 @@ def test_invalid(self, get_bom: Callable[[], Bom], sv: SchemaVersion) -> None: LicenseExpressionAlongWithOthersException, InvalidOmniBorIdException, InvalidSwhidException, + MutuallyExclusivePropertiesException, UnknownComponentDependencyException, )): return None # expected