Repository: CycloneDX/cyclonedx-core-java
Pull request #574 (2024-12-14 14:52:38)

Fix `UnsupportedOperationException` when modifying an existing `metadata.tools` list
This PR fixes the issue https://github.com/CycloneDX/cyclonedx-core-java/issues/571

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

 | Coverage variation | Diff coverage | 
 | ------------- | ------------- |
 | :white_check_mark: **+0.18%** | :white_check_mark: **85.71%** |

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

| | Coverable lines | Covered lines | Coverage |
| ------------- | ------------- | ------------- | ------------- |
| Common ancestor commit (0a6732e4736d956a27eb899aa6921f4951c5bea4) | 6370 | 4758 | 74.69% | |
| Head commit (02ebe973943244b0b49eee8f720fc83eb8628751) | 6395 (+25) | 4788 (+30) | 74.87% (**+0.18%**) |

**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 (#574) | 42 | 36 | **85.71%** |

**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/model/metadata/ToolInformation.java b/src/main/java/org/cyclonedx/model/metadata/ToolInformation.java
index 74d12618a..b0d59fb8c 100644
--- a/src/main/java/org/cyclonedx/model/metadata/ToolInformation.java
+++ b/src/main/java/org/cyclonedx/model/metadata/ToolInformation.java
@@ -1,5 +1,6 @@
 package org.cyclonedx.model.metadata;
 
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Objects;
 
@@ -21,6 +22,9 @@ public class ToolInformation
   private List<Service> services;
 
   public List<Component> getComponents() {
+    if(components==null) {
+      components = new ArrayList<>();
+    }
     return components;
   }
 
@@ -29,6 +33,9 @@ public class ToolInformation
   }
 
   public List<Service> getServices() {
+    if(services==null) {
+      services = new ArrayList<>();
+    }
     return services;
   }
 
diff --git a/src/main/java/org/cyclonedx/model/vulnerability/Vulnerability.java b/src/main/java/org/cyclonedx/model/vulnerability/Vulnerability.java
index 8bb66328a..dfd9654b2 100644
--- a/src/main/java/org/cyclonedx/model/vulnerability/Vulnerability.java
+++ b/src/main/java/org/cyclonedx/model/vulnerability/Vulnerability.java
@@ -150,7 +150,7 @@ public class Vulnerability
     this.references = references;
   }
 
-  private void addReference(Reference reference) {
+  public void addReference(Reference reference) {
     if (references == null) {
       references = new ArrayList<>();
     }
@@ -225,6 +225,13 @@ public class Vulnerability
     this.advisories = advisories;
   }
 
+  public void addAdvisory(Advisory advisory) {
+    if (advisories == null) {
+      advisories = new ArrayList<>();
+    }
+    advisories.add(advisory);
+  }
+
   public Date getCreated() {
     return created;
   }
@@ -824,6 +831,13 @@ public class Vulnerability
       this.responses = responses;
     }
 
+    public void addResponse(Response response) {
+      if (this.responses == null) {
+        this.responses = new ArrayList<>();
+      }
+      this.responses.add(response);
+    }
+
     public String getDetail() {
       return detail;
     }
@@ -896,6 +910,13 @@ public class Vulnerability
       this.versions = versions;
     }
 
+    public void addVersion(Version version) {
+      if (versions == null) {
+        versions = new ArrayList<>();
+      }
+      versions.add(version);
+    }
+
     @Override
     public boolean equals(Object o) {
       if (this == o) return true;
@@ -992,6 +1013,13 @@ public class Vulnerability
       this.individuals = individuals;
     }
 
+    public void addIndividual(OrganizationalContact individual) {
+      if (individuals == null) {
+        individuals = new ArrayList<>();
+      }
+      individuals.add(individual);
+    }
+
     public List<OrganizationalEntity> getOrganizations() {
       return organizations;
     }
@@ -1000,6 +1028,13 @@ public class Vulnerability
       this.organizations = organizations;
     }
 
+    public void addOrganization(OrganizationalEntity organization) {
+      if (this.organizations == null) {
+        this.organizations = new ArrayList<>();
+      }
+      this.organizations.add(organization);
+    }
+
     @Override
     public boolean equals(Object o) {
       if (this == o) return true;
diff --git a/src/main/java/org/cyclonedx/util/deserializer/AffectDeserializer.java b/src/main/java/org/cyclonedx/util/deserializer/AffectDeserializer.java
index f18efc849..6f6175d96 100644
--- a/src/main/java/org/cyclonedx/util/deserializer/AffectDeserializer.java
+++ b/src/main/java/org/cyclonedx/util/deserializer/AffectDeserializer.java
@@ -58,10 +58,9 @@ public class AffectDeserializer
         if (versionNode.isArray()) {
           List<Vulnerability.Version> versions = mapper.convertValue(versionNode, new TypeReference<List<Vulnerability.Version>>() {});
           affect.setVersions(versions);
-        } else {
-          affect.setVersions(Collections.singletonList(
-                  mapper.convertValue(versionNode, Vulnerability.Version.class)
-          ));
+        }
+        else {
+          affect.addVersion(mapper.convertValue(versionNode, Vulnerability.Version.class));
         }
       }
     }
