Repository: CycloneDX/cyclonedx-core-java
Pull request #568 (2024-12-09 22:30:07)

Fix incorrect (de-) serialization of `tools`
Fixes https://github.com/CycloneDX/cyclonedx-core-java/issues/562 and https://github.com/CycloneDX/cyclonedx-core-java/issues/492

codacy-production[bot]: ## Coverage summary from Codacy
### [See diff coverage on Codacy](https://app.codacy.com/gh/CycloneDX/cyclonedx-core-java/pull-requests/568/coverage?utm_source=github.com&utm_medium=coverageSummary&utm_campaign=coverageSummaryPullRequest)

 | Coverage variation | Diff coverage | 
 | ------------- | ------------- |
 | :white_check_mark: **+0.08%** | :white_check_mark: **91.30%** |

<details>
<summary>Coverage variation details</summary>

| | Coverable lines | Covered lines | Coverage |
| ------------- | ------------- | ------------- | ------------- |
| Common ancestor commit (2e3b7af0ce4256926a768daf002089cb4f6ac10a) | 6358 | 4745 | 74.63% | |
| Head commit (b87c60cfbe1ccacd6823f78e959bab57c8a9fa85) | 6369 (+11) | 4758 (+13) | 74.71% (**+0.08%**) |

**Coverage variation** is the difference between the coverage for the head and common ancestor commits of the pull request branch: `<coverage of head commit> - <coverage of common ancestor commit>`
</details>

<details>
<summary>Diff coverage details</summary>

