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
225 changes: 222 additions & 3 deletions cyclonedx/model/license.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
License related things
"""

from collections.abc import Iterable
from enum import Enum
from json import loads as json_loads
from typing import TYPE_CHECKING, Any, Optional, Union
Expand All @@ -33,8 +34,9 @@
from .._internal.compare import ComparableTuple as _ComparableTuple
from ..exception.model import MutuallyExclusivePropertiesException
from ..exception.serialization import CycloneDxDeserializationException
from ..schema import SchemaVersion
from ..schema.schema import SchemaVersion1Dot5, SchemaVersion1Dot6, SchemaVersion1Dot7
from . import AttachedText, XsUri
from . import AttachedText, Property, XsUri
from .bom_ref import BomRef


Expand Down Expand Up @@ -262,6 +264,122 @@ def __repr__(self) -> str:
return f'<License id={self._id!r}, name={self._name!r}>'


@serializable.serializable_class(ignore_unknown_during_deserialization=True)
class ExpressionDetails:
"""
This is our internal representation of the `licenseExpressionDetailedType` complex type that specifies the details
and attributes related to a software license identifier within a CycloneDX BOM document.

.. note::
Introduced in CycloneDX v1.7


.. note::
See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.7/xml/#type_licenseExpressionDetailedType
"""

def __init__(
self, license_identifier: str, *,
bom_ref: Optional[Union[str, BomRef]] = None,
text: Optional[AttachedText] = None,
url: Optional[XsUri] = None,
) -> None:
self._bom_ref = _bom_ref_from_str(bom_ref)
self.license_identifier = license_identifier
self.text = text
self.url = url

@property
@serializable.xml_name('license-identifier')
@serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING)
@serializable.xml_attribute()
def license_identifier(self) -> str:
"""
A valid SPDX license identifier. Refer to https://spdx.org/specifications for syntax requirements.
This field serves as the primary key, which uniquely identifies each record.

Example values:
- "Apache-2.0",
- "GPL-3.0-only WITH Classpath-exception-2.0"
- "LicenseRef-my-custom-license"

Returns:
`str`
"""
return self._license_identifier

@license_identifier.setter
def license_identifier(self, license_identifier: str) -> None:
self._license_identifier = license_identifier

@property
@serializable.json_name('bom-ref')
@serializable.type_mapping(BomRef)
@serializable.xml_attribute()
@serializable.xml_name('bom-ref')
def bom_ref(self) -> BomRef:
"""
An optional identifier which can be used to reference the component elsewhere in the BOM. Every bom-ref MUST be
unique within the BOM.

Returns:
`BomRef`
"""
return self._bom_ref

@property
@serializable.xml_sequence(1)
def text(self) -> Optional[AttachedText]:
"""
Specifies the optional full text of the attachment

Returns:
`AttachedText` else `None`
"""
return self._text

@text.setter
def text(self, text: Optional[AttachedText]) -> None:
self._text = text

@property
@serializable.xml_sequence(2)
def url(self) -> Optional[XsUri]:
"""
The URL to the attachment file. If the attachment is a license or BOM, an externalReference should also be
specified for completeness.

Returns:
`XsUri` or `None`
"""
return self._url

@url.setter
def url(self, url: Optional[XsUri]) -> None:
self._url = url

def __comparable_tuple(self) -> _ComparableTuple:
return _ComparableTuple((
self.bom_ref.value, self.license_identifier, self.url, self.text,
))

def __eq__(self, other: object) -> bool:
if isinstance(other, ExpressionDetails):
return self.__comparable_tuple() == other.__comparable_tuple()
return False

def __lt__(self, other: object) -> bool:
if isinstance(other, ExpressionDetails):
return self.__comparable_tuple() < other.__comparable_tuple()
return NotImplemented

def __hash__(self) -> int:
return hash(self.__comparable_tuple())

def __repr__(self) -> str:
return f'<ExpressionDetails bom-ref={self.bom_ref!r}, license_identifier={self.license_identifier}>'


@serializable.serializable_class(
name='expression',
ignore_unknown_during_deserialization=True
Expand All @@ -280,10 +398,14 @@ def __init__(
self, value: str, *,
bom_ref: Optional[Union[str, BomRef]] = None,
acknowledgement: Optional[LicenseAcknowledgement] = None,
expression_details: Optional[Iterable[ExpressionDetails]] = None,
properties: Optional[Iterable[Property]] = None,
) -> None:
self._bom_ref = _bom_ref_from_str(bom_ref)
self._value = value
self._acknowledgement = acknowledgement
self.expression_details = expression_details or []
self.properties = properties or []

@property
@serializable.view(SchemaVersion1Dot5)
Expand Down Expand Up @@ -346,11 +468,60 @@ def acknowledgement(self) -> Optional[LicenseAcknowledgement]:
def acknowledgement(self, acknowledgement: Optional[LicenseAcknowledgement]) -> None:
self._acknowledgement = acknowledgement

@property
@serializable.view(SchemaVersion1Dot7)
@serializable.xml_array(serializable.XmlArraySerializationType.FLAT, child_name='details')
@serializable.xml_sequence(1)
def expression_details(self) -> 'SortedSet[ExpressionDetails]':
"""
Details for parts of the expression.

