diff --git a/cyclonedx/model/bom.py b/cyclonedx/model/bom.py index 925a105d..5e9e5402 100644 --- a/cyclonedx/model/bom.py +++ b/cyclonedx/model/bom.py @@ -1,8 +1,25 @@ +import datetime from typing import List from .cyclonedx import Component from ..parser import BaseParser +class BomMetaData: + """ + Our internal representation of the metadata complex type within the CycloneDX standard. + + See https://cyclonedx.org/docs/1.3/#type_metadata + """ + + _timestamp: datetime.datetime + + def __init__(self): + self._timestamp = datetime.datetime.now(tz=datetime.timezone.utc) + + def get_timestamp(self) -> datetime.datetime: + return self._timestamp + + class Bom: """ This is our internal representation of the BOM. @@ -11,6 +28,7 @@ class Bom: to the requested schema version. """ + _metadata: BomMetaData = None _components: List[Component] = [] @staticmethod @@ -20,6 +38,7 @@ def from_parser(parser: BaseParser): return bom def __init__(self): + self._metadata = BomMetaData() self._components.clear() def add_component(self, component: Component): @@ -34,5 +53,8 @@ def component_count(self) -> int: def get_components(self) -> List[Component]: return self._components + def get_metadata(self) -> BomMetaData: + return self._metadata + def has_component(self, component: Component) -> bool: return component in self._components diff --git a/cyclonedx/output/xml.py b/cyclonedx/output/xml.py index a90a705d..f150d0bb 100644 --- a/cyclonedx/output/xml.py +++ b/cyclonedx/output/xml.py @@ -33,10 +33,14 @@ def _xml_pretty_print(elem: ElementTree.Element, level: int = 0) -> ElementTree. class Xml(BaseOutput): - XML_VERSION_DECLARATION: str = '\n' + XML_VERSION_DECLARATION: str = '' + + def get_target_namespace(self) -> str: + return 'http://cyclonedx.org/schema/bom/{}'.format(self._get_schema_version()) def output_as_string(self) -> str: - bom = ElementTree.Element('bom', {'xmlns': self._get_target_namespace(), 'version': '1'}) + bom = ElementTree.Element('bom', {'xmlns': self.get_target_namespace(), 'version': '1'}) + bom = self._add_metadata(bom=bom) components = ElementTree.SubElement(bom, 'components') for component in self.get_bom().get_components(): components.append(Xml._get_component_as_xml_element(component=component)) @@ -83,12 +87,16 @@ def _get_component_as_xml_element(component: Component) -> ElementTree.Element: return element + def _add_metadata(self, bom: ElementTree.Element) -> ElementTree.Element: + metadata_e = ElementTree.SubElement(bom, 'metadata') + ElementTree.SubElement(metadata_e, 'timestamp').text = self.get_bom().get_metadata().get_timestamp().isoformat() + return bom + @abstractmethod def _get_schema_version(self) -> str: pass - def _get_target_namespace(self) -> str: - return 'http://cyclonedx.org/schema/bom/{}'.format(self._get_schema_version()) + class XmlV1Dot2(Xml): diff --git a/tests/base.py b/tests/base.py index ce37dc5a..a4cddbc6 100644 --- a/tests/base.py +++ b/tests/base.py @@ -1,6 +1,8 @@ +import xml.etree.ElementTree from unittest import TestCase import json +from datetime import datetime, timezone from xml.dom import minidom @@ -28,7 +30,28 @@ class BaseXmlTestCase(TestCase): def assertEqualXml(self, a: str, b: str): da, db = minidom.parseString(a), minidom.parseString(b) - self.assertTrue(self._is_equal_xml_element(da.documentElement, db.documentElement)) + self.assertTrue(self._is_equal_xml_element(da.documentElement, db.documentElement), + 'XML Documents are not equal: \n{}\n{}'.format(da.toxml(), db.toxml())) + + def assertEqualXmlBom(self, a: str, b: str, namespace: str): + """ + Sanitise some fields such as timestamps which cannot have their values directly compared for equality. + """ + ba, bb = xml.etree.ElementTree.fromstring(a), xml.etree.ElementTree.fromstring(b) + + now = datetime.now(tz=timezone.utc) + metadata_ts_a = ba.find('./{{{}}}metadata/{{{}}}timestamp'.format(namespace, namespace)) + if metadata_ts_a is not None: + metadata_ts_a.text = now.isoformat() + + metadata_ts_b = bb.find('./{{{}}}metadata/{{{}}}timestamp'.format(namespace, namespace)) + if metadata_ts_b is not None: + metadata_ts_b.text = now.isoformat() + + self.assertEqualXml( + xml.etree.ElementTree.tostring(ba, 'unicode'), + xml.etree.ElementTree.tostring(bb, 'unicode') + ) def _is_equal_xml_element(self, a, b): if a.tagName != b.tagName: diff --git a/tests/fixtures/bom_v1.2_setuptools.xml b/tests/fixtures/bom_v1.2_setuptools.xml index 694739cb..9030abd7 100644 --- a/tests/fixtures/bom_v1.2_setuptools.xml +++ b/tests/fixtures/bom_v1.2_setuptools.xml @@ -1,5 +1,8 @@ + + 2021-09-01T10:50:42.051979+00:00 + setuptools diff --git a/tests/fixtures/bom_v1.3_setuptools.xml b/tests/fixtures/bom_v1.3_setuptools.xml index 7e116b29..420b8dbe 100644 --- a/tests/fixtures/bom_v1.3_setuptools.xml +++ b/tests/fixtures/bom_v1.3_setuptools.xml @@ -1,5 +1,8 @@ + + 2021-09-01T10:50:42.051979+00:00 + setuptools diff --git a/tests/test_output_xml.py b/tests/test_output_xml.py index 032f40e9..5989f6ae 100644 --- a/tests/test_output_xml.py +++ b/tests/test_output_xml.py @@ -3,7 +3,7 @@ from cyclonedx.model.bom import Bom from cyclonedx.model.cyclonedx import Component from cyclonedx.output import get_instance, SchemaVersion -from cyclonedx.output.xml import XmlV1Dot3, XmlV1Dot2 +from cyclonedx.output.xml import XmlV1Dot3, XmlV1Dot2, Xml from tests.base import BaseXmlTestCase @@ -13,10 +13,11 @@ class TestOutputXml(BaseXmlTestCase): def test_simple_bom_v1_3(self): bom = Bom() bom.add_component(Component(name='setuptools', version='50.3.2', qualifiers='extension=tar.gz')) - outputter = get_instance(bom=bom) + outputter: Xml = get_instance(bom=bom) self.assertIsInstance(outputter, XmlV1Dot3) with open(join(dirname(__file__), 'fixtures/bom_v1.3_setuptools.xml')) as expected_xml: - self.assertEqualXml(outputter.output_as_string(), expected_xml.read()) + self.assertEqualXmlBom(a=outputter.output_as_string(), b=expected_xml.read(), + namespace=outputter.get_target_namespace()) expected_xml.close() def test_simple_bom_v1_2(self): @@ -25,5 +26,6 @@ def test_simple_bom_v1_2(self): outputter = get_instance(bom=bom, schema_version=SchemaVersion.V1_2) self.assertIsInstance(outputter, XmlV1Dot2) with open(join(dirname(__file__), 'fixtures/bom_v1.2_setuptools.xml')) as expected_xml: - self.assertEqualXml(outputter.output_as_string(), expected_xml.read()) + self.assertEqualXmlBom(outputter.output_as_string(), expected_xml.read(), + namespace=outputter.get_target_namespace()) expected_xml.close()