| | Coverable lines | Covered lines | Diff coverage |
| ------------- | ------------- | ------------- | ------------- |
| Pull request (#568) | 23 | 21 | **91.30%** |

**Diff coverage** is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified: `<covered lines added or modified>/<coverable lines added or modified> * 100%`
</details>

#### [See your quality gate settings](https://app.codacy.com/gh/CycloneDX/cyclonedx-core-java/settings/quality?utm_source=github.com&utm_medium=coverageSummary&utm_campaign=coverageSummaryGates)&nbsp;&nbsp;&nbsp;&nbsp;[Change summary preferences](https://app.codacy.com/gh/CycloneDX/cyclonedx-core-java/settings/integrations?utm_source=github.com&utm_medium=coverageSummary&utm_campaign=coverageSummarySettings)
<sub>Codacy stopped sending the deprecated coverage status on June 5th, 2024. [Learn more](https://docs.codacy.com/release-notes/cloud/cloud-2023-11-23-new-coverage-engine-status-checks/#deprecation-and-removal-of-the-old-coverage-engine-status-checks)</sub>

<!-- 108f945d-e553-4918-8ccf-027c6d4acc2c -->

Patch:
diff --git a/src/main/java/org/cyclonedx/util/deserializer/ToolInformationDeserializer.java b/src/main/java/org/cyclonedx/util/deserializer/ToolInformationDeserializer.java
index 001967945..8f7c642ed 100644
--- a/src/main/java/org/cyclonedx/util/deserializer/ToolInformationDeserializer.java
+++ b/src/main/java/org/cyclonedx/util/deserializer/ToolInformationDeserializer.java
@@ -45,7 +45,7 @@ public class ToolInformationDeserializer
     return parseToolInformation(node);
   }
 
-  private ToolInformation parseToolInformation(JsonNode toolsNode) throws IOException {
+  private ToolInformation parseToolInformation(JsonNode toolsNode) {
     ToolInformation toolInformation = new ToolInformation();
     if (toolsNode.has("components")) {
       parseComponents(toolsNode.get("components"), toolInformation);
@@ -58,24 +58,42 @@ public class ToolInformationDeserializer
 
   private void parseComponents(JsonNode componentsNode, ToolInformation toolInformation) {
     if (componentsNode != null) {
+      // Case JSON input where "components" is an array
       if (componentsNode.isArray()) {
         List<Component> components = mapper.convertValue(componentsNode, new TypeReference<List<Component>>() {});
         toolInformation.setComponents(components);
-      } else if (componentsNode.isObject()) {
-        Component component = mapper.convertValue(componentsNode, Component.class);
-        toolInformation.setComponents(Collections.singletonList(component));
+      }
+      // Case XML-like input where "components" contains "component"
+      else if (componentsNode.isObject() && componentsNode.has("component")) {
+        JsonNode componentNode = componentsNode.get("component");
+        if (componentNode.isArray()) {
+          List<Component> components = mapper.convertValue(componentNode, new TypeReference<List<Component>>() {});
+          toolInformation.setComponents(components);
+        } else if (componentNode.isObject()) {
+          Component component = mapper.convertValue(componentNode, Component.class);
+          toolInformation.setComponents(Collections.singletonList(component));
+        }
       }
     }
   }
 
   private void parseServices(JsonNode servicesNode, ToolInformation toolInformation) {
     if (servicesNode != null) {
+      // Case JSON input where "services" is an array
       if (servicesNode.isArray()) {
         List<Service> services = mapper.convertValue(servicesNode, new TypeReference<List<Service>>() {});
         toolInformation.setServices(services);
-      } else if (servicesNode.isObject()) {
-        Service service = mapper.convertValue(servicesNode, Service.class);
-        toolInformation.setServices(Collections.singletonList(service));
+      }
+      // Case XML-like input where "services" contains "component"
+      else if (servicesNode.isObject() && servicesNode.has("service")) {
+        JsonNode serviceNode = servicesNode.get("service");
+        if (serviceNode.isArray()) {
+          List<Service> services = mapper.convertValue(servicesNode, new TypeReference<List<Service>>() {});
+          toolInformation.setServices(services);
+        } else if (serviceNode.isObject()) {
+          Service service = mapper.convertValue(servicesNode, Service.class);
+          toolInformation.setServices(Collections.singletonList(service));
+        }
       }
     }
   }
diff --git a/src/main/java/org/cyclonedx/util/serializer/MetadataSerializer.java b/src/main/java/org/cyclonedx/util/serializer/MetadataSerializer.java
index 5b55c226d..f90c22379 100644
--- a/src/main/java/org/cyclonedx/util/serializer/MetadataSerializer.java
+++ b/src/main/java/org/cyclonedx/util/serializer/MetadataSerializer.java
@@ -11,6 +11,7 @@ import org.apache.commons.collections4.CollectionUtils;
 import org.cyclonedx.Version;
 import org.cyclonedx.model.Metadata;
 import org.cyclonedx.model.Property;
+import org.cyclonedx.model.component.evidence.Occurrence;
 import org.cyclonedx.model.metadata.ToolInformation;
 
 import static org.cyclonedx.util.serializer.SerializerUtils.shouldSerializeField;
@@ -164,15 +165,14 @@ public class MetadataSerializer
   }
 
   private <T> void writeArrayFieldXML(List<T> items, ToXmlGenerator xmlGenerator, String fieldName) throws IOException {
-    if (items != null) {
+    if (CollectionUtils.isNotEmpty(items)) {
       xmlGenerator.writeFieldName(fieldName + "s");
-      xmlGenerator.writeStartArray();
+      xmlGenerator.writeStartObject();
       for (T item : items) {
-        xmlGenerator.writeStartObject();
-        xmlGenerator.writeObjectField(fieldName, item);
-        xmlGenerator.writeEndObject();
+        xmlGenerator.writeFieldName(fieldName);
+        xmlGenerator.writeObject(item);
       }
-      xmlGenerator.writeEndArray();
+      xmlGenerator.writeEndObject();
     }
   }
 


Test patch:
diff --git a/src/test/java/org/cyclonedx/BomJsonGeneratorTest.java b/src/test/java/org/cyclonedx/BomJsonGeneratorTest.java
index 09aea41ee..35357f095 100644
--- a/src/test/java/org/cyclonedx/BomJsonGeneratorTest.java
+++ b/src/test/java/org/cyclonedx/BomJsonGeneratorTest.java
@@ -545,6 +545,32 @@ public class BomJsonGeneratorTest {
         assertTrue(parser.isValid(loadedFile, version));
     }
 
+    @Test
+    public void testIssue562() throws Exception {
+        Version version = Version.VERSION_16;
+        Bom bom = createCommonJsonBom("/regression/issue562.json");
+
+
+        BomJsonGenerator generator = BomGeneratorFactory.createJson(version, bom);
+        File loadedFile = writeToFile(generator.toJsonString());
+
+        JsonParser parser = new JsonParser();
+        assertTrue(parser.isValid(loadedFile, version));
+    }
+
+    @Test
+    public void testIssue492() throws Exception {
+        Version version = Version.VERSION_14;
+        Bom bom = createCommonJsonBom("/regression/issue492.json");
+
+
+        BomJsonGenerator generator = BomGeneratorFactory.createJson(version, bom);
+        File loadedFile = writeToFile(generator.toJsonString());
+
+        JsonParser parser = new JsonParser();
+        assertTrue(parser.isValid(loadedFile, version));
+    }
+
     private void assertExternalReferenceInfo(Bom bom) {
         assertEquals(3, bom.getExternalReferences().size());
         assertEquals(3, bom.getComponents().get(0).getExternalReferences().size());
diff --git a/src/test/java/org/cyclonedx/BomXmlGeneratorTest.java b/src/test/java/org/cyclonedx/BomXmlGeneratorTest.java
index 6f6c54b5a..dd1ecacbf 100644
--- a/src/test/java/org/cyclonedx/BomXmlGeneratorTest.java
+++ b/src/test/java/org/cyclonedx/BomXmlGeneratorTest.java
@@ -32,8 +32,10 @@ import org.cyclonedx.model.ExternalReference;
 import org.cyclonedx.model.License;
 import org.cyclonedx.model.LicenseChoice;
 import org.cyclonedx.model.Metadata;
+import org.cyclonedx.model.OrganizationalContact;
 import org.cyclonedx.model.Service;
 import org.cyclonedx.model.license.Expression;
+import org.cyclonedx.model.metadata.ToolInformation;
 import org.cyclonedx.parsers.JsonParser;
 import org.cyclonedx.parsers.XmlParser;
 import org.junit.jupiter.api.AfterEach;
@@ -50,7 +52,9 @@ import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.LinkedList;
 import java.util.List;
+import java.util.UUID;
 import java.util.stream.Stream;
 import java.util.Objects;
 
@@ -672,11 +676,67 @@ public class BomXmlGeneratorTest {
 
     @Test
     public void testIssue408Regression_extensibleTypes() throws Exception {
+        Bom bom = new Bom();
+        bom.setSerialNumber("urn:uuid:" + UUID.randomUUID());
+
+        Metadata meta = new Metadata();
+
+        // ToolInformation test
+        Component tool1 = new Component();
+        tool1.setType(Component.Type.APPLICATION);
+        tool1.setName("TOOL 1");
+        tool1.setVersion("v1");
+
+        Component tool2 = new Component();
+        tool2.setType(Component.Type.APPLICATION);
+        tool2.setName("TOOL 2");
+        tool2.setVersion("v2");
+
+        ToolInformation tools = new ToolInformation();
+        List<Component> components = new LinkedList<>();
+        components.add(tool1);
+        components.add(tool2);
+        tools.setComponents(components);
+        meta.setToolChoice(tools);
+
+        // Author test
+        OrganizationalContact auth1 = new OrganizationalContact();
+        auth1.setName("Author 1");
+        meta.addAuthor(auth1);
+
+        OrganizationalContact auth2 = new OrganizationalContact();
+        auth2.setName("Author 2");
+        meta.addAuthor(auth2);
+
+        bom.setMetadata(meta);
+
+        BomXmlGenerator generator = BomGeneratorFactory.createXml(Version.VERSION_16, bom);
+        File loadedFile = writeToFile(generator.toXmlString());
+
+        XmlParser parser = new XmlParser();
+        assertTrue(parser.isValid(loadedFile, Version.VERSION_16));
+    }
+
+    @Test
+    public void testIssue562() throws Exception {
         Version version = Version.VERSION_15;
-        Bom bom = createCommonBomXml("/regression/issue408-extensible-type.xml");
-        addExtensibleTypes(bom);
+        Bom bom = createCommonBomXml("/regression/issue562.xml");
 
         BomXmlGenerator generator = BomGeneratorFactory.createXml(version, bom);
+        
+        File loadedFile = writeToFile(generator.toXmlString());
+
+        XmlParser parser = new XmlParser();
+        assertTrue(parser.isValid(loadedFile, version));
+    }
+
+    @Test
+    public void testIssue492() throws Exception {
+        Version version = Version.VERSION_15;
+        Bom bom = createCommonBomXml("/regression/issue492.xml");
+
+        BomXmlGenerator generator = BomGeneratorFactory.createXml(version, bom);
+
         File loadedFile = writeToFile(generator.toXmlString());
 
         XmlParser parser = new XmlParser();
diff --git a/src/test/java/org/cyclonedx/parsers/JsonParserTest.java b/src/test/java/org/cyclonedx/parsers/JsonParserTest.java
index 94edd03a6..1cdccb4db 100644
--- a/src/test/java/org/cyclonedx/parsers/JsonParserTest.java
+++ b/src/test/java/org/cyclonedx/parsers/JsonParserTest.java
@@ -568,4 +568,17 @@ public class JsonParserTest
             .containsAll(Arrays.asList(CryptoFunction.KEYGEN, CryptoFunction.ENCRYPT, CryptoFunction.DECRYPT,
                 CryptoFunction.TAG)));
     }
+
+    @Test
+    public void testIssue562Regression() throws Exception {
+        final Bom bom = getJsonBom("regression/issue562.json");
+        assertEquals(2, bom.getMetadata().getToolChoice().getComponents().size());
+        assertEquals(2, bom.getMetadata().getAuthors().size());
+    }
+
+    @Test
+    public void testIssue492Regression() throws Exception {
+        final Bom bom = getJsonBom("regression/issue492.json");
+        assertEquals(2, bom.getMetadata().getTools().size());
+    }
 }
diff --git a/src/test/java/org/cyclonedx/parsers/XmlParserTest.java b/src/test/java/org/cyclonedx/parsers/XmlParserTest.java
index c2aadeadd..997813e59 100644
--- a/src/test/java/org/cyclonedx/parsers/XmlParserTest.java
+++ b/src/test/java/org/cyclonedx/parsers/XmlParserTest.java
@@ -713,4 +713,17 @@ public class XmlParserTest
             .containsAll(Arrays.asList(CryptoFunction.KEYGEN, CryptoFunction.ENCRYPT, CryptoFunction.DECRYPT,
                 CryptoFunction.TAG)));
     }
+
+    @Test
+    public void testIssue562Regression() throws Exception {
+        final Bom bom = getXmlBom("regression/issue562.xml");
+        assertEquals(2, bom.getMetadata().getToolChoice().getComponents().size());
+        assertEquals(2, bom.getMetadata().getAuthors().size());
+    }
+
+    @Test
+    public void testIssue492Regression() throws Exception {
+        final Bom bom = getXmlBom("regression/issue492.xml");
+        assertEquals(2, bom.getMetadata().getTools().size());
+    }
 }
diff --git a/src/test/resources/regression/issue492.json b/src/test/resources/regression/issue492.json
new file mode 100644
index 000000000..a88564fb1
--- /dev/null
+++ b/src/test/resources/regression/issue492.json
@@ -0,0 +1,17 @@
+{
+  "bomFormat":"CycloneDX",
+  "specVersion":"1.4",
+  "serialNumber":"urn:uuid:0c81ff2e-d64e-4897-bfa4-2f0f7d8ab767",
+  "version" : 1,
+  "metadata" : {
+    "timestamp":"2024-12-09T21:56:45Z",
+    "tools": [
+      {
+        "name": "tool-a"
+      },
+      {
+        "name": "tool-b"
+      }
+    ]
+  }
+}
\ No newline at end of file
diff --git a/src/test/resources/regression/issue492.xml b/src/test/resources/regression/issue492.xml
new file mode 100644
index 000000000..c562d1f78
--- /dev/null
+++ b/src/test/resources/regression/issue492.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<bom xmlns="http://cyclonedx.org/schema/bom/1.4" serialNumber="urn:uuid:0c81ff2e-d64e-4897-bfa4-2f0f7d8ab767" version="1">
+  <metadata>
+    <timestamp>2024-12-09T21:56:45Z</timestamp>
+    <tools>
+      <tool>
+        <vendor>corp A</vendor>
+        <name>tool A</name>
+        <version>1-A</version>
+      </tool>
+      <tool>
+        <vendor>corp B</vendor>
+        <name>tool B</name>
+        <version>1-B</version>
+      </tool>
+    </tools>
+  </metadata>
+</bom>
diff --git a/src/test/resources/regression/issue562.json b/src/test/resources/regression/issue562.json
new file mode 100644
index 000000000..c2222e9c2
--- /dev/null
+++ b/src/test/resources/regression/issue562.json
@@ -0,0 +1,25 @@
+{
+  "bomFormat":"CycloneDX",
+  "specVersion":"1.6",
+  "serialNumber":"urn:uuid:0c81ff2e-d64e-4897-bfa4-2f0f7d8ab767",
+  "version" : 1,
+  "metadata" : {
+    "timestamp":"2024-12-09T21:56:45Z",
+    "tools" : {
+      "components" : [ {
+        "type":"application",
+        "name":"TOOL 1",
+        "version":"v1"
+      }, {
+        "type":"application",
+        "name":"TOOL 2",
+        "version":"v2"
+      } ]
+    },
+    "authors" : [ {
+      "name":"Author 1"
+    }, {
+      "name":"Author 2"
+    } ]
+  }
+}
\ No newline at end of file
diff --git a/src/test/resources/regression/issue562.xml b/src/test/resources/regression/issue562.xml
new file mode 100644
index 000000000..dc37b7f13
--- /dev/null
+++ b/src/test/resources/regression/issue562.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<bom xmlns="http://cyclonedx.org/schema/bom/1.6" serialNumber="urn:uuid:0c81ff2e-d64e-4897-bfa4-2f0f7d8ab767" version="1">
+  <metadata>
+    <timestamp>2024-12-09T21:56:45Z</timestamp>
+    <tools>
+      <components>
+        <component type="application">
+          <name>TOOL 1</name>
+          <version>v1</version>
+        </component>
+        <component type="application">
+          <name>TOOL 2</name>
+          <version>v2</version>
+        </component>
+      </components>
+    </tools>
+    <authors>
+      <author>
+        <name>Author 1</name>
+      </author>
+      <author>
+        <name>Author 2</name>
+      </author>
+    </authors>
+  </metadata>
+</bom>