diff --git a/src/main/java/org/cyclonedx/util/deserializer/ToolInformationDeserializer.java b/src/main/java/org/cyclonedx/util/deserializer/ToolInformationDeserializer.java
index 8f7c642ed..3a9ce3623 100644
--- a/src/main/java/org/cyclonedx/util/deserializer/ToolInformationDeserializer.java
+++ b/src/main/java/org/cyclonedx/util/deserializer/ToolInformationDeserializer.java
@@ -29,7 +29,6 @@ import org.cyclonedx.model.Service;
 import org.cyclonedx.model.metadata.ToolInformation;
 
 import java.io.IOException;
-import java.util.Collections;
 import java.util.List;
 
 public class ToolInformationDeserializer
@@ -71,7 +70,7 @@ public class ToolInformationDeserializer
           toolInformation.setComponents(components);
         } else if (componentNode.isObject()) {
           Component component = mapper.convertValue(componentNode, Component.class);
-          toolInformation.setComponents(Collections.singletonList(component));
+          toolInformation.getComponents().add(component);
         }
       }
     }
@@ -84,7 +83,7 @@ public class ToolInformationDeserializer
         List<Service> services = mapper.convertValue(servicesNode, new TypeReference<List<Service>>() {});
         toolInformation.setServices(services);
       }
-      // Case XML-like input where "services" contains "component"
+      // Case XML-like input where "services" contains "service"
       else if (servicesNode.isObject() && servicesNode.has("service")) {
         JsonNode serviceNode = servicesNode.get("service");
         if (serviceNode.isArray()) {
@@ -92,7 +91,7 @@ public class ToolInformationDeserializer
           toolInformation.setServices(services);
         } else if (serviceNode.isObject()) {
           Service service = mapper.convertValue(servicesNode, Service.class);
-          toolInformation.setServices(Collections.singletonList(service));
+          toolInformation.getServices().add(service);
         }
       }
     }
diff --git a/src/main/java/org/cyclonedx/util/deserializer/VulnerabilityDeserializer.java b/src/main/java/org/cyclonedx/util/deserializer/VulnerabilityDeserializer.java
index b0013f5f6..6604b80a1 100644
--- a/src/main/java/org/cyclonedx/util/deserializer/VulnerabilityDeserializer.java
+++ b/src/main/java/org/cyclonedx/util/deserializer/VulnerabilityDeserializer.java
@@ -20,7 +20,6 @@ package org.cyclonedx.util.deserializer;
 
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
 
 import com.fasterxml.jackson.core.JsonParser;
@@ -108,8 +107,7 @@ public class VulnerabilityDeserializer
                   new TypeReference<List<Vulnerability.Reference>>() {});
           vulnerability.setReferences(references);
         } else {
-          vulnerability.setReferences(Collections.singletonList(
-                  mapper.convertValue(referenceNode, Vulnerability.Reference.class)));
+          vulnerability.addReference(mapper.convertValue(referenceNode, Vulnerability.Reference.class));
         }
       }
     }
@@ -126,9 +124,9 @@ public class VulnerabilityDeserializer
           List<Vulnerability.Rating> ratings = mapper.convertValue(ratingNode, new TypeReference<List<Vulnerability.Rating>>() {
           });
           vulnerability.setRatings(ratings);
-        } else {
-          vulnerability.setRatings(Collections.singletonList(
-                  mapper.convertValue(ratingNode, Vulnerability.Rating.class)));
+        }
+        else {
+          vulnerability.addRating(mapper.convertValue(ratingNode, Vulnerability.Rating.class));
         }
       }
     }
@@ -145,7 +143,7 @@ public class VulnerabilityDeserializer
           });
           vulnerability.setCwes(codes);
         } else {
-          vulnerability.setCwes(Collections.singletonList(cweNode.asInt()));
+          vulnerability.addCwe(cweNode.asInt());
         }
       }
     }
@@ -163,8 +161,7 @@ public class VulnerabilityDeserializer
                   new TypeReference<List<Vulnerability.Advisory>>() {});
           vulnerability.setAdvisories(advisories);
         } else {
-          vulnerability.setAdvisories(Collections.singletonList(
-                  mapper.convertValue(advisoryNode, Vulnerability.Advisory.class)));
+          vulnerability.addAdvisory(mapper.convertValue(advisoryNode, Vulnerability.Advisory.class));
         }
       }
     }
@@ -251,8 +248,9 @@ public class VulnerabilityDeserializer
             analysis.setResponses(responses);
           }
           else if (responseNode.isTextual()) {
-            Vulnerability.Analysis.Response response = Vulnerability.Analysis.Response.fromString(responseNode.asText());
-            analysis.setResponses(Collections.singletonList(response));
+            Vulnerability.Analysis.Response response =
+                Vulnerability.Analysis.Response.fromString(responseNode.asText());
+            analysis.addResponse(response);
           }
         }
       }
