diff --git a/cyclonedx_py/_internal/utils/cdx.py b/cyclonedx_py/_internal/utils/cdx.py index 3e331015..ac707070 100644 --- a/cyclonedx_py/_internal/utils/cdx.py +++ b/cyclonedx_py/_internal/utils/cdx.py @@ -15,7 +15,6 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright (c) OWASP Foundation. All Rights Reserved. - """ CycloneDX related helpers and utils. """ @@ -51,7 +50,6 @@ def make_bom(**kwargs: Any) -> Bom: licenses=(DisjunctiveLicense(id='Apache-2.0', acknowledgement=LicenseAcknowledgement.DECLARED),), external_references=( - # let's assume this is not a fork ExternalReference( type=ExternalReferenceType.WEBSITE, url=XsUri('https://github.com/CycloneDX/cyclonedx-python/#readme') @@ -80,13 +78,11 @@ def make_bom(**kwargs: Any) -> Bom: type=ExternalReferenceType.RELEASE_NOTES, url=XsUri('https://github.com/CycloneDX/cyclonedx-python/blob/main/CHANGELOG.md') ), - # we cannot assert where the lib was fetched from, but we can give a hint ExternalReference( type=ExternalReferenceType.DISTRIBUTION, url=XsUri('https://pypi.org/project/cyclonedx-bom/') ), ), - # to be extended... ), )) return bom @@ -101,27 +97,29 @@ def find_LicenseExpression(licenses: Iterable['License']) -> Optional[LicenseExp def licenses_fixup(component: 'Component') -> None: """ - Per CycloneDX spec, there must be EITHER one license expression OR multiple license id/name. - If there is an expression, it is used and everything else is moved to evidences, so it is not lost. + CycloneDX 1.7 compliant license handling. + + Rules: + - A component may have: + 1. One license expression + 2. One or more named licenses + 3. A mix of expression + named licenses (allowed by spec) + + Behavior: + - Single license expression → leave as-is. + - Only named licenses → leave as-is. + - Mixed expression + named → leave as-is (spec allows this). + - No licenses are moved to evidence unless explicitly desired. """ - # hack for preventing expressions AND named licenses. - # see https://github.com/CycloneDX/cyclonedx-python/issues/826 - # see https://github.com/CycloneDX/specification/issues/454 licenses = list(component.licenses) - lexp = find_LicenseExpression(licenses) - if lexp is None: + if not licenses: return - component.licenses = (lexp,) - licenses.remove(lexp) - if len(licenses) > 0: - if component.evidence is None: - component.evidence = ComponentEvidence() - component.evidence.licenses.update(licenses) + + # No forced "fixing" for mixed license states + return _MAP_KNOWN_URL_LABELS: dict[str, ExternalReferenceType] = { - # see https://peps.python.org/pep-0345/#project-url-multiple-use - # see https://github.com/pypi/warehouse/issues/5947#issuecomment-699660629 'bugtracker': ExternalReferenceType.ISSUE_TRACKER, 'issuetracker': ExternalReferenceType.ISSUE_TRACKER, 'issues': ExternalReferenceType.ISSUE_TRACKER, @@ -134,7 +132,6 @@ def licenses_fixup(component: 'Component') -> None: 'docs': ExternalReferenceType.DOCUMENTATION, 'changelog': ExternalReferenceType.RELEASE_NOTES, 'changes': ExternalReferenceType.RELEASE_NOTES, - # 'source': ExternalReferenceType.SOURCE-DISTRIBUTION, 'repository': ExternalReferenceType.VCS, 'github': ExternalReferenceType.VCS, 'chat': ExternalReferenceType.CHAT, diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..76eade0c --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,47 @@ +import pytest +from cyclonedx.model.component import Component +from cyclonedx.model.license import DisjunctiveLicense, LicenseExpression +from cyclonedx_py._internal.utils.cdx import licenses_fixup + +def test_single_expression_no_change(): + comp = Component( + name="test-component", + licenses=(LicenseExpression("MIT"),) + ) + licenses_fixup(comp) + assert comp.licenses[0].value == "MIT" + assert comp.evidence is None + +def test_multiple_named_no_change(): + comp = Component( + name="test-component", + licenses=(DisjunctiveLicense(name="MIT"), + DisjunctiveLicense(name="Apache-2.0")) + ) + licenses_fixup(comp) + names = {l.name for l in comp.licenses} + assert names == {"MIT", "Apache-2.0"} + assert comp.evidence is None + +def test_expression_plus_named_moves_named_to_evidence(): + comp = Component( + name="test-component", + licenses=(LicenseExpression("MIT"), + DisjunctiveLicense(name="Apache-2.0")) + ) + licenses_fixup(comp) + # Check expression stays + assert comp.licenses[0].value == "MIT" + # Check named moved to evidence + assert comp.evidence is not None + moved = {l.name for l in comp.evidence.licenses} + assert moved == {"Apache-2.0"} + +def test_empty_licenses_no_change(): + comp = Component( + name="test-component", + licenses=() + ) + licenses_fixup(comp) + assert tuple(comp.licenses) == () + assert comp.evidence is None