Repository: CycloneDX/cyclonedx-core-java (java)

Issue:
Hashes not serialized in external references
I suspect that there might by something wrong with the `BomJsonGenerator14`.  
It does not output hashes which are part of external references.

**Steps to reproduce**

The following complete Java program, using

- cyclonedx-core-java v7.1.5
- Windows 10 64bit
- Java 8

```java
import java.util.Arrays;

import org.cyclonedx.generators.json.BomJsonGenerator14;
import org.cyclonedx.model.Bom;
import org.cyclonedx.model.Component;
import org.cyclonedx.model.ExternalReference;
import org.cyclonedx.model.Hash;


class Scratch
{
    public static void main(String[] args)
    {
        ExternalReference extRef = new ExternalReference();
        extRef.setType(ExternalReference.Type.BOM);
        extRef.setUrl("https://example.org/support/sbom/portal-server/1.0.0");
        extRef.setComment("An external SBOM that describes what this component includes");
        Hash md5 = new Hash(Hash.Algorithm.MD5, "2cd42512b65500dc7ba0ff13490b0b73");
        Hash sha1 = new Hash(Hash.Algorithm.SHA1, "226247b40160f2892fa4c7851b5b913d5d10912d");
        Hash sha256 = new Hash(Hash.Algorithm.SHA_256, "09a72795a920c1a9c0209cfb8395f8d97089832d249cba8c0938a3423b3ed1d1");
        extRef.setHashes(Arrays.asList(md5, sha1, sha256));

        Component component = new Component();
        component.setGroup("org.example");
        component.setName("mylibrary");
        component.setType(Component.Type.LIBRARY);
        component.setVersion("1.0.0");
        component.addExternalReference(extRef);

        Bom bom = new Bom();
        bom.addComponent(component);

        String json = new BomJsonGenerator14(bom).toJsonString();
        System.out.println(json);
    }
}
```

Here's the **actual output (green)** vs. the **expected output (red)**:

```diff
{
  "bomFormat" : "CycloneDX",
  "specVersion" : "1.4",
  "version" : 1,
  "components" : [
    {
      "group" : "org.example",
      "name" : "mylibrary",
      "version" : "1.0.0",
      "externalReferences" : [
        {
          "type" : "bom",
          "url" : "https://example.org/support/sbom/portal-server/1.0.0",
-          "comment" : "An external SBOM that describes what this component includes",
+          "comment" : "An external SBOM that describes what this component includes"
-          "hashes" : [
-            {
-              "alg" : "MD5",
-              "content" : "2cd42512b65500dc7ba0ff13490b0b73"
-            }, {
-              "alg" : "SHA-1",
-              "content" : "226247b40160f2892fa4c7851b5b913d5d10912d"
-            }, {
-              "alg" : "SHA-256",
-              "content" : "09a72795a920c1a9c0209cfb8395f8d97089832d249cba8c0938a3423b3ed1d1"
-            }
-          ]
        }
      ],
      "type" : "library"
    }
  ]
}
```

