Skip to content

Commit 3c63ce8

Browse files
committed
feat: add bom.definitions
for CycloneDX#697 Signed-off-by: Hakan Dilek <[email protected]>
1 parent c72d5f4 commit 3c63ce8

File tree

3 files changed

+363
-0
lines changed

3 files changed

+363
-0
lines changed

cyclonedx/model/bom.py

+20
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
from .bom_ref import BomRef
4242
from .component import Component
4343
from .contact import OrganizationalContact, OrganizationalEntity
44+
from .definition import DefinitionRepository, _DefinitionRepositoryHelper
4445
from .dependency import Dependable, Dependency
4546
from .license import License, LicenseExpression, LicenseRepository
4647
from .service import Service
@@ -317,6 +318,7 @@ def __init__(
317318
dependencies: Optional[Iterable[Dependency]] = None,
318319
vulnerabilities: Optional[Iterable[Vulnerability]] = None,
319320
properties: Optional[Iterable[Property]] = None,
321+
definitions: Optional[DefinitionRepository] = None,
320322
) -> None:
321323
"""
322324
Create a new Bom that you can manually/programmatically add data to later.
@@ -333,6 +335,7 @@ def __init__(
333335
self.vulnerabilities = vulnerabilities or [] # type:ignore[assignment]
334336
self.dependencies = dependencies or [] # type:ignore[assignment]
335337
self.properties = properties or [] # type:ignore[assignment]
338+
self.definitions = definitions or DefinitionRepository()
336339

337340
@property
338341
@serializable.type_mapping(UrnUuidHelper)
@@ -520,6 +523,23 @@ def vulnerabilities(self) -> 'SortedSet[Vulnerability]':
520523
def vulnerabilities(self, vulnerabilities: Iterable[Vulnerability]) -> None:
521524
self._vulnerabilities = SortedSet(vulnerabilities)
522525

526+
@property
527+
@serializable.type_mapping(_DefinitionRepositoryHelper)
528+
@serializable.view(SchemaVersion1Dot6)
529+
@serializable.xml_sequence(90)
530+
def definitions(self) -> Optional[DefinitionRepository]:
531+
"""
532+
The repository for definitions
533+
534+
Returns:
535+
`DefinitionRepository`
536+
"""
537+
return self._definitions
538+
539+
@definitions.setter
540+
def definitions(self, definitions: DefinitionRepository) -> None:
541+
self._definitions = definitions
542+
523543
# @property
524544
# ...
525545
# @serializable.view(SchemaVersion1Dot5)

cyclonedx/model/definition.py

+278
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
# This file is part of CycloneDX Python Library
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
# SPDX-License-Identifier: Apache-2.0
16+
# Copyright (c) OWASP Foundation. All Rights Reserved.
17+
18+
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Type, Union
19+
from xml.etree.ElementTree import Element # nosec B405
20+
21+
import serializable
22+
from serializable.helpers import BaseHelper
23+
from sortedcontainers import SortedSet
24+
25+
from .._internal.compare import ComparableTuple as _ComparableTuple
26+
from ..exception.serialization import CycloneDxDeserializationException
27+
from ..serialization import BomRefHelper
28+
from . import ExternalReference
29+
from .bom_ref import BomRef
30+
31+
if TYPE_CHECKING: # pragma: no cover
32+
from serializable import ObjectMetadataLibrary, ViewType
33+
34+
35+
def bom_ref_or_str(bom_ref: Optional[Union[str, BomRef]]) -> BomRef:
36+
if isinstance(bom_ref, BomRef):
37+
return bom_ref
38+
else:
39+
return BomRef(value=str(bom_ref) if bom_ref else None)
40+
41+
42+
@serializable.serializable_class(serialization_types=[
43+
serializable.SerializationType.JSON,
44+
serializable.SerializationType.XML]
45+
)
46+
class Standard:
47+
"""
48+
A standard of regulations, industry or organizational-specific standards, maturity models, best practices,
49+
or any other requirements.
50+
"""
51+
52+
def __init__(
53+
self, *,
54+
bom_ref: Optional[Union[str, BomRef]] = None,
55+
name: Optional[str] = None,
56+
version: Optional[str] = None,
57+
description: Optional[str] = None,
58+
owner: Optional[str] = None,
59+
# TODO: requirements: Optional[Iterable[Requirement]] = None,
60+
# TODO: levels: Optional[Iterable[Level]] = None,
61+
external_references: Optional[Iterable['ExternalReference']] = None
62+
) -> None:
63+
self._bom_ref = bom_ref_or_str(bom_ref)
64+
self.name = name
65+
self.version = version
66+
self.description = description
67+
self.owner = owner
68+
self.external_references = external_references or [] # type:ignore[assignment]
69+
70+
def __lt__(self, other: Any) -> bool:
71+
if isinstance(other, Standard):
72+
return (_ComparableTuple((self.bom_ref, self.name, self.version))
73+
< _ComparableTuple((other.bom_ref, other.name, other.version)))
74+
return NotImplemented
75+
76+
def __eq__(self, other: object) -> bool:
77+
if isinstance(other, Standard):
78+
return hash(other) == hash(self)
79+
return False
80+
81+
def __hash__(self) -> int:
82+
return hash((
83+
self.bom_ref, self.name, self.version, self.description, self.owner, tuple(self.external_references)
84+
))
85+
86+
def __repr__(self) -> str:
87+
return f'<Standard bom-ref={self.bom_ref}, name={self.name}, version={self.version}, ' \
88+
f'description={self.description}, owner={self.owner}>'
89+
90+
@property
91+
@serializable.json_name('bom-ref')
92+
@serializable.type_mapping(BomRefHelper)
93+
@serializable.xml_attribute()
94+
@serializable.xml_name('bom-ref')
95+
def bom_ref(self) -> BomRef:
96+
"""
97+
An optional identifier which can be used to reference the standard elsewhere in the BOM. Every bom-ref MUST be
98+
unique within the BOM. If a value was not provided in the constructor, a UUIDv4 will have been assigned.
99+
Returns:
100+
`BomRef`
101+
"""
102+
return self._bom_ref
103+
104+
@property
105+
@serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING)
106+
@serializable.xml_sequence(2)
107+
def name(self) -> Optional[str]:
108+
"""
109+
Returns:
110+
The name of the standard
111+
"""
112+
return self._name
113+
114+
@name.setter
115+
def name(self, name: Optional[str]) -> None:
116+
self._name = name
117+
118+
@property
119+
@serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING)
120+
@serializable.xml_sequence(3)
121+
def version(self) -> Optional[str]:
122+
"""
123+
Returns:
124+
The version of the standard
125+
"""
126+
return self._version
127+
128+
@version.setter
129+
def version(self, version: Optional[str]) -> None:
130+
self._version = version
131+
132+
@property
133+
@serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING)
134+
@serializable.xml_sequence(4)
135+
def description(self) -> Optional[str]:
136+
"""
137+
Returns:
138+
The description of the standard
139+
"""
140+
return self._description
141+
142+
@description.setter
143+
def description(self, description: Optional[str]) -> None:
144+
self._description = description
145+
146+
@property
147+
@serializable.xml_string(serializable.XmlStringSerializationType.NORMALIZED_STRING)
148+
@serializable.xml_sequence(5)
149+
def owner(self) -> Optional[str]:
150+
"""
151+
Returns:
152+
The owner of the standard, often the entity responsible for its release.
153+
"""
154+
return self._owner
155+
156+
@owner.setter
157+
def owner(self, owner: Optional[str]) -> None:
158+
self._owner = owner
159+
160+
@property
161+
@serializable.xml_array(serializable.XmlArraySerializationType.NESTED, 'reference')
162+
@serializable.xml_sequence(30)
163+
def external_references(self) -> 'SortedSet[ExternalReference]':
164+
"""
165+
Returns:
166+
A SortedSet of external references associated with the standard.
167+
"""
168+
return self._external_references
169+
170+
@external_references.setter
171+
def external_references(self, external_references: Iterable[ExternalReference]) -> None:
172+
self._external_references = SortedSet(external_references)
173+
174+
175+
class DefinitionRepository:
176+
"""
177+
The repository for definitions
178+
"""
179+
180+
def __init__(
181+
self, *,
182+
standards: Optional[Iterable[Standard]] = None
183+
) -> None:
184+
self.standards = standards or () # type:ignore[assignment]
185+
186+
@property
187+
def standards(self) -> 'SortedSet[Standard]':
188+
"""
189+
Returns:
190+
A SortedSet of Standards
191+
"""
192+
return self._standards
193+
194+
@standards.setter
195+
def standards(self, standards: Iterable[Standard]) -> None:
196+
self._standards = SortedSet(standards)
197+
198+
def __len__(self) -> int:
199+
return len(self._standards)
200+
201+
def __bool__(self) -> bool:
202+
return len(self._standards) > 0
203+
204+
def __eq__(self, other: object) -> bool:
205+
if not isinstance(other, DefinitionRepository):
206+
return False
207+
208+
return self._standards == other._standards
209+
210+
def __hash__(self) -> int:
211+
return hash((tuple(self._standards)))
212+
213+
def __lt__(self, other: Any) -> bool:
214+
if isinstance(other, DefinitionRepository):
215+
return (_ComparableTuple(self._standards)
216+
< _ComparableTuple(other.standards))
217+
return NotImplemented
218+
219+
def __repr__(self) -> str:
220+
return '<Definitions>'
221+
222+
223+
class _DefinitionRepositoryHelper(BaseHelper):
224+
"""
225+
Helper class for serializing and deserializing a Definitions.
226+
"""
227+
228+
@classmethod
229+
def json_normalize(cls, o: DefinitionRepository, *,
230+
view: Optional[Type['ViewType']],
231+
**__: Any) -> Any:
232+
elem: Dict[str, Any] = {}
233+
if o.standards:
234+
elem['standards'] = tuple(o.standards)
235+
return elem or None
236+
237+
@classmethod
238+
def json_denormalize(cls, o: Union[List[Dict[str, Any]], Dict[str, Any]],
239+
**__: Any) -> DefinitionRepository:
240+
standards = None
241+
if isinstance(o, Dict):
242+
standards = map(lambda c: Standard.from_json(c), # type:ignore[attr-defined]
243+
o.get('standards', ()))
244+
return DefinitionRepository(standards=standards)
245+
246+
@classmethod
247+
def xml_normalize(cls, o: DefinitionRepository, *,
248+
element_name: str,
249+
view: Optional[Type['ViewType']],
250+
xmlns: Optional[str],
251+
**__: Any) -> Optional[Element]:
252+
elem = Element(element_name)
253+
if o.standards:
254+
elem_s = Element(f'{{{xmlns}}}standards' if xmlns else 'standards')
255+
elem_s.extend(
256+
si.as_xml( # type:ignore[attr-defined]
257+
view_=view, as_string=False, element_name='standard', xmlns=xmlns)
258+
for si in o.standards)
259+
elem.append(elem_s)
260+
return elem \
261+
if len(elem) > 0 \
262+
else None
263+
264+
@classmethod
265+
def xml_denormalize(cls, o: Element, *,
266+
default_ns: Optional[str],
267+
prop_info: 'ObjectMetadataLibrary.SerializableProperty',
268+
ctx: Type[Any],
269+
**kwargs: Any) -> DefinitionRepository:
270+
standards = None
271+
for e in o:
272+
tag = e.tag if default_ns is None else e.tag.replace(f'{{{default_ns}}}', '')
273+
if tag == 'standards':
274+
standards = map(lambda s: Standard.from_xml( # type:ignore[attr-defined]
275+
s, default_ns), e)
276+
else:
277+
raise CycloneDxDeserializationException(f'unexpected: {e!r}')
278+
return DefinitionRepository(standards=standards)
+65
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# This file is part of CycloneDX Python Library
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
# SPDX-License-Identifier: Apache-2.0
16+
# Copyright (c) OWASP Foundation. All Rights Reserved.
17+
18+
19+
from unittest import TestCase
20+
21+
from cyclonedx.model.definition import DefinitionRepository, Standard
22+
23+
24+
class TestModelDefinitionRepository(TestCase):
25+
26+
def test_init(self) -> DefinitionRepository:
27+
s = Standard(name='test-standard')
28+
dr = DefinitionRepository(
29+
standards=(s, ),
30+
)
31+
self.assertIs(s, tuple(dr.standards)[0])
32+
return dr
33+
34+
def test_filled(self) -> None:
35+
dr = self.test_init()
36+
self.assertEqual(1, len(dr))
37+
self.assertTrue(dr)
38+
39+
def test_empty(self) -> None:
40+
dr = DefinitionRepository()
41+
self.assertEqual(0, len(dr))
42+
self.assertFalse(dr)
43+
44+
def test_unequal_different_type(self) -> None:
45+
dr = DefinitionRepository()
46+
self.assertFalse(dr == 'other')
47+
48+
def test_equal_self(self) -> None:
49+
dr = DefinitionRepository()
50+
dr.standards.add(Standard(name='my-standard'))
51+
self.assertTrue(dr == dr)
52+
53+
def test_unequal(self) -> None:
54+
dr1 = DefinitionRepository()
55+
dr1.standards.add(Standard(name='my-standard'))
56+
tr2 = DefinitionRepository()
57+
self.assertFalse(dr1 == tr2)
58+
59+
def test_equal(self) -> None:
60+
s = Standard(name='my-standard')
61+
dr1 = DefinitionRepository()
62+
dr1.standards.add(s)
63+
tr2 = DefinitionRepository()
64+
tr2.standards.add(s)
65+
self.assertTrue(dr1 == tr2)

0 commit comments

Comments
 (0)