diff --git a/src/main/java/org/cyclonedx/model/Component.java b/src/main/java/org/cyclonedx/model/Component.java index 0ba8fabe1..4b1af3759 100644 --- a/src/main/java/org/cyclonedx/model/Component.java +++ b/src/main/java/org/cyclonedx/model/Component.java @@ -24,11 +24,13 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonUnwrapped; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; import org.cyclonedx.Version; import org.cyclonedx.model.component.ModelCard; import org.cyclonedx.model.component.crypto.CryptoProperties; import org.cyclonedx.model.component.Tags; import org.cyclonedx.model.component.data.ComponentData; +import org.cyclonedx.util.deserializer.ComponentAuthorsDeserializer; import org.cyclonedx.util.deserializer.ComponentListDeserializer; import org.cyclonedx.util.deserializer.ExternalReferencesDeserializer; import org.cyclonedx.util.deserializer.HashesDeserializer; @@ -43,6 +45,7 @@ import com.github.packageurl.PackageURL; import org.cyclonedx.util.deserializer.LicenseDeserializer; import org.cyclonedx.util.deserializer.PropertiesDeserializer; +import org.cyclonedx.util.serializer.ComponentAuthorsSerializer; @SuppressWarnings("unused") @JacksonXmlRootElement(localName = "component") @@ -224,6 +227,9 @@ public String getScopeName() { @VersionFilter(Version.VERSION_16) @JsonProperty("authors") + @JacksonXmlElementWrapper(localName = "authors") + @JsonSerialize(using = ComponentAuthorsSerializer.class) + @JsonDeserialize(using = ComponentAuthorsDeserializer.class) private List authors; @VersionFilter(Version.VERSION_16) diff --git a/src/main/java/org/cyclonedx/util/deserializer/ComponentAuthorsDeserializer.java b/src/main/java/org/cyclonedx/util/deserializer/ComponentAuthorsDeserializer.java new file mode 100644 index 000000000..fb601a055 --- /dev/null +++ b/src/main/java/org/cyclonedx/util/deserializer/ComponentAuthorsDeserializer.java @@ -0,0 +1,65 @@ +/* + * This file is part of CycloneDX Core (Java). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.cyclonedx.util.deserializer; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.dataformat.xml.deser.FromXmlParser; +import org.cyclonedx.model.OrganizationalContact; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +public class ComponentAuthorsDeserializer extends JsonDeserializer> { + + @Override + public List deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + List contacts = new ArrayList<>(); + if (p instanceof FromXmlParser) { // Handle XML + while (p.nextToken() != JsonToken.END_OBJECT && p.currentToken() != JsonToken.END_OBJECT) { + if (p.currentToken() == JsonToken.FIELD_NAME) { + String fieldName = p.currentName(); + if ("author".equals(fieldName) || "authors".equals(fieldName)) { + // Handles both and as the item tag + p.nextToken(); + contacts.add(p.readValueAs(OrganizationalContact.class)); + } else { + ctxt.reportInputMismatch( + List.class, + "Unexpected field '%s' in %s", + fieldName, + getClass().getSimpleName() + ); + } + } + } + } else { // Handle JSON + if (p.isExpectedStartArrayToken()) { + while (p.nextToken() != JsonToken.END_ARRAY) { + // Handles case where author is a JSON object + contacts.add(p.readValueAs(OrganizationalContact.class)); + } + } + } + return contacts; + } +} \ No newline at end of file diff --git a/src/main/java/org/cyclonedx/util/serializer/ComponentAuthorsSerializer.java b/src/main/java/org/cyclonedx/util/serializer/ComponentAuthorsSerializer.java new file mode 100644 index 000000000..50750b10e --- /dev/null +++ b/src/main/java/org/cyclonedx/util/serializer/ComponentAuthorsSerializer.java @@ -0,0 +1,60 @@ +/* + * This file is part of CycloneDX Core (Java). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ + +package org.cyclonedx.util.serializer; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import com.fasterxml.jackson.dataformat.xml.ser.ToXmlGenerator; +import org.cyclonedx.model.OrganizationalContact; + +import javax.xml.namespace.QName; +import java.io.IOException; +import java.util.List; + +public class ComponentAuthorsSerializer extends StdSerializer> { + + + public ComponentAuthorsSerializer() { + super(List.class, false); + } + + + @Override + public void serialize(List authors, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException { + if (jsonGenerator instanceof ToXmlGenerator) { // Handle XML + ToXmlGenerator xmlGenerator = (ToXmlGenerator) jsonGenerator; + xmlGenerator.writeStartArray(); + + for (OrganizationalContact author : authors) { + xmlGenerator.setNextName(new QName("author")); + xmlGenerator.writeObject(author); + } + + xmlGenerator.writeEndArray(); + } else { // Handle JSON, as default. + JsonSerializer defaultSerializer = + serializerProvider.findValueSerializer(List.class, null); + defaultSerializer.serialize(authors, jsonGenerator, serializerProvider); + } + } + +} diff --git a/src/test/java/org/cyclonedx/BomJsonGeneratorTest.java b/src/test/java/org/cyclonedx/BomJsonGeneratorTest.java index 2d9840577..d87e74706 100644 --- a/src/test/java/org/cyclonedx/BomJsonGeneratorTest.java +++ b/src/test/java/org/cyclonedx/BomJsonGeneratorTest.java @@ -21,17 +21,14 @@ import com.fasterxml.jackson.databind.JsonNode; import java.nio.charset.StandardCharsets; + +import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.commons.io.IOUtils; 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.*; 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.Service; import org.cyclonedx.model.license.Expression; import org.cyclonedx.parsers.JsonParser; import org.cyclonedx.parsers.XmlParser; @@ -48,6 +45,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; import java.util.stream.Stream; import java.util.Objects; @@ -602,6 +601,67 @@ public void testIssue492() throws Exception { assertTrue(parser.isValid(loadedFile, version)); } + @Test + public void testComponentAuthorsSerializationAndDeserialization() throws Exception { + Version version = Version.VERSION_16; + Bom bom = createCommonJsonBom("/1.6/valid-component-authors-1.6.json"); + + assertNotNull(bom.getComponents()); + assertEquals(1, bom.getComponents().size()); + + Component bomComponent = bom.getComponents().get(0); + assertEquals("Outer Author with String value", bomComponent.getAuthor()); + + List bomAuthors = bomComponent.getAuthors(); + assertNotNull(bomAuthors); + assertEquals(2, bomAuthors.size()); + + OrganizationalContact bomAuthor1 = bomAuthors.get(0); + OrganizationalContact bomAuthor2 = bomAuthors.get(1); + + assertNotNull(bomAuthor1); + assertEquals("Test Author 1", bomAuthor1.getName()); + assertEquals("author1@example.com", bomAuthor1.getEmail()); + assertEquals("123", bomAuthor1.getPhone()); + + assertNotNull(bomAuthor2); + assertEquals("Test Author 2", bomAuthor2.getName()); + assertEquals("author2@example.com", bomAuthor2.getEmail()); + assertEquals("456", bomAuthor2.getPhone()); + + BomJsonGenerator generator = BomGeneratorFactory.createJson(version, bom); + String jsonString = generator.toJsonString(); + + File loadedFile = writeToFile(jsonString); + JsonParser parser = new JsonParser(); + assertTrue(parser.isValid(loadedFile, version)); + + // Verify the json content + ObjectMapper mapper = new ObjectMapper(); + JsonNode rootNode = mapper.readTree(jsonString); + + JsonNode component = rootNode.path("components").get(0); + assertNotNull(component); + + String outerAuthor = component.path("author").asText(); + assertEquals("Outer Author with String value", outerAuthor, "Outer author value mismatch"); + + JsonNode authorsNode = component.path("authors"); + assertTrue(authorsNode.isArray()); + assertEquals(2, authorsNode.size(), "Authors list size mismatch"); + + Iterator elements = authorsNode.elements(); + JsonNode author1 = elements.next(); + assertEquals("Test Author 1", author1.path("name").asText()); + assertEquals("author1@example.com", author1.path("email").asText()); + assertEquals("123", author1.path("phone").asText()); + + JsonNode author2 = elements.next(); + assertEquals("Test Author 2", author2.path("name").asText()); + assertEquals("author2@example.com", author2.path("email").asText()); + assertEquals("456", author2.path("phone").asText()); + } + 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 bf041845b..55499c778 100644 --- a/src/test/java/org/cyclonedx/BomXmlGeneratorTest.java +++ b/src/test/java/org/cyclonedx/BomXmlGeneratorTest.java @@ -45,14 +45,23 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; import org.w3c.dom.Document; +import org.w3c.dom.NodeList; + +import javax.xml.namespace.NamespaceContext; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.xpath.XPath; +import javax.xml.xpath.XPathConstants; +import javax.xml.xpath.XPathFactory; import java.io.File; import java.io.FileWriter; 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.Collections; +import java.util.Iterator; import java.util.LinkedList; import java.util.List; import java.util.UUID; @@ -786,6 +795,105 @@ public void testIssue408Regression_jsonToXml_externalReferenceBom() throws Excep assertTrue(parser.isValid(loadedFile, version)); } + @ParameterizedTest + @ValueSource(strings = { + "/1.6/valid-component-authors-1.6.xml", + "/1.6/invalid-component-authors-legacy-1.6.xml" + }) + public void testComponentAuthorsSerializationAndDeserialization(String xmlFilePath) throws Exception { + Version version = Version.VERSION_16; + Bom bom = createCommonBomXml(xmlFilePath); + + assertNotNull(bom.getComponents()); + assertEquals(1, bom.getComponents().size()); + + Component bomComponent = bom.getComponents().get(0); + assertEquals("Outer Author with String value", bomComponent.getAuthor()); + + List bomAuthors = bomComponent.getAuthors(); + assertNotNull(bomAuthors); + assertEquals(2, bomAuthors.size()); + + OrganizationalContact bomAuthor1 = bomAuthors.get(0); + OrganizationalContact bomAuthor2 = bomAuthors.get(1); + + assertNotNull(bomAuthor1); + assertEquals("Test Author 1", bomAuthor1.getName()); + assertEquals("author1@example.com", bomAuthor1.getEmail()); + assertEquals("123", bomAuthor1.getPhone()); + + assertNotNull(bomAuthor2); + assertEquals("Test Author 2", bomAuthor2.getName()); + assertEquals("author2@example.com", bomAuthor2.getEmail()); + assertEquals("456", bomAuthor2.getPhone()); + + BomXmlGenerator generator = BomGeneratorFactory.createXml(version, bom); + String xmlString = generator.toXmlString(); + File loadedFile = writeToFile(xmlString); + + XmlParser parser = new XmlParser(); + assertTrue(parser.isValid(loadedFile, version)); + + // Verify the xml content + DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); + dbf.setNamespaceAware(true); + dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + dbf.setFeature("http://xml.org/sax/features/external-general-entities", false); + dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + Document doc = dbf.newDocumentBuilder() + .parse(new java.io.ByteArrayInputStream(xmlString.getBytes())); + + XPath xpath = XPathFactory.newInstance().newXPath(); + xpath.setNamespaceContext(new NamespaceContext() { + @Override + public String getNamespaceURI(String prefix) { + return "bom".equals(prefix) ? "http://cyclonedx.org/schema/bom/1.6" : null; + } + @Override + public String getPrefix(String namespaceURI) { + return "http://cyclonedx.org/schema/bom/1.6".equals(namespaceURI) ? "bom" : null; + } + @Override + public Iterator getPrefixes(String namespaceURI) { + return Collections.singleton("bom").iterator(); + } + }); + + NodeList authors = (NodeList) xpath.evaluate( + "//bom:component/bom:authors/bom:author", + doc, + XPathConstants.NODESET + ); + assertEquals(2, authors.getLength(), "There should be exactly 2 elements"); + + String author1 = xpath.evaluate("//bom:component/bom:authors/bom:author[1]/bom:name", doc); + String author2 = xpath.evaluate("//bom:component/bom:authors/bom:author[2]/bom:name", doc); + + assertEquals("Test Author 1", author1); + assertEquals("Test Author 2", author2); + + String email1 = xpath.evaluate("//bom:component/bom:authors/bom:author[1]/bom:email", doc); + String email2 = xpath.evaluate("//bom:component/bom:authors/bom:author[2]/bom:email", doc); + + assertEquals("author1@example.com", email1); + assertEquals("author2@example.com", email2); + + String phone1 = xpath.evaluate("//bom:component/bom:authors/bom:author[1]/bom:phone", doc); + String phone2 = xpath.evaluate("//bom:component/bom:authors/bom:author[2]/bom:phone", doc); + + assertEquals("123", phone1); + assertEquals("456", phone2); + + String outerAuthorStr = (String) xpath.evaluate("//bom:component/bom:author", doc, XPathConstants.STRING); + assertEquals("Outer Author with String value", outerAuthorStr); + } + + @Test + public void testComponentAuthorsWithInvalidItemTag(){ + assertThrows(ParseException.class, () -> createCommonBomXml("/1.6/invalid-component-authors-bad-item-name-1.6.xml")); + } + private void assertExternalReferenceInfo(Bom bom) { assertEquals(3, bom.getExternalReferences().size()); assertEquals(3, bom.getComponents().get(0).getExternalReferences().size()); diff --git a/src/test/resources/1.6/invalid-component-authors-bad-item-name-1.6.xml b/src/test/resources/1.6/invalid-component-authors-bad-item-name-1.6.xml new file mode 100644 index 000000000..327d4b01b --- /dev/null +++ b/src/test/resources/1.6/invalid-component-authors-bad-item-name-1.6.xml @@ -0,0 +1,21 @@ + + + + + + + Test Author 1 + author1@example.com + 123 + + + Test Author 2 + author2@example.com + 456 + + + Outer Author with String value + Test Component + + + \ No newline at end of file diff --git a/src/test/resources/1.6/invalid-component-authors-legacy-1.6.xml b/src/test/resources/1.6/invalid-component-authors-legacy-1.6.xml new file mode 100644 index 000000000..c32a50a29 --- /dev/null +++ b/src/test/resources/1.6/invalid-component-authors-legacy-1.6.xml @@ -0,0 +1,21 @@ + + + + + + + Test Author 1 + author1@example.com + 123 + + + Test Author 2 + author2@example.com + 456 + + + Outer Author with String value + Test Component + + + \ No newline at end of file diff --git a/src/test/resources/1.6/valid-component-authors-1.6.json b/src/test/resources/1.6/valid-component-authors-1.6.json new file mode 100644 index 000000000..a375d7318 --- /dev/null +++ b/src/test/resources/1.6/valid-component-authors-1.6.json @@ -0,0 +1,25 @@ +{ + "bomFormat" : "CycloneDX", + "specVersion" : "1.6", + "serialNumber" : "urn:uuid:e1acbeda-240f-4ab6-bd4e-749ab4183fec", + "version" : 1, + "components" : [ + { + "type" : "application", + "authors" : [ + { + "name" : "Test Author 1", + "email" : "author1@example.com", + "phone" : "123" + }, + { + "name" : "Test Author 2", + "email" : "author2@example.com", + "phone" : "456" + } + ], + "author" : "Outer Author with String value", + "name" : "Test Component" + } + ] +} \ No newline at end of file diff --git a/src/test/resources/1.6/valid-component-authors-1.6.xml b/src/test/resources/1.6/valid-component-authors-1.6.xml new file mode 100644 index 000000000..a4d1876a0 --- /dev/null +++ b/src/test/resources/1.6/valid-component-authors-1.6.xml @@ -0,0 +1,21 @@ + + + + + + + Test Author 1 + author1@example.com + 123 + + + Test Author 2 + author2@example.com + 456 + + + Outer Author with String value + Test Component + + + \ No newline at end of file