@@ -279,7 +277,7 @@ public class VulnerabilityDeserializer
         credits.setOrganizations(organizations);
       } else if (organizationsNode.isObject()) {
         OrganizationalEntity organization = mapper.convertValue(organizationsNode.get("organization"), OrganizationalEntity.class);
-        credits.setOrganizations(Collections.singletonList(organization));
+        credits.addOrganization(organization);
       }
     }
   }
@@ -291,7 +289,7 @@ public class VulnerabilityDeserializer
         credits.setIndividuals(individuals);
       } else if (individualsNode.isObject()) {
         OrganizationalContact individual = mapper.convertValue(individualsNode.get("individual"), OrganizationalContact.class);
-        credits.setIndividuals(Collections.singletonList(individual));
+        credits.addIndividual(individual);
       }
     }
   }


Test patch:
diff --git a/src/test/java/org/cyclonedx/BomJsonGeneratorTest.java b/src/test/java/org/cyclonedx/BomJsonGeneratorTest.java
index 35357f095..6a162c9f6 100644
--- a/src/test/java/org/cyclonedx/BomJsonGeneratorTest.java
+++ b/src/test/java/org/cyclonedx/BomJsonGeneratorTest.java
@@ -19,18 +19,24 @@
 package org.cyclonedx;
 
 import com.fasterxml.jackson.databind.JsonNode;
+
+import java.io.FileReader;
 import java.nio.charset.StandardCharsets;
 import org.apache.commons.io.IOUtils;
+import org.cyclonedx.exception.GeneratorException;
 import org.cyclonedx.generators.BomGeneratorFactory;
 import org.cyclonedx.generators.json.BomJsonGenerator;
 import org.cyclonedx.generators.xml.BomXmlGenerator;
 import org.cyclonedx.model.Bom;
 import org.cyclonedx.model.Component;
+import org.cyclonedx.model.Component.Type;
 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;
@@ -46,6 +52,10 @@ import java.io.IOException;
 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;
 
@@ -550,6 +560,23 @@ public class BomJsonGeneratorTest {
         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 testIssue571() throws Exception {
+        Version version = Version.VERSION_16;
+        Bom bom = createCommonJsonBom("/regression/issue571.json");
+
+        Component component = new Component();
+        component.setName("test");
+        component.setVersion("v2");
+        component.setType(Type.APPLICATION);
+        bom.getMetadata().getToolChoice().getComponents().add(component);
 
         BomJsonGenerator generator = BomGeneratorFactory.createJson(version, bom);
         File loadedFile = writeToFile(generator.toJsonString());
diff --git a/src/test/java/org/cyclonedx/BomXmlGeneratorTest.java b/src/test/java/org/cyclonedx/BomXmlGeneratorTest.java
index dd1ecacbf..bf041845b 100644
--- a/src/test/java/org/cyclonedx/BomXmlGeneratorTest.java
+++ b/src/test/java/org/cyclonedx/BomXmlGeneratorTest.java
@@ -27,6 +27,7 @@ import org.cyclonedx.generators.xml.*;
 import org.cyclonedx.model.Attribute;
 import org.cyclonedx.model.Bom;
 import org.cyclonedx.model.Component;
+import org.cyclonedx.model.Component.Type;
 import org.cyclonedx.model.ExtensibleType;
 import org.cyclonedx.model.ExternalReference;
 import org.cyclonedx.model.License;
@@ -730,6 +731,24 @@ public class BomXmlGeneratorTest {
         assertTrue(parser.isValid(loadedFile, version));
     }
 
+    @Test
+    public void testIssue571() throws Exception {
+        Version version = Version.VERSION_16;
+        Bom bom = createCommonBomXml("/regression/issue571.xml");
+
+        Component component = new Component();
+        component.setName("test");
+        component.setVersion("v2");
+        component.setType(Type.APPLICATION);
+        bom.getMetadata().getToolChoice().getComponents().add(component);
+
+        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_15;
diff --git a/src/test/resources/regression/issue571.json b/src/test/resources/regression/issue571.json
new file mode 100644
index 000000000..41e037498
--- /dev/null
+++ b/src/test/resources/regression/issue571.json
@@ -0,0 +1,21 @@
+{
+  "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"
+      } ]
+    },
+    "authors" : [ {
+      "name":"Author 1"
+    }, {
+      "name":"Author 2"
+    } ]
+  }
+}
\ No newline at end of file
diff --git a/src/test/resources/regression/issue571.xml b/src/test/resources/regression/issue571.xml
new file mode 100644
index 000000000..cedea26a7
--- /dev/null
+++ b/src/test/resources/regression/issue571.xml
@@ -0,0 +1,22 @@
+<?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>
+      </components>
+    </tools>
+    <authors>
+      <author>
+        <name>Author 1</name>
+      </author>
+      <author>
+        <name>Author 2</name>
+      </author>
+    </authors>
+  </metadata>
+</bom>