Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion cyclonedx/model/bom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -863,6 +863,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:
Expand Down
78 changes: 73 additions & 5 deletions cyclonedx/model/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -1047,7 +1050,6 @@ def __init__(
# spec-deprecated properties below
self.author = author
self.modified = modified
self.version = version

@property
@serializable.type_mapping(_ComponentTypeSerializationHelper)
Expand Down Expand Up @@ -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 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)
Expand Down Expand Up @@ -1702,7 +1732,7 @@ def get_pypi_url(self) -> str:

def __comparable_tuple(self) -> _ComparableTuple:
return _ComparableTuple((
self.type, self.group, self.name, self.version,
self.type, self.group, self.name, self.version, self.version_range,
self.bom_ref.value,
None if self.purl is None else _ComparablePackageURL(self.purl),
self.swid, self.cpe, _ComparableTuple(self.swhids),
Expand Down Expand Up @@ -1732,5 +1762,43 @@ def __hash__(self) -> int:
return hash(self.__comparable_tuple())

def __repr__(self) -> str:
return f'<Component bom-ref={self.bom_ref!r}, group={self.group}, name={self.name}, ' \
f'version={self.version}, type={self.type}>'
if not self.is_external:
# omit is_external unless it is set to the non-default value (i.e. True)
return f'<Component bom-ref={self.bom_ref!r}, group={self.group}, name={self.name}, ' \
f'version={self.version}, type={self.type}>'
elif self.version_range is None:
return f'<Component bom-ref={self.bom_ref!r}, group={self.group}, name={self.name}, ' \
f'version={self.version}, type={self.type}, is_external={self.is_external}>'
else:
return f'<Component bom-ref={self.bom_ref!r}, group={self.group}, name={self.name}, ' \
f'version_range={self.version_range}, type={self.type}, is_external={self.is_external}>'


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)
88 changes: 82 additions & 6 deletions tests/_data/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -599,8 +599,33 @@ def get_bom_with_external_references() -> Bom:
return bom


def get_bom_with_external_component_1_7() -> Bom:
bom = _make_bom(components=[get_component_external()])
def get_bom_with_external_component_without_version() -> Bom:
bom = _make_bom(components=[get_component_external_without_version()])
return bom


def get_bom_with_external_component_with_version() -> Bom:
bom = _make_bom(components=[get_component_external_with_version()])
return bom


def get_bom_with_external_component_with_version_range() -> Bom:
bom = _make_bom(components=[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


Expand Down Expand Up @@ -864,16 +889,65 @@ 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,
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,
bom_ref='external-lib-1.0.0',
)


def get_component_external_with_version_range() -> Component:
return Component(
name='external-lib',
version_range='vers:all/*',
type=ComponentType.LIBRARY,
is_external=True,
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',
Expand Down Expand Up @@ -1718,7 +1792,9 @@ def get_bom_for_issue941_nested_dependencies_irreversible_migrate() -> Bom:
get_bom_with_licenses,
get_bom_with_multiple_licenses,
get_bom_for_issue_497_urls,
get_bom_with_external_component_1_7,
get_bom_with_external_component_without_version,
get_bom_with_external_component_with_version,
get_bom_with_external_component_with_version_range,
get_bom_for_issue_598_multiple_components_with_purl_qualifiers,
get_bom_with_component_setuptools_with_v16_fields,
get_bom_for_issue_630_empty_property,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
<component type="library">
<name>external-lib</name>
<version>1.0.0</version>
<scope>required</scope>
<modified>false</modified>
</component>
</components>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
<component type="library" bom-ref="external-lib-1.0.0">
<name>external-lib</name>
<version>1.0.0</version>
<scope>required</scope>
</component>
</components>
</bom>
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
{
"bom-ref": "external-lib-1.0.0",
"name": "external-lib",
"scope": "required",
"type": "library",
"version": "1.0.0"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
<component type="library" bom-ref="external-lib-1.0.0">
<name>external-lib</name>
<version>1.0.0</version>
<scope>required</scope>
</component>
</components>
<dependencies>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
{
"bom-ref": "external-lib-1.0.0",
"name": "external-lib",
"scope": "required",
"type": "library",
"version": "1.0.0"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
<component type="library" bom-ref="external-lib-1.0.0">
<name>external-lib</name>
<version>1.0.0</version>
<scope>required</scope>
</component>
</components>
<dependencies>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
{
"bom-ref": "external-lib-1.0.0",
"name": "external-lib",
"scope": "required",
"type": "library",
"version": "1.0.0"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
<component type="library" bom-ref="external-lib-1.0.0">
<name>external-lib</name>
<version>1.0.0</version>
<scope>required</scope>
</component>
</components>
<dependencies>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
{
"bom-ref": "external-lib-1.0.0",
"name": "external-lib",
"scope": "required",
"type": "library",
"version": "1.0.0"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
<component type="library" bom-ref="external-lib-1.0.0">
<name>external-lib</name>
<version>1.0.0</version>
<scope>required</scope>
</component>
</components>
<dependencies>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
{
"bom-ref": "external-lib-1.0.0",
"name": "external-lib",
"scope": "required",
"type": "library",
"version": "1.0.0"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
<component type="library" bom-ref="external-lib-1.0.0">
<name>external-lib</name>
<version>1.0.0</version>
<scope>required</scope>
</component>
</components>
<dependencies>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
"bom-ref": "external-lib-1.0.0",
"isExternal": true,
"name": "external-lib",
"scope": "required",
"type": "library",
"version": "1.0.0"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
<component type="library" bom-ref="external-lib-1.0.0" isExternal="true">
<name>external-lib</name>
<version>1.0.0</version>
<scope>required</scope>
</component>
</components>
<dependencies>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" ?>
<bom xmlns="http://cyclonedx.org/schema/bom/1.0" version="1">
<components>
<component type="library">
<name>external-lib</name>
<version/>
<modified>false</modified>
</component>
</components>
</bom>
Loading