Skip to content

Commit

Permalink
feat: add bom.definitions
Browse files Browse the repository at this point in the history
for CycloneDX#697

Signed-off-by: Hakan Dilek <[email protected]>
  • Loading branch information
hakandilek committed Oct 17, 2024
1 parent c72d5f4 commit 3c63ce8
Show file tree
Hide file tree
Showing 3 changed files with 363 additions and 0 deletions.
20 changes: 20 additions & 0 deletions cyclonedx/model/bom.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
from .bom_ref import BomRef
from .component import Component
from .contact import OrganizationalContact, OrganizationalEntity
from .definition import DefinitionRepository, _DefinitionRepositoryHelper
from .dependency import Dependable, Dependency
from .license import License, LicenseExpression, LicenseRepository
from .service import Service
Expand Down Expand Up @@ -317,6 +318,7 @@ def __init__(
dependencies: Optional[Iterable[Dependency]] = None,
vulnerabilities: Optional[Iterable[Vulnerability]] = None,
properties: Optional[Iterable[Property]] = None,
definitions: Optional[DefinitionRepository] = None,
) -> None:
"""
Create a new Bom that you can manually/programmatically add data to later.
Expand All @@ -333,6 +335,7 @@ def __init__(
self.vulnerabilities = vulnerabilities or [] # type:ignore[assignment]
self.dependencies = dependencies or [] # type:ignore[assignment]
self.properties = properties or [] # type:ignore[assignment]
self.definitions = definitions or DefinitionRepository()

@property
@serializable.type_mapping(UrnUuidHelper)
Expand Down Expand Up @@ -520,6 +523,23 @@ def vulnerabilities(self) -> 'SortedSet[Vulnerability]':
def vulnerabilities(self, vulnerabilities: Iterable[Vulnerability]) -> None:
self._vulnerabilities = SortedSet(vulnerabilities)

@property
@serializable.type_mapping(_DefinitionRepositoryHelper)
@serializable.view(SchemaVersion1Dot6)
@serializable.xml_sequence(90)
def definitions(self) -> Optional[DefinitionRepository]:
"""
The repository for definitions
Returns:
`DefinitionRepository`
"""
return self._definitions

@definitions.setter
def definitions(self, definitions: DefinitionRepository) -> None:
self._definitions = definitions

# @property
# ...
# @serializable.view(SchemaVersion1Dot5)
Expand Down
278 changes: 278 additions & 0 deletions cyclonedx/model/definition.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
# 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 typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Type, Union
from xml.etree.ElementTree import Element # nosec B405

import serializable
from serializable.helpers import BaseHelper
from sortedcontainers import SortedSet

from .._internal.compare import ComparableTuple as _ComparableTuple
from ..exception.serialization import CycloneDxDeserializationException
from ..serialization import BomRefHelper
from . import ExternalReference
from .bom_ref import BomRef

if TYPE_CHECKING: # pragma: no cover
from serializable import ObjectMetadataLibrary, ViewType


def bom_ref_or_str(bom_ref: Optional[Union[str, BomRef]]) -> BomRef:
if isinstance(bom_ref, BomRef):
return bom_ref
else:
return BomRef(value=str(bom_ref) if bom_ref else None)


@serializable.serializable_class(serialization_types=[
serializable.SerializationType.JSON,
serializable.SerializationType.XML]
)
class Standard:
"""
A standard of regulations, industry or organizational-specific standards, maturity models, best practices,
or any other requirements.
"""

def __init__(
self, *,
bom_ref: Optional[Union[str, BomRef]] = None,
name: Optional[str] = None,
version: Optional[str] = None,
description: Optional[str] = None,
owner: Optional[str] = None,
# TODO: requirements: Optional[Iterable[Requirement]] = None,
# TODO: levels: Optional[Iterable[Level]] = None,
external_references: Optional[Iterable['ExternalReference']] = None
) -> None:
self._bom_ref = bom_ref_or_str(bom_ref)
self.name = name
self.version = version
self.description = description
self.owner = owner
self.external_references = external_references or [] # type:ignore[assignment]

def __lt__(self, other: Any) -> bool:
if isinstance(other, Standard):
return (_ComparableTuple((self.bom_ref, self.name, self.version))
< _ComparableTuple((other.bom_ref, other.name, other.version)))
return NotImplemented

def __eq__(self, other: object) -> bool:
if isinstance(other, Standard):
return hash(other) == hash(self)
return False

def __hash__(self) -> int:
return hash((
self.bom_ref, self.name, self.version, self.description, self.owner, tuple(self.external_references)
))

def __repr__(self) -> str:
return f'<Standard bom-ref={self.bom_ref}, name={self.name}, version={self.version}, ' \
f'description={self.description}, owner={self.owner}>'

@property
@serializable.json_name('bom-ref')
@serializable.type_mapping(BomRefHelper)
@serializable.xml_attribute()
@serializable.xml_name('bom-ref')
def bom_ref(self) -> BomRef:
"""
An optional identifier which can be used to reference the standard elsewhere in the BOM. Every bom-ref MUST be
unique within the BOM. If a value was not provided in the constructor, a UUIDv4 will have been assigned.
Returns:
`BomRef`
"""
return self._bom_ref

@property
@serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING)
@serializable.xml_sequence(2)
def name(self) -> Optional[str]:
"""
Returns:
The name of the standard
"""
return self._name

@name.setter
def name(self, name: Optional[str]) -> None:
self._name = name

@property
@serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING)
@serializable.xml_sequence(3)
def version(self) -> Optional[str]:
"""
Returns:
The version of the standard
"""
return self._version

@version.setter
def version(self, version: Optional[str]) -> None:
self._version = version

@property
@serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING)
@serializable.xml_sequence(4)
def description(self) -> Optional[str]:
"""
Returns:
The description of the standard
"""
return self._description

@description.setter
def description(self, description: Optional[str]) -> None:
self._description = description

@property
@serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING)
@serializable.xml_sequence(5)
def owner(self) -> Optional[str]:
"""
Returns:
The owner of the standard, often the entity responsible for its release.
"""
return self._owner

@owner.setter
def owner(self, owner: Optional[str]) -> None:
self._owner = owner

@property
@serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'reference')
@serializable.xml_sequence(30)
def external_references(self) -> 'SortedSet[ExternalReference]':
"""
Returns:
A SortedSet of external references associated with the standard.
"""
return self._external_references

@external_references.setter
def external_references(self, external_references: Iterable[ExternalReference]) -> None:
self._external_references = SortedSet(external_references)


class DefinitionRepository:
"""
The repository for definitions
"""

def __init__(
self, *,
standards: Optional[Iterable[Standard]] = None
) -> None:
self.standards = standards or () # type:ignore[assignment]

@property
def standards(self) -> 'SortedSet[Standard]':
"""
Returns:
A SortedSet of Standards
"""
return self._standards

@standards.setter
def standards(self, standards: Iterable[Standard]) -> None:
self._standards = SortedSet(standards)

def __len__(self) -> int:
return len(self._standards)

def __bool__(self) -> bool:
return len(self._standards) > 0

def __eq__(self, other: object) -> bool:
if not isinstance(other, DefinitionRepository):
return False

return self._standards == other._standards

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

def __lt__(self, other: Any) -> bool:
if isinstance(other, DefinitionRepository):
return (_ComparableTuple(self._standards)
< _ComparableTuple(other.standards))
return NotImplemented

def __repr__(self) -> str:
return '<Definitions>'


class _DefinitionRepositoryHelper(BaseHelper):
"""
Helper class for serializing and deserializing a Definitions.
"""

@classmethod
def json_normalize(cls, o: DefinitionRepository, *,
view: Optional[Type['ViewType']],
**__: Any) -> Any:
elem: Dict[str, Any] = {}
if o.standards:
elem['standards'] = tuple(o.standards)
return elem or None

@classmethod
def json_denormalize(cls, o: Union[List[Dict[str, Any]], Dict[str, Any]],
**__: Any) -> DefinitionRepository:
standards = None
if isinstance(o, Dict):
standards = map(lambda c: Standard.from_json(c), # type:ignore[attr-defined]
o.get('standards', ()))
return DefinitionRepository(standards=standards)

@classmethod
def xml_normalize(cls, o: DefinitionRepository, *,
element_name: str,
view: Optional[Type['ViewType']],
xmlns: Optional[str],
**__: Any) -> Optional[Element]:
elem = Element(element_name)
if o.standards:
elem_s = Element(f'{{{xmlns}}}standards' if xmlns else 'standards')
elem_s.extend(
si.as_xml( # type:ignore[attr-defined]
view_=view, as_string=False, element_name='standard', xmlns=xmlns)
for si in o.standards)
elem.append(elem_s)
return elem \
if len(elem) > 0 \
else None

@classmethod
def xml_denormalize(cls, o: Element, *,
default_ns: Optional[str],
prop_info: 'ObjectMetadataLibrary.SerializableProperty',
ctx: Type[Any],
**kwargs: Any) -> DefinitionRepository:
standards = None
for e in o:
tag = e.tag if default_ns is None else e.tag.replace(f'{{{default_ns}}}', '')
if tag == 'standards':
standards = map(lambda s: Standard.from_xml( # type:ignore[attr-defined]
s, default_ns), e)
else:
raise CycloneDxDeserializationException(f'unexpected: {e!r}')
return DefinitionRepository(standards=standards)
65 changes: 65 additions & 0 deletions tests/test_model_definition_repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# 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.model.definition import DefinitionRepository, Standard


class TestModelDefinitionRepository(TestCase):

def test_init(self) -> DefinitionRepository:
s = Standard(name='test-standard')
dr = DefinitionRepository(
standards=(s, ),
)
self.assertIs(s, tuple(dr.standards)[0])
return dr

def test_filled(self) -> None:
dr = self.test_init()
self.assertEqual(1, len(dr))
self.assertTrue(dr)

def test_empty(self) -> None:
dr = DefinitionRepository()
self.assertEqual(0, len(dr))
self.assertFalse(dr)

def test_unequal_different_type(self) -> None:
dr = DefinitionRepository()
self.assertFalse(dr == 'other')

def test_equal_self(self) -> None:
dr = DefinitionRepository()
dr.standards.add(Standard(name='my-standard'))
self.assertTrue(dr == dr)

def test_unequal(self) -> None:
dr1 = DefinitionRepository()
dr1.standards.add(Standard(name='my-standard'))
tr2 = DefinitionRepository()
self.assertFalse(dr1 == tr2)

def test_equal(self) -> None:
s = Standard(name='my-standard')
dr1 = DefinitionRepository()
dr1.standards.add(s)
tr2 = DefinitionRepository()
tr2.standards.add(s)
self.assertTrue(dr1 == tr2)

0 comments on commit 3c63ce8

Please sign in to comment.