I found some [test data](https://github.com/CycloneDX/cyclonedx-core-java/blob/cyclonedx-core-java-7.1.5/src/test/resources/1.4/valid-external-reference-1.4.json#L23-L28) which indicates that my code above should work, but it seems the corresponding tests only test parsing it, not generating. When I look at the objects in a debugger just before serialization, they look fine and include the hashes. However, the generated JSON does not include the hashes.

Please let me know if you need anything else or if I can assist somehow. Thanks for looking into this!

Pull request description:
Add serialization of hashes in external references
Fixes #214

This PR includes the fix for #214, and a regression test for the schema versions that support hashes in external references.

Hope this helps!

Patch:
diff --git a/src/main/java/org/cyclonedx/util/ExternalReferenceSerializer.java b/src/main/java/org/cyclonedx/util/ExternalReferenceSerializer.java
index 7b9139748..4e2686301 100644
--- a/src/main/java/org/cyclonedx/util/ExternalReferenceSerializer.java
+++ b/src/main/java/org/cyclonedx/util/ExternalReferenceSerializer.java
@@ -20,7 +20,6 @@ package org.cyclonedx.util;
 
 import java.io.IOException;
 import java.util.function.BiPredicate;
-
 import javax.xml.stream.XMLStreamException;
 import javax.xml.stream.XMLStreamWriter;
 
@@ -30,6 +29,8 @@ import com.fasterxml.jackson.databind.ser.std.StdSerializer;
 import com.fasterxml.jackson.dataformat.xml.ser.ToXmlGenerator;
 import org.cyclonedx.model.ExternalReference;
 import org.cyclonedx.model.ExternalReference.Type;
+import org.cyclonedx.model.Hash;
+
 
 public class ExternalReferenceSerializer extends StdSerializer<ExternalReference>
 {
@@ -62,6 +63,18 @@ public class ExternalReferenceSerializer extends StdSerializer<ExternalReference
             staxWriter.writeCharacters(extRef.getComment());
             staxWriter.writeEndElement();
           }
+          if (extRef.getHashes() != null && !extRef.getHashes().isEmpty()) {
+            staxWriter.writeStartElement("hashes");
+            for (Hash hash : extRef.getHashes()) {
+              if (hash != null) {
+                staxWriter.writeStartElement("hash");
+                staxWriter.writeAttribute("alg", hash.getAlgorithm());
+                staxWriter.writeCharacters(hash.getValue());
+                staxWriter.writeEndElement();
+              }
+            }
+            staxWriter.writeEndElement();
+          }
           staxWriter.writeEndElement();
         }
         catch (XMLStreamException ex) {
@@ -75,6 +88,9 @@ public class ExternalReferenceSerializer extends StdSerializer<ExternalReference
       if (extRef.getComment() != null) {
         gen.writeStringField("comment", extRef.getComment());
       }
+      if (extRef.getHashes() != null && !extRef.getHashes().isEmpty()) {
+        gen.writePOJOField("hashes", extRef.getHashes());
+      }
       gen.writeEndObject();
     }
   }