Returns:
`Iterable[ExpressionDetails]` if set else `None`
"""
return self._expression_details

@expression_details.setter
def expression_details(self, expression_details: Iterable[ExpressionDetails]) -> None:
self._expression_details = SortedSet(expression_details)

# @property
# @serializable.view(SchemaVersion1Dot7)
# ...
# @serializable.xml_array(serializable.XmlArraySerializationType.FLAT, child_name='licensing')
# @serializable.xml_sequence(2)
# def licensing(self) -> ...:
# ... # TODO
#

@property
@serializable.view(SchemaVersion1Dot7)
@serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'property')
@serializable.xml_sequence(3)
def properties(self) -> 'SortedSet[Property]':
"""
Provides the ability to document properties in a key/value store. This provides flexibility to include data not
officially supported in the standard without having to use additional namespaces or create extensions.

Property names of interest to the general public are encouraged to be registered in the CycloneDX Property
Taxonomy - https://github.com/CycloneDX/cyclonedx-property-taxonomy. Formal registration is OPTIONAL.

Return:
Set of `Property`
"""
return self._properties

@properties.setter
def properties(self, properties: Iterable[Property]) -> None:
self._properties = SortedSet(properties)

def __comparable_tuple(self) -> _ComparableTuple:
return _ComparableTuple((
self._acknowledgement,
self._value,
self._bom_ref.value,
_ComparableTuple(self.expression_details),
_ComparableTuple(self.properties),
))

def __hash__(self) -> int:
Expand Down Expand Up @@ -415,12 +586,51 @@ class LicenseRepository(SortedSet):
class _LicenseRepositorySerializationHelper(serializable.helpers.BaseHelper):
""" THIS CLASS IS NON-PUBLIC API """

@staticmethod
def __supports_expression_details(view: Any) -> bool:
try:
return view is not None and view().schema_version_enum >= SchemaVersion.V1_7
except Exception: # pragma: no cover
return False

@staticmethod
def __serialize_license_expression_details_xml(
license_expression: LicenseExpression,
view: Optional[type[serializable.ViewType]],
xmlns: Optional[str]
) -> Element:
elem: Element = license_expression.as_xml( # type:ignore[attr-defined]
view_=view, as_string=False, element_name='expression-detailed', xmlns=xmlns)

expression_value = elem.text
if expression_value:
elem.set(f'{{{xmlns}}}expression' if xmlns else 'expression', expression_value)
elem.text = None

return elem

@staticmethod
def __deserialize_license_expression_details_xml(
li: Element,
default_ns: Optional[str]
) -> LicenseExpression:
expression_value = li.get('expression')
if not expression_value:
raise CycloneDxDeserializationException(f'unexpected content: {li!r}')

license_expression = LicenseExpression.from_xml( # type:ignore[attr-defined]
li, default_ns)
license_expression.value = expression_value

return license_expression

@classmethod
def json_normalize(cls, o: LicenseRepository, *,
view: Optional[type[serializable.ViewType]],
**__: Any) -> Any:
if len(o) == 0:
return None

expression = next((li for li in o if isinstance(li, LicenseExpression)), None)
if expression:
# mixed license expression and license? this is an invalid constellation according to schema!
Expand Down Expand Up @@ -461,13 +671,20 @@ def xml_normalize(cls, o: LicenseRepository, *,
if len(o) == 0:
return None
elem = Element(element_name)

expression = next((li for li in o if isinstance(li, LicenseExpression)), None)
if expression:
# mixed license expression and license? this is an invalid constellation according to schema!
# see https://github.com/CycloneDX/specification/pull/205
# but models need to allow it for backwards compatibility with JSON CDX < 1.5
elem.append(expression.as_xml( # type:ignore[attr-defined]
view_=view, as_string=False, element_name='expression', xmlns=xmlns))

if expression.expression_details and cls.__supports_expression_details(view):
elem.append(cls.__serialize_license_expression_details_xml(expression, view, xmlns))
else:
if expression.expression_details:
warn('LicenseExpression details are not supported in schema versions < 1.7; skipping serialization')
elem.append(expression.as_xml( # type:ignore[attr-defined]
view_=view, as_string=False, element_name='expression', xmlns=xmlns))
else:
elem.extend(
li.as_xml( # type:ignore[attr-defined]
Expand All @@ -490,6 +707,8 @@ def xml_denormalize(cls, o: Element,
elif tag == 'expression':
repo.add(LicenseExpression.from_xml( # type:ignore[attr-defined]
li, default_ns))
elif tag == 'expression-detailed':
repo.add(cls.__deserialize_license_expression_details_xml(li, default_ns))
else:
raise CycloneDxDeserializationException(f'unexpected: {li!r}')
return repo
22 changes: 21 additions & 1 deletion tests/_data/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,13 @@
ImpactAnalysisState,
)
from cyclonedx.model.issue import IssueClassification, IssueType, IssueTypeSource
from cyclonedx.model.license import DisjunctiveLicense, License, LicenseAcknowledgement, LicenseExpression
from cyclonedx.model.license import (
DisjunctiveLicense,
ExpressionDetails,
License,
LicenseAcknowledgement,
LicenseExpression,
)
from cyclonedx.model.lifecycle import LifecyclePhase, NamedLifecycle, PredefinedLifecycle
from cyclonedx.model.release_note import ReleaseNotes
from cyclonedx.model.service import Service
Expand Down Expand Up @@ -1061,6 +1067,15 @@ def get_vulnerability_source_owasp() -> VulnerabilitySource:


def get_bom_with_licenses() -> Bom:
expression_details = [
ExpressionDetails(license_identifier='GPL-3.0-or-later',
url=XsUri('https://www.apache.org/licenses/LICENSE-2.0.txt'),
text=AttachedText(content='specific GPL-3.0-or-later license text')),
ExpressionDetails(license_identifier='GPL-2.0',
bom_ref='some-bomref-1234',
text=AttachedText(content='specific GPL-2.0 license text')),
]

return _make_bom(
metadata=BomMetaData(
licenses=[DisjunctiveLicense(id='CC-BY-1.0')],
Expand All @@ -1082,6 +1097,11 @@ def get_bom_with_licenses() -> Bom:
DisjunctiveLicense(name='some additional',
text=AttachedText(content='this is additional license text')),
]),
Component(name='c-with-expression-details', type=ComponentType.LIBRARY, bom_ref='C4',
licenses=[LicenseExpression(value='GPL-3.0-or-later OR GPL-2.0',
expression_details=expression_details,
acknowledgement=LicenseAcknowledgement.DECLARED
)]),
],
services=[
Service(name='s-with-expression', bom_ref='S1',
Expand Down
5 changes: 5 additions & 0 deletions tests/_data/snapshots/get_bom_with_licenses-1.0.xml.bin
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
<version/>
<modified>false</modified>
</component>
<component type="library">
<name>c-with-expression-details</name>
<version/>
<modified>false</modified>
</component>
<component type="library">
<name>c-with-name</name>
<version/>
Expand Down
7 changes: 7 additions & 0 deletions tests/_data/snapshots/get_bom_with_licenses-1.1.xml.bin
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@
<expression>Apache-2.0 OR MIT</expression>
</licenses>
</component>
<component type="library" bom-ref="C4">
<name>c-with-expression-details</name>
<version/>
<licenses>
<expression>GPL-3.0-or-later OR GPL-2.0</expression>
</licenses>
</component>
<component type="library" bom-ref="C3">
<name>c-with-name</name>
<version/>
Expand Down
14 changes: 14 additions & 0 deletions tests/_data/snapshots/get_bom_with_licenses-1.2.json.bin
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@
"type": "library",
"version": ""
},
{
"bom-ref": "C4",
"licenses": [
{
"expression": "GPL-3.0-or-later OR GPL-2.0"
}
],
"name": "c-with-expression-details",
"type": "library",
"version": ""
},
{
"bom-ref": "C3",
"licenses": [
Expand Down Expand Up @@ -62,6 +73,9 @@
{
"ref": "C3"
},
{
"ref": "C4"
},
{
"ref": "S1"
},
Expand Down
Loading