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
18 changes: 18 additions & 0 deletions cyclonedx/contrib/bom/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# 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.

"""Bom related functionality"""
92 changes: 92 additions & 0 deletions cyclonedx/contrib/bom/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# 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.

"""Bom related utilities"""

__all__ = ['BomRefDiscriminator']

from collections.abc import Iterable
from itertools import chain
from random import random
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING: # pragma: no cover
from ...model.bom import Bom
from ...model.bom_ref import BomRef


class BomRefDiscriminator:
"""
Ensure that a collection of BomRef objects has unique, non‑empty values.

The discriminator inspects the provided BomRef instances and assigns new,
automatically generated identifiers to any BomRef whose value is missing
or duplicates another. Original values are preserved so they can be
restored later via `reset()` or by using this class as a context manager.
"""

def __init__(self, bomrefs: Iterable['BomRef'], prefix: str = 'BomRef') -> None:
# do not use dict/set here, different BomRefs with same value have same hash and would shadow each other
self._bomrefs = tuple((bomref, bomref.value) for bomref in bomrefs)
self._prefix = prefix

def __enter__(self) -> None:
self.discriminate()

def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
self.reset()

def discriminate(self) -> None:
"""
Enforce uniqueness across all BomRef values.

Any BomRef whose value is `None` or duplicates a previously encountered
value is assigned a newly generated unique identifier.
"""
known_values = []
for bomref, _ in self._bomrefs:
value = bomref.value
if value is None or value in known_values:
value = self._make_unique()
bomref.value = value
known_values.append(value)
Comment thread
jkowalleck marked this conversation as resolved.

def reset(self) -> None:
"""
Restore all BomRef values to their original state.
"""
for bomref, original_value in self._bomrefs:
bomref.value = original_value

def _make_unique(self) -> str:
return f'{self._prefix}{str(random())[1:]}{str(random())[1:]}' # nosec B311

@classmethod
def from_bom(cls, bom: 'Bom', prefix: str = 'BomRef') -> 'BomRefDiscriminator':
"""
Create a discriminator for all BomRefs contained within a BOM.

This includes BomRefs from
- components
- services
- vulnerabilities
"""
return cls(chain(
map(lambda c: c.bom_ref, bom._get_all_components()),
map(lambda s: s.bom_ref, bom.services),
map(lambda v: v.bom_ref, bom.vulnerabilities)
), prefix)
55 changes: 19 additions & 36 deletions cyclonedx/output/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,21 @@
"""

import os
import sys
from abc import ABC, abstractmethod
from collections.abc import Iterable, Mapping
from itertools import chain
from random import random
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, Literal, Optional, Union, overload

if sys.version_info >= (3, 13):
from warnings import deprecated
else:
from typing_extensions import deprecated

from ..contrib.bom.utils import BomRefDiscriminator as _BomRefDiscriminator
from ..schema import OutputFormat, SchemaVersion

if TYPE_CHECKING: # pragma: no cover
from ..model.bom import Bom
from ..model.bom_ref import BomRef
from .json import Json as JsonOutputter
from .xml import Xml as XmlOutputter

Expand Down Expand Up @@ -138,40 +142,19 @@ def make_outputter(bom: 'Bom', output_format: OutputFormat, schema_version: Sche
raise ValueError(f'Unknown {output_format.name}/schema_version: {schema_version!r}')
return klass(bom)

# region deprecated re-export

class BomRefDiscriminator:

def __init__(self, bomrefs: Iterable['BomRef'], prefix: str = 'BomRef') -> None:
# do not use dict/set here, different BomRefs with same value have same hash and would shadow each other
self._bomrefs = tuple((bomref, bomref.value) for bomref in bomrefs)
self._prefix = prefix

def __enter__(self) -> None:
self.discriminate()

def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
self.reset()
@deprecated('Deprecated re-export location - see docstring of "BomRefDiscriminator" for details.')
class BomRefDiscriminator(_BomRefDiscriminator):
"""Deprecated — Alias of :class:`cyclonedx.contrib.bom.utils.BomRefDiscriminator`.
def discriminate(self) -> None:
known_values = []
for bomref, _ in self._bomrefs:
value = bomref.value
if value is None or value in known_values:
value = self._make_unique()
bomref.value = value
known_values.append(value)