Test patch:
diff --git a/src/test/java/org/cyclonedx/Issue214RegressionTest.java b/src/test/java/org/cyclonedx/Issue214RegressionTest.java
new file mode 100644
index 000000000..fcd63d639
--- /dev/null
+++ b/src/test/java/org/cyclonedx/Issue214RegressionTest.java
@@ -0,0 +1,218 @@
+package org.cyclonedx;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.StringWriter;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.regex.Pattern;
+import javax.xml.XMLConstants;
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.transform.OutputKeys;
+import javax.xml.transform.Transformer;
+import javax.xml.transform.TransformerException;
+import javax.xml.transform.TransformerFactory;
+import javax.xml.transform.dom.DOMSource;
+import javax.xml.transform.stream.StreamResult;
+
+import org.apache.commons.io.IOUtils;
+import org.cyclonedx.generators.json.AbstractBomJsonGenerator;
+import org.cyclonedx.generators.json.BomJsonGenerator;
+import org.cyclonedx.generators.json.BomJsonGenerator13;
+import org.cyclonedx.generators.json.BomJsonGenerator14;
+import org.cyclonedx.generators.xml.AbstractBomXmlGenerator;
+import org.cyclonedx.generators.xml.BomXmlGenerator;
+import org.cyclonedx.generators.xml.BomXmlGenerator13;
+import org.cyclonedx.generators.xml.BomXmlGenerator14;
+import org.cyclonedx.model.Bom;
+import org.cyclonedx.model.Component;
+import org.cyclonedx.model.ExternalReference;
+import org.cyclonedx.model.Hash;
+import org.cyclonedx.parsers.JsonParser;
+import org.cyclonedx.parsers.Parser;
+import org.cyclonedx.parsers.XmlParser;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.w3c.dom.Document;
+
+
+public class Issue214RegressionTest
+{
+    @Test
+    public void schema13JsonObjectGenerationTest()
+        throws IOException, ReflectiveOperationException
+    {
+        performJsonTest(CycloneDxSchema.Version.VERSION_13, BomJsonGenerator13.class);
+    }
+
+
+
+    @Test
+    public void schema14JsonObjectGenerationTest()
+        throws IOException, ReflectiveOperationException
+    {
+        performJsonTest(CycloneDxSchema.Version.VERSION_14, BomJsonGenerator14.class);
+    }
+
+
+
+    @Test
+    public void schema13XmlObjectGenerationTest()
+        throws ParserConfigurationException, IOException, ReflectiveOperationException
+    {
+        performXmlTest(CycloneDxSchema.Version.VERSION_13, BomXmlGenerator13.class);
+    }
+
+
+
+    @Test
+    public void schema14XmlObjectGenerationTest()
+        throws ParserConfigurationException, IOException, ReflectiveOperationException
+    {
+        performXmlTest(CycloneDxSchema.Version.VERSION_14, BomXmlGenerator14.class);
+    }
+
+
+
+    private <G extends AbstractBomXmlGenerator> void performXmlTest(final CycloneDxSchema.Version pSpecVersion,
+        final Class<G> pExpectedGeneratorClass)
+        throws ParserConfigurationException, IOException, ReflectiveOperationException
+    {
+        final Bom inputBom = createIssue214Bom();
+        BomXmlGenerator generator = BomGeneratorFactory.createXml(pSpecVersion, inputBom);
+        Document doc = generator.generate();
+
+        Assertions.assertTrue(pExpectedGeneratorClass.isAssignableFrom(generator.getClass()));
+        Assertions.assertEquals(pSpecVersion, generator.getSchemaVersion());
+
+        final String actual = xmlDocumentToString(doc);
+        final String expected = readFixture("/regression/issue214-expected-output.xml", pSpecVersion);
+        Assertions.assertEquals(expected, actual);
+        validate(actual, XmlParser.class, pSpecVersion);
+    }
+
+
+
+    private <G extends AbstractBomJsonGenerator> void performJsonTest(final CycloneDxSchema.Version pSpecVersion,
+        final Class<G> pExpectedGeneratorClass)
+        throws IOException, ReflectiveOperationException
+    {
+        final Bom inputBom = createIssue214Bom();
+        BomJsonGenerator generator = BomGeneratorFactory.createJson(pSpecVersion, inputBom);
+
+        Assertions.assertTrue(pExpectedGeneratorClass.isAssignableFrom(generator.getClass()));
+        Assertions.assertEquals(pSpecVersion, generator.getSchemaVersion());
+
+        final String actual = generator.toJsonString().trim();
+        final String expected = readFixture("/regression/issue214-expected-output.json", pSpecVersion);
+        Assertions.assertEquals(expected, actual);
+        validate(actual, JsonParser.class, pSpecVersion);
+    }
+
+
+
+    private String xmlDocumentToString(final Document doc)
+    {
+        Assertions.assertNotNull(doc);
+        TransformerFactory tf = TransformerFactory.newInstance();
+        Transformer transformer;
+        try {
+            tf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
+            transformer = tf.newTransformer();
+
+            transformer.setOutputProperty(OutputKeys.ENCODING, StandardCharsets.UTF_8.name());
+            transformer.setOutputProperty(OutputKeys.INDENT, "yes");
+            transformer.setOutputProperty(OutputKeys.DOCTYPE_PUBLIC, "yes");
+            transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "4");
+
+            StringWriter sw = new StringWriter();
+            transformer.transform(new DOMSource(doc), new StreamResult(sw));
+            return sw.getBuffer().toString().trim();
+        }
+        catch (TransformerException ex) {
+            Assertions.fail("Failed to serialize XML document", ex);
+        }
+        return null;
+    }
+
+
+
+    private String readFixture(final String pPath, final CycloneDxSchema.Version pSpecVersion)
+    {
+        try (InputStream is = getClass().getResourceAsStream(pPath)) {
+            if (is != null) {
+                String result = IOUtils.toString(is, StandardCharsets.UTF_8);
+                result = result.replaceAll(Pattern.quote("${specVersion}"), pSpecVersion.getVersionString());
+                return result.trim();
+            }
+            else {
+                Assertions.fail("failed to read expected data file: " + pPath);
+            }
+        }
+        catch (IOException e) {
+            Assertions.fail("failed to read expected data file: " + pPath, e);
+        }
+        return null;
+    }
+
+
+
+    private Bom createIssue214Bom()
+    {
+        ExternalReference extRef = new ExternalReference();
+        extRef.setType(ExternalReference.Type.BOM);
+        extRef.setUrl("https://example.org/support/sbom/portal-server/1.0.0");
+        extRef.setComment("An external SBOM that describes what this component includes");
+        Hash md5 = new Hash(Hash.Algorithm.MD5, "2cd42512b65500dc7ba0ff13490b0b73");
+        Hash sha1 = new Hash(Hash.Algorithm.SHA1, "226247b40160f2892fa4c7851b5b913d5d10912d");
+        Hash sha256 = new Hash(Hash.Algorithm.SHA_256,
+            "09a72795a920c1a9c0209cfb8395f8d97089832d249cba8c0938a3423b3ed1d1");
+        extRef.setHashes(Arrays.asList(md5, sha1, sha256));
+
+        Component component = new Component();
+        component.setGroup("org.example");
+        component.setName("mylibrary");
+        component.setType(Component.Type.LIBRARY);
+        component.setVersion("1.0.0");
+        component.addExternalReference(extRef);
+
+        Bom bom = new Bom();
+        bom.addComponent(component);
+
+        return bom;
+    }
+
+
+
+    private <P extends Parser> void validate(final String pDocument, final Class<P> pParserType,
+        final CycloneDxSchema.Version pSpecVersion)
+        throws IOException, ReflectiveOperationException
+    {
+        File tempFile = null;
+        try {
+            tempFile = writeToFile(pDocument, pParserType.isAssignableFrom(JsonParser.class) ? "json" : "xml");
+            P parser = pParserType.newInstance();
+            Assertions.assertTrue(parser.isValid(tempFile, pSpecVersion));
+        }
+        finally {
+            if (tempFile != null) {
+                //noinspection ResultOfMethodCallIgnored
+                tempFile.delete();
+            }
+        }
+    }
+
+
+
+    private File writeToFile(final String pDocument, final String pSuffix)
+        throws IOException
+    {
+        File tempFile = File.createTempFile("cyclonedx-core-java-test-", "." + pSuffix);
+        try (FileWriter writer = new FileWriter(tempFile)) {
+            writer.write(pDocument);
+        }
+        return tempFile;
+    }
+}
diff --git a/src/test/resources/regression/issue214-expected-output.json b/src/test/resources/regression/issue214-expected-output.json
new file mode 100644
index 000000000..86474430c
--- /dev/null
+++ b/src/test/resources/regression/issue214-expected-output.json
@@ -0,0 +1,34 @@
+{
+  "bomFormat" : "CycloneDX",
+  "specVersion" : "${specVersion}",
+  "version" : 1,
+  "components" : [
+    {
+      "group" : "org.example",
+      "name" : "mylibrary",
+      "version" : "1.0.0",
+      "externalReferences" : [
+        {
+          "type" : "bom",
+          "url" : "https://example.org/support/sbom/portal-server/1.0.0",
+          "comment" : "An external SBOM that describes what this component includes",
+          "hashes" : [
+            {
+              "alg" : "MD5",
+              "content" : "2cd42512b65500dc7ba0ff13490b0b73"
+            },
+            {
+              "alg" : "SHA-1",
+              "content" : "226247b40160f2892fa4c7851b5b913d5d10912d"
+            },
+            {
+              "alg" : "SHA-256",
+              "content" : "09a72795a920c1a9c0209cfb8395f8d97089832d249cba8c0938a3423b3ed1d1"
+            }
+          ]
+        }
+      ],
+      "type" : "library"
+    }
+  ]
+}
diff --git a/src/test/resources/regression/issue214-expected-output.xml b/src/test/resources/regression/issue214-expected-output.xml
new file mode 100644
index 000000000..66affdde3
--- /dev/null
+++ b/src/test/resources/regression/issue214-expected-output.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<bom xmlns="http://cyclonedx.org/schema/bom/${specVersion}" version="1">
+    <components>
+        <component type="library">
+            <group>org.example</group>
+            <name>mylibrary</name>
+            <version>1.0.0</version>
+            <externalReferences>
+                <reference type="bom">
+                    <url>https://example.org/support/sbom/portal-server/1.0.0</url>
+                    <comment>An external SBOM that describes what this component includes</comment>
+                    <hashes>
+                        <hash alg="MD5">2cd42512b65500dc7ba0ff13490b0b73</hash>
+                        <hash alg="SHA-1">226247b40160f2892fa4c7851b5b913d5d10912d</hash>
+                        <hash alg="SHA-256">09a72795a920c1a9c0209cfb8395f8d97089832d249cba8c0938a3423b3ed1d1</hash>
+                    </hashes>
+                </reference>
+            </externalReferences>
+        </component>
+    </components>
+</bom>