def reset(self) -> None:
for bomref, original_value in self._bomrefs:
bomref.value = original_value
.. deprecated:: next
This re-export location is deprecated.
Use ``from cyclonedx.contrib.bom.utils import BomRefDiscriminator`` instead.
The exported symbol itself is NOT deprecated — only this import path.
"""
pass

def _make_unique(self) -> str:
return f'{self._prefix}{str(random())[1:]}{str(random())[1:]}' # nosec B311

@classmethod
def from_bom(cls, bom: 'Bom', prefix: str = 'BomRef') -> 'BomRefDiscriminator':
return cls(chain(
map(lambda c: c.bom_ref, bom._get_all_components()),
map(lambda s: s.bom_ref, bom.services),
map(lambda v: v.bom_ref, bom.vulnerabilities)
), prefix)
# endregion deprecated re-export
3 changes: 2 additions & 1 deletion cyclonedx/output/json.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from json import dumps as json_dumps, loads as json_loads
from typing import TYPE_CHECKING, Any, Literal, Optional, Union

from ..contrib.bom.utils import BomRefDiscriminator
from ..exception.output import FormatNotSupportedException
from ..schema import OutputFormat, SchemaVersion
from ..schema.schema import (
Expand All @@ -33,7 +34,7 @@
SchemaVersion1Dot6,
SchemaVersion1Dot7,
)
from . import BaseOutput, BomRefDiscriminator
from . import BaseOutput

if TYPE_CHECKING: # pragma: no cover
from ..model.bom import Bom
Expand Down
3 changes: 2 additions & 1 deletion cyclonedx/output/xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from xml.dom.minidom import parseString as dom_parseString # nosec B408
from xml.etree.ElementTree import Element as XmlElement, tostring as xml_dumps # nosec B405

from ..contrib.bom.utils import BomRefDiscriminator
from ..schema import OutputFormat, SchemaVersion
from ..schema.schema import (
SCHEMA_VERSIONS,
Expand All @@ -33,7 +34,7 @@
SchemaVersion1Dot6,
SchemaVersion1Dot7,
)
from . import BaseOutput, BomRefDiscriminator
from . import BaseOutput

if TYPE_CHECKING: # pragma: no cover
from ..model.bom import Bom
Expand Down
2 changes: 1 addition & 1 deletion tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

from sortedcontainers import SortedSet

from cyclonedx.output import BomRefDiscriminator as _BomRefDiscriminator
from cyclonedx.contrib.bom.utils import BomRefDiscriminator as _BomRefDiscriminator
from cyclonedx.schema import OutputFormat, SchemaVersion

if TYPE_CHECKING:
Expand Down
Empty file removed tests/test_contrib/.gitkeep
Empty file.
16 changes: 16 additions & 0 deletions tests/test_contrib/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# 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.
48 changes: 48 additions & 0 deletions tests/test_contrib/test_bom_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# 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.contrib.bom.utils import BomRefDiscriminator
from cyclonedx.model.bom_ref import BomRef


class TestBomRefDiscriminator(TestCase):

def test_discriminate_and_reset_manually(self) -> None:
bomref1 = BomRef('djdlkfjdslkf')
bomref2 = BomRef('djdlkfjdslkf')
self.assertEqual(bomref1.value, bomref2.value, 'blank')
discr = BomRefDiscriminator([bomref1, bomref2])
self.assertEqual(bomref1.value, bomref2.value, 'init')
discr.discriminate()
self.assertNotEqual(bomref1.value, bomref2.value, 'should be discriminated')
discr.reset()
self.assertEqual('djdlkfjdslkf', bomref1.value)
self.assertEqual('djdlkfjdslkf', bomref2.value)

def test_discriminate_and_reset_with(self) -> None:
bomref1 = BomRef('djdlkfjdslkf')
bomref2 = BomRef('djdlkfjdslkf')
self.assertEqual(bomref1.value, bomref2.value, 'blank')
discr = BomRefDiscriminator([bomref1, bomref2])
self.assertEqual(bomref1.value, bomref2.value, 'init')
with discr:
self.assertNotEqual(bomref1.value, bomref2.value, 'should be discriminated')
self.assertEqual('djdlkfjdslkf', bomref1.value)
self.assertEqual('djdlkfjdslkf', bomref2.value)
4 changes: 4 additions & 0 deletions tests/test_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ def test_fails_on_wrong_args(self, of: OutputFormat, sv: SchemaVersion, raises_r


class TestBomRefDiscriminator(TestCase):
"""
System under test `BomRefDiscriminator` is a deprecated re-export.
We keep the old tests untouched, to assert old behavior.
"""

def test_discriminate_and_reset_with(self) -> None:
bomref1 = BomRef('djdlkfjdslkf')
Expand Down
Loading