From c724a3db0398102c8d861f4e0c284ce55341630c Mon Sep 17 00:00:00 2001 From: Alex Alzate Date: Mon, 25 Aug 2025 22:31:48 -0500 Subject: [PATCH] Refactor author/authors serialization for Component Introduces custom serializers to resolve naming conflicts between the deprecated 'author' string and the new 'authors' list fields in Component. Adds AuthorsBeanSerializerModifier and AuthorsSerializer to ensure correct XML/JSON output across CycloneDX schema versions. Updates OrganizationalContact and Component annotations for precise serialization. Includes comprehensive tests for all field combinations, formats, and migration scenarios. --- .../generators/AbstractBomGenerator.java | 5 + .../java/org/cyclonedx/model/Component.java | 33 +- .../model/OrganizationalContact.java | 2 + .../VersionJsonAnnotationIntrospector.java | 2 +- .../AuthorsBeanSerializerModifier.java | 70 ++ .../util/serializer/AuthorsSerializer.java | 77 +++ .../cyclonedx/Issue638ComprehensiveTest.java | 650 ++++++++++++++++++ .../org/cyclonedx/Issue638RegressionTest.java | 177 +++++ .../java/org/cyclonedx/SimpleAuthorTest.java | 83 +++ 9 files changed, 1097 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/cyclonedx/util/serializer/AuthorsBeanSerializerModifier.java create mode 100644 src/main/java/org/cyclonedx/util/serializer/AuthorsSerializer.java create mode 100644 src/test/java/org/cyclonedx/Issue638ComprehensiveTest.java create mode 100644 src/test/java/org/cyclonedx/Issue638RegressionTest.java create mode 100644 src/test/java/org/cyclonedx/SimpleAuthorTest.java diff --git a/src/main/java/org/cyclonedx/generators/AbstractBomGenerator.java b/src/main/java/org/cyclonedx/generators/AbstractBomGenerator.java index 6dcc2c4f5..2846cd559 100644 --- a/src/main/java/org/cyclonedx/generators/AbstractBomGenerator.java +++ b/src/main/java/org/cyclonedx/generators/AbstractBomGenerator.java @@ -6,6 +6,7 @@ import org.cyclonedx.Format; import org.cyclonedx.Version; import org.cyclonedx.model.Bom; +import org.cyclonedx.util.serializer.AuthorsBeanSerializerModifier; import org.cyclonedx.util.serializer.CustomSerializerModifier; import org.cyclonedx.util.serializer.EvidenceSerializer; import org.cyclonedx.util.serializer.ExternalReferenceSerializer; @@ -93,6 +94,10 @@ protected void setupObjectMapper(boolean isXml) { hash1Module.addSerializer(new HashSerializer(version)); mapper.registerModule(hash1Module); + SimpleModule authorsModule = new SimpleModule(); + authorsModule.setSerializerModifier(new AuthorsBeanSerializerModifier(version)); + mapper.registerModule(authorsModule); + SimpleModule propertiesModule = new SimpleModule(); propertiesModule.setSerializerModifier(new CustomSerializerModifier(isXml, version)); mapper.registerModule(propertiesModule); diff --git a/src/main/java/org/cyclonedx/model/Component.java b/src/main/java/org/cyclonedx/model/Component.java index 0ba8fabe1..16c471841 100644 --- a/src/main/java/org/cyclonedx/model/Component.java +++ b/src/main/java/org/cyclonedx/model/Component.java @@ -22,7 +22,9 @@ import java.util.List; import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonGetter; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonSetter; import com.fasterxml.jackson.annotation.JsonUnwrapped; import org.cyclonedx.Version; import org.cyclonedx.model.component.ModelCard; @@ -223,7 +225,6 @@ public String getScopeName() { private Tags tags; @VersionFilter(Version.VERSION_16) - @JsonProperty("authors") private List authors; @VersionFilter(Version.VERSION_16) @@ -258,10 +259,26 @@ public void setSupplier(OrganizationalEntity supplier) { this.supplier = supplier; } + /** + * Gets the deprecated author field as a string. + * @return the author name as a string + * @deprecated since version 1.6, use {@link #getAuthors()} instead + */ + @Deprecated + @JsonGetter("author") + @JacksonXmlProperty(localName = "author") public String getAuthor() { return author; } + /** + * Sets the deprecated author field as a string. + * @param author the author name as a string + * @deprecated since version 1.6, use {@link #setAuthors(List)} instead + */ + @Deprecated + @JsonSetter("author") + @JacksonXmlProperty(localName = "author") public void setAuthor(String author) { this.author = author; } @@ -556,10 +573,24 @@ public void setTags(final Tags tags) { this.tags = tags; } + /** + * Gets the component authors as a list of contacts. + * This replaces the deprecated string-based author field. + * @return the list of authors, or null if not set + * @since 1.6 + */ + @JsonGetter("authors") public List getAuthors() { return authors; } + /** + * Sets the component authors as a list of contacts. + * This replaces the deprecated string-based author field. + * @param authors the list of authors + * @since 1.6 + */ + @JsonSetter("authors") public void setAuthors(final List authors) { this.authors = authors; } diff --git a/src/main/java/org/cyclonedx/model/OrganizationalContact.java b/src/main/java/org/cyclonedx/model/OrganizationalContact.java index adecd83f8..d3da135b9 100644 --- a/src/main/java/org/cyclonedx/model/OrganizationalContact.java +++ b/src/main/java/org/cyclonedx/model/OrganizationalContact.java @@ -24,10 +24,12 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyOrder; import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement; import org.cyclonedx.Version; import java.util.Objects; +@JacksonXmlRootElement(localName = "author") @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(Include.NON_EMPTY) @JsonPropertyOrder({"name", "email", "phone"}) diff --git a/src/main/java/org/cyclonedx/util/introspector/VersionJsonAnnotationIntrospector.java b/src/main/java/org/cyclonedx/util/introspector/VersionJsonAnnotationIntrospector.java index 260c832d0..5bc688ef4 100644 --- a/src/main/java/org/cyclonedx/util/introspector/VersionJsonAnnotationIntrospector.java +++ b/src/main/java/org/cyclonedx/util/introspector/VersionJsonAnnotationIntrospector.java @@ -47,7 +47,7 @@ public boolean hasIgnoreMarker(final AnnotatedMember m) { // Check if the field has the XmlOnly annotation if (m.hasAnnotation(XmlOnly.class)) { - // If true, the field should be ignored for XML serialization + // If true, the field should be ignored for JSON serialization return true; } diff --git a/src/main/java/org/cyclonedx/util/serializer/AuthorsBeanSerializerModifier.java b/src/main/java/org/cyclonedx/util/serializer/AuthorsBeanSerializerModifier.java new file mode 100644 index 000000000..d618a1633 --- /dev/null +++ b/src/main/java/org/cyclonedx/util/serializer/AuthorsBeanSerializerModifier.java @@ -0,0 +1,70 @@ +/* + * 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.databind.BeanDescription; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializationConfig; +import com.fasterxml.jackson.databind.ser.BeanPropertyWriter; +import com.fasterxml.jackson.databind.ser.BeanSerializerModifier; +import org.cyclonedx.Version; +import org.cyclonedx.model.Component; + +import java.util.List; + +/** + * Bean serializer modifier for Component.authors field. + * Applies the AuthorsSerializer only to the authors field in Component class. + */ +public class AuthorsBeanSerializerModifier extends BeanSerializerModifier { + private final Version version; + + public AuthorsBeanSerializerModifier(Version version) { + this.version = version; + } + + @Override + public List changeProperties( + SerializationConfig config, + BeanDescription beanDesc, + List beanProperties) { + + // Only modify Component class + if (Component.class.isAssignableFrom(beanDesc.getBeanClass())) { + java.util.Iterator iterator = beanProperties.iterator(); + while (iterator.hasNext()) { + BeanPropertyWriter writer = iterator.next(); + // Find the authors property + if ("authors".equals(writer.getName())) { + // Check if the current version supports the authors field (v1.6+) + if (version.getVersion() < Version.VERSION_16.getVersion()) { + // Remove the property for versions earlier than 1.6 + iterator.remove(); + } else { + // Assign the custom serializer for v1.6+ + JsonSerializer serializer = new AuthorsSerializer(); + writer.assignSerializer((JsonSerializer) serializer); + } + break; + } + } + } + return beanProperties; + } +} diff --git a/src/main/java/org/cyclonedx/util/serializer/AuthorsSerializer.java b/src/main/java/org/cyclonedx/util/serializer/AuthorsSerializer.java new file mode 100644 index 000000000..c69805573 --- /dev/null +++ b/src/main/java/org/cyclonedx/util/serializer/AuthorsSerializer.java @@ -0,0 +1,77 @@ +/* + * 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.SerializerProvider; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import com.fasterxml.jackson.dataformat.xml.ser.ToXmlGenerator; +import org.cyclonedx.model.OrganizationalContact; + +import java.io.IOException; +import java.util.List; + +/** + * Custom serializer for the Component.authors field to handle XML element naming. + * This serializer ensures that: + * - JSON: serializes as "authors": [...] + * - XML: serializes as authors-author + * This is necessary because the deprecated "author" field also uses author element, + * creating a naming conflict that standard Jackson annotations cannot resolve. + * Version filtering is handled by AuthorsBeanSerializerModifier, which removes this + * property entirely for versions prior to 1.6. + */ +public class AuthorsSerializer extends StdSerializer> { + + @SuppressWarnings("unchecked") + public AuthorsSerializer() { + super((Class>) (Class) List.class); + } + + @Override + public void serialize(List authors, JsonGenerator gen, SerializerProvider serializers) + throws IOException { + + if (authors == null || authors.isEmpty()) { + return; + } + + // Check if we're serializing to XML + if (gen instanceof ToXmlGenerator) { + ToXmlGenerator xmlGen = (ToXmlGenerator) gen; + + // For XML: The property name "authors" creates the wrapper + // We need to write the array structure, and each item gets its own tag + xmlGen.writeStartArray(); + for (OrganizationalContact contact : authors) { + // Set the element name for this array item to "author" + xmlGen.setNextName(new javax.xml.namespace.QName("author")); + serializers.defaultSerializeValue(contact, xmlGen); + } + xmlGen.writeEndArray(); + } else { + // For JSON, write as a standard array + gen.writeStartArray(); + for (OrganizationalContact contact : authors) { + serializers.defaultSerializeValue(contact, gen); + } + gen.writeEndArray(); + } + } +} diff --git a/src/test/java/org/cyclonedx/Issue638ComprehensiveTest.java b/src/test/java/org/cyclonedx/Issue638ComprehensiveTest.java new file mode 100644 index 000000000..c3a1f94b3 --- /dev/null +++ b/src/test/java/org/cyclonedx/Issue638ComprehensiveTest.java @@ -0,0 +1,650 @@ +/* + * 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; + +import org.cyclonedx.exception.ParseException; +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.OrganizationalContact; +import org.cyclonedx.parsers.JsonParser; +import org.cyclonedx.parsers.XmlParser; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Comprehensive TDD test suite for Issue #638: author/authors field handling across versions and formats. + * + * This test suite covers: + * - XML serialization and deserialization for versions 1.2, 1.5, and 1.6 + * - JSON serialization and deserialization for versions 1.2, 1.5, and 1.6 + * - Cross-format conversions (XML to JSON, JSON to XML) + * - All field combinations: only author (string), only authors (list), both fields + * - Schema validation for all generated outputs + * - Migration scenarios across versions + */ +@DisplayName("Issue #638: Comprehensive author/authors field handling") +public class Issue638ComprehensiveTest { + + // ============================================================================ + // VERSION 1.2 TESTS - Only 'author' (String) field exists + // ============================================================================ + + @Test + @DisplayName("v1.2 XML: Serialize component with author string") + public void testV12_XML_SerializeAuthorString() throws Exception { + Component component = createComponentWithAuthorString("John Doe"); + Bom bom = createBom(component); + + BomXmlGenerator generator = BomGeneratorFactory.createXml(Version.VERSION_12, bom); + String xml = generator.toXmlString(); + + assertThat(xml).contains("John Doe"); + assertThat(xml).doesNotContain(""); + + // Validate against schema + XmlParser parser = new XmlParser(); + List errors = parser.validate(xml.getBytes(), Version.VERSION_12); + assertThat(errors).isEmpty(); + } + + @Test + @DisplayName("v1.2 XML: Deserialize author string from XML") + public void testV12_XML_DeserializeAuthorString() throws Exception { + String xml = "\n" + + "\n" + + " \n" + + " \n" + + " Jane Smith\n" + + " test-lib\n" + + " 1.0.0\n" + + " \n" + + " \n" + + ""; + + XmlParser parser = new XmlParser(); + Bom bom = parser.parse(xml.getBytes()); + + assertThat(bom.getComponents()).hasSize(1); + Component component = bom.getComponents().get(0); + assertThat(component.getAuthor()).isEqualTo("Jane Smith"); + assertThat(component.getAuthors()).isNull(); + } + + @Test + @DisplayName("v1.2 XML: Roundtrip with author string") + public void testV12_XML_Roundtrip() throws Exception { + String originalXml = "\n" + + "\n" + + " \n" + + " \n" + + " Original Author\n" + + " test-lib\n" + + " 1.0.0\n" + + " \n" + + " \n" + + ""; + + XmlParser parser = new XmlParser(); + Bom bom = parser.parse(originalXml.getBytes()); + + BomXmlGenerator generator = BomGeneratorFactory.createXml(Version.VERSION_12, bom); + String regeneratedXml = generator.toXmlString(); + + assertThat(regeneratedXml).contains("Original Author"); + assertThat(regeneratedXml).doesNotContain(""); + + // Validate regenerated XML + List errors = parser.validate(regeneratedXml.getBytes(), Version.VERSION_12); + assertThat(errors).isEmpty(); + } + + @Test + @DisplayName("v1.2 JSON: Serialize component with author string") + public void testV12_JSON_SerializeAuthorString() throws Exception { + Component component = createComponentWithAuthorString("Alice Johnson"); + Bom bom = createBom(component); + + BomJsonGenerator generator = BomGeneratorFactory.createJson(Version.VERSION_12, bom); + String json = generator.toJsonString(); + + assertThat(json).contains("\"author\" : \"Alice Johnson\""); + assertThat(json).doesNotContain("\"authors\""); + } + + @Test + @DisplayName("v1.2 JSON: Roundtrip with author string") + public void testV12_JSON_Roundtrip() throws Exception { + Component component = createComponentWithAuthorString("Bob Brown"); + Bom bom = createBom(component); + + BomJsonGenerator generator = BomGeneratorFactory.createJson(Version.VERSION_12, bom); + String json = generator.toJsonString(); + + JsonParser parser = new JsonParser(); + Bom parsedBom = parser.parse(json.getBytes()); + + assertThat(parsedBom.getComponents()).hasSize(1); + Component parsedComponent = parsedBom.getComponents().get(0); + assertThat(parsedComponent.getAuthor()).isEqualTo("Bob Brown"); + assertThat(parsedComponent.getAuthors()).isNull(); + } + + @Test + @DisplayName("v1.2: Cross-format XML to JSON") + public void testV12_CrossFormat_XmlToJson() throws Exception { + String xml = "\n" + + "\n" + + " \n" + + " \n" + + " Cross Format Author\n" + + " test-lib\n" + + " 1.0.0\n" + + " \n" + + " \n" + + ""; + + XmlParser xmlParser = new XmlParser(); + Bom bom = xmlParser.parse(xml.getBytes()); + + BomJsonGenerator jsonGenerator = BomGeneratorFactory.createJson(Version.VERSION_12, bom); + String json = jsonGenerator.toJsonString(); + + assertThat(json).contains("\"author\" : \"Cross Format Author\""); + assertThat(json).doesNotContain("\"authors\""); + } + + // ============================================================================ + // VERSION 1.5 TESTS - Only 'author' (String) field exists (same as 1.2) + // ============================================================================ + + @Test + @DisplayName("v1.5 XML: Serialize and validate author string") + public void testV15_XML_SerializeAuthorString() throws Exception { + Component component = createComponentWithAuthorString("v1.5 Author"); + Bom bom = createBom(component); + + BomXmlGenerator generator = BomGeneratorFactory.createXml(Version.VERSION_15, bom); + String xml = generator.toXmlString(); + + assertThat(xml).contains("v1.5 Author"); + assertThat(xml).doesNotContain(""); + + XmlParser parser = new XmlParser(); + List errors = parser.validate(xml.getBytes(), Version.VERSION_15); + assertThat(errors).isEmpty(); + } + + // ============================================================================ + // VERSION 1.6 TESTS - Both 'author' (deprecated) and 'authors' exist + // ============================================================================ + + @Test + @DisplayName("v1.6 XML: Serialize component with ONLY author string (deprecated field)") + public void testV16_XML_SerializeOnlyAuthorString() throws Exception { + Component component = createComponentWithAuthorString("Deprecated Author"); + Bom bom = createBom(component); + + BomXmlGenerator generator = BomGeneratorFactory.createXml(Version.VERSION_16, bom); + String xml = generator.toXmlString(); + + // Should contain deprecated author field + assertThat(xml).contains("Deprecated Author"); + // Should NOT contain authors wrapper (not set) + assertThat(xml).doesNotContain(""); + + XmlParser parser = new XmlParser(); + List errors = parser.validate(xml.getBytes(), Version.VERSION_16); + assertThat(errors).isEmpty(); + } + + @Test + @DisplayName("v1.6 XML: Serialize component with ONLY authors list (new field)") + public void testV16_XML_SerializeOnlyAuthorsList() throws Exception { + Component component = createComponentWithAuthorsList( + Arrays.asList( + createOrganizationalContact("Author One", "author1@example.com", "+1-555-0001"), + createOrganizationalContact("Author Two", "author2@example.com", null) + ) + ); + Bom bom = createBom(component); + + BomXmlGenerator generator = BomGeneratorFactory.createXml(Version.VERSION_16, bom); + String xml = generator.toXmlString(); + + // Should contain authors wrapper with properly nested author elements + assertThat(xml).contains(""); + assertThat(xml).contains(""); + assertThat(xml).contains("Author One"); + assertThat(xml).contains("author1@example.com"); + assertThat(xml).contains("+1-555-0001"); + assertThat(xml).contains("Author Two"); + assertThat(xml).contains("author2@example.com"); + + // Should NOT contain the deprecated author string field (not set) + assertThat(xml).doesNotContainPattern("(?!\\s*)[^<]+"); + + // Critical: Verify correct nesting - NO double tags + assertThat(xml).doesNotContain("\n "); + assertThat(xml).doesNotContain(""); + + XmlParser parser = new XmlParser(); + List errors = parser.validate(xml.getBytes(), Version.VERSION_16); + assertThat(errors).isEmpty(); + } + + @Test + @DisplayName("v1.6 XML: Serialize component with BOTH author and authors (both fields set)") + public void testV16_XML_SerializeBothFields() throws Exception { + Component component = new Component(); + component.setType(Component.Type.LIBRARY); + component.setName("test-lib"); + component.setVersion("1.0.0"); + + // Set both deprecated and new fields + component.setAuthor("Legacy String Author"); + component.setAuthors(Arrays.asList( + createOrganizationalContact("New Format Author", "new@example.com", null) + )); + + Bom bom = createBom(component); + + BomXmlGenerator generator = BomGeneratorFactory.createXml(Version.VERSION_16, bom); + String xml = generator.toXmlString(); + + // Both fields should appear as separate elements + assertThat(xml).contains("Legacy String Author"); + assertThat(xml).contains(""); + assertThat(xml).contains("New Format Author"); + + XmlParser parser = new XmlParser(); + List errors = parser.validate(xml.getBytes(), Version.VERSION_16); + assertThat(errors).isEmpty(); + } + + @Test + @DisplayName("v1.6 XML: Deserialize ONLY deprecated author string") + public void testV16_XML_DeserializeOnlyAuthorString() throws Exception { + String xml = "\n" + + "\n" + + " \n" + + " \n" + + " Only Deprecated Field\n" + + " test-lib\n" + + " 1.0.0\n" + + " \n" + + " \n" + + ""; + + XmlParser parser = new XmlParser(); + Bom bom = parser.parse(xml.getBytes()); + + assertThat(bom.getComponents()).hasSize(1); + Component component = bom.getComponents().get(0); + assertThat(component.getAuthor()).isEqualTo("Only Deprecated Field"); + assertThat(component.getAuthors()).isNull(); + } + + @Test + @DisplayName("v1.6 XML: Deserialize ONLY authors list") + public void testV16_XML_DeserializeOnlyAuthorsList() throws Exception { + String xml = "\n" + + "\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " First Author\n" + + " first@example.com\n" + + " \n" + + " \n" + + " Second Author\n" + + " +1-555-9999\n" + + " \n" + + " \n" + + " test-lib\n" + + " 1.0.0\n" + + " \n" + + " \n" + + ""; + + XmlParser parser = new XmlParser(); + Bom bom = parser.parse(xml.getBytes()); + + assertThat(bom.getComponents()).hasSize(1); + Component component = bom.getComponents().get(0); + assertThat(component.getAuthor()).isNull(); + assertThat(component.getAuthors()).isNotNull(); + assertThat(component.getAuthors()).hasSize(2); + assertThat(component.getAuthors().get(0).getName()).isEqualTo("First Author"); + assertThat(component.getAuthors().get(0).getEmail()).isEqualTo("first@example.com"); + assertThat(component.getAuthors().get(1).getName()).isEqualTo("Second Author"); + assertThat(component.getAuthors().get(1).getPhone()).isEqualTo("+1-555-9999"); + } + + @Test + @DisplayName("v1.6 XML: Deserialize BOTH author and authors") + public void testV16_XML_DeserializeBothFields() throws Exception { + String xml = "\n" + + "\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " New Format\n" + + " \n" + + " \n" + + " Old Format\n" + + " test-lib\n" + + " 1.0.0\n" + + " \n" + + " \n" + + ""; + + XmlParser parser = new XmlParser(); + Bom bom = parser.parse(xml.getBytes()); + + assertThat(bom.getComponents()).hasSize(1); + Component component = bom.getComponents().get(0); + assertThat(component.getAuthor()).isEqualTo("Old Format"); + assertThat(component.getAuthors()).isNotNull(); + assertThat(component.getAuthors()).hasSize(1); + assertThat(component.getAuthors().get(0).getName()).isEqualTo("New Format"); + } + + @Test + @DisplayName("v1.6 XML: Roundtrip with only authors list") + public void testV16_XML_RoundtripAuthorsList() throws Exception { + String originalXml = "\n" + + "\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " Roundtrip Author\n" + + " roundtrip@example.com\n" + + " \n" + + " \n" + + " test-lib\n" + + " 1.0.0\n" + + " \n" + + " \n" + + ""; + + XmlParser parser = new XmlParser(); + Bom bom = parser.parse(originalXml.getBytes()); + + BomXmlGenerator generator = BomGeneratorFactory.createXml(Version.VERSION_16, bom); + String regeneratedXml = generator.toXmlString(); + + assertThat(regeneratedXml).contains(""); + assertThat(regeneratedXml).contains("Roundtrip Author"); + assertThat(regeneratedXml).contains("roundtrip@example.com"); + + List errors = parser.validate(regeneratedXml.getBytes(), Version.VERSION_16); + assertThat(errors).isEmpty(); + } + + @Test + @DisplayName("v1.6 JSON: Serialize component with ONLY author string") + public void testV16_JSON_SerializeOnlyAuthorString() throws Exception { + Component component = createComponentWithAuthorString("JSON Deprecated Author"); + Bom bom = createBom(component); + + BomJsonGenerator generator = BomGeneratorFactory.createJson(Version.VERSION_16, bom); + String json = generator.toJsonString(); + + assertThat(json).contains("\"author\" : \"JSON Deprecated Author\""); + assertThat(json).doesNotContain("\"authors\""); + } + + @Test + @DisplayName("v1.6 JSON: Serialize component with ONLY authors list") + public void testV16_JSON_SerializeOnlyAuthorsList() throws Exception { + Component component = createComponentWithAuthorsList( + Arrays.asList( + createOrganizationalContact("JSON Author", "json@example.com", null) + ) + ); + Bom bom = createBom(component); + + BomJsonGenerator generator = BomGeneratorFactory.createJson(Version.VERSION_16, bom); + String json = generator.toJsonString(); + + assertThat(json).contains("\"authors\""); + assertThat(json).contains("\"name\" : \"JSON Author\""); + assertThat(json).contains("\"email\" : \"json@example.com\""); + assertThat(json).doesNotContain("\"author\" : \""); + } + + @Test + @DisplayName("v1.6 JSON: Serialize component with BOTH fields") + public void testV16_JSON_SerializeBothFields() throws Exception { + Component component = new Component(); + component.setType(Component.Type.LIBRARY); + component.setName("test-lib"); + component.setVersion("1.0.0"); + + component.setAuthor("JSON Legacy"); + component.setAuthors(Arrays.asList( + createOrganizationalContact("JSON New", "jsonnew@example.com", null) + )); + + Bom bom = createBom(component); + + BomJsonGenerator generator = BomGeneratorFactory.createJson(Version.VERSION_16, bom); + String json = generator.toJsonString(); + + assertThat(json).contains("\"author\" : \"JSON Legacy\""); + assertThat(json).contains("\"authors\""); + assertThat(json).contains("\"name\" : \"JSON New\""); + } + + @Test + @DisplayName("v1.6 JSON: Roundtrip with authors list") + public void testV16_JSON_RoundtripAuthorsList() throws Exception { + Component component = createComponentWithAuthorsList( + Arrays.asList( + createOrganizationalContact("Roundtrip JSON", "rt@example.com", "+1-555-7777") + ) + ); + Bom bom = createBom(component); + + BomJsonGenerator generator = BomGeneratorFactory.createJson(Version.VERSION_16, bom); + String json = generator.toJsonString(); + + JsonParser parser = new JsonParser(); + Bom parsedBom = parser.parse(json.getBytes()); + + assertThat(parsedBom.getComponents()).hasSize(1); + Component parsedComponent = parsedBom.getComponents().get(0); + assertThat(parsedComponent.getAuthors()).isNotNull(); + assertThat(parsedComponent.getAuthors()).hasSize(1); + assertThat(parsedComponent.getAuthors().get(0).getName()).isEqualTo("Roundtrip JSON"); + assertThat(parsedComponent.getAuthors().get(0).getEmail()).isEqualTo("rt@example.com"); + assertThat(parsedComponent.getAuthors().get(0).getPhone()).isEqualTo("+1-555-7777"); + } + + @Test + @DisplayName("v1.6: Cross-format XML to JSON with authors list") + public void testV16_CrossFormat_XmlToJson_AuthorsList() throws Exception { + String xml = "\n" + + "\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " Cross Author\n" + + " \n" + + " \n" + + " test-lib\n" + + " 1.0.0\n" + + " \n" + + " \n" + + ""; + + XmlParser xmlParser = new XmlParser(); + Bom bom = xmlParser.parse(xml.getBytes()); + + BomJsonGenerator jsonGenerator = BomGeneratorFactory.createJson(Version.VERSION_16, bom); + String json = jsonGenerator.toJsonString(); + + assertThat(json).contains("\"authors\""); + assertThat(json).contains("\"name\" : \"Cross Author\""); + } + + @Test + @DisplayName("v1.6: Cross-format JSON to XML with authors list") + public void testV16_CrossFormat_JsonToXml_AuthorsList() throws Exception { + Component component = createComponentWithAuthorsList( + Arrays.asList( + createOrganizationalContact("JSON to XML", "j2x@example.com", null) + ) + ); + Bom bom = createBom(component); + + BomJsonGenerator jsonGenerator = BomGeneratorFactory.createJson(Version.VERSION_16, bom); + String json = jsonGenerator.toJsonString(); + + JsonParser jsonParser = new JsonParser(); + Bom parsedBom = jsonParser.parse(json.getBytes()); + + BomXmlGenerator xmlGenerator = BomGeneratorFactory.createXml(Version.VERSION_16, parsedBom); + String xml = xmlGenerator.toXmlString(); + + assertThat(xml).contains(""); + assertThat(xml).contains("JSON to XML"); + + XmlParser xmlParser = new XmlParser(); + List errors = xmlParser.validate(xml.getBytes(), Version.VERSION_16); + assertThat(errors).isEmpty(); + } + + // ============================================================================ + // MIGRATION SCENARIOS - Cross-version compatibility + // ============================================================================ + + @Test + @DisplayName("Migration: v1.2 XML with author → v1.6 XML (deprecated field should work)") + public void testMigration_V12ToV16_AuthorString() throws Exception { + String xmlV12 = "\n" + + "\n" + + " \n" + + " \n" + + " Migrated Author\n" + + " test-lib\n" + + " 1.0.0\n" + + " \n" + + " \n" + + ""; + + XmlParser parser = new XmlParser(); + Bom bom = parser.parse(xmlV12.getBytes()); + + // Serialize as v1.6 - deprecated field should still appear + BomXmlGenerator generator = BomGeneratorFactory.createXml(Version.VERSION_16, bom); + String xmlV16 = generator.toXmlString(); + + assertThat(xmlV16).contains("Migrated Author"); + + List errors = parser.validate(xmlV16.getBytes(), Version.VERSION_16); + assertThat(errors).isEmpty(); + } + + @Test + @DisplayName("Migration: v1.6 XML with authors → v1.5 XML (should handle gracefully)") + public void testMigration_V16ToV15_AuthorsList() throws Exception { + String xmlV16 = "\n" + + "\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " New Format Author\n" + + " \n" + + " \n" + + " test-lib\n" + + " 1.0.0\n" + + " \n" + + " \n" + + ""; + + XmlParser parser = new XmlParser(); + Bom bom = parser.parse(xmlV16.getBytes()); + + // Serialize as v1.5 - authors list should not appear (not in spec) + BomXmlGenerator generator = BomGeneratorFactory.createXml(Version.VERSION_15, bom); + String xmlV15 = generator.toXmlString(); + + assertThat(xmlV15).doesNotContain(""); + + List errors = parser.validate(xmlV15.getBytes(), Version.VERSION_15); + assertThat(errors).isEmpty(); + } + + // ============================================================================ + // Helper Methods + // ============================================================================ + + private Component createComponentWithAuthorString(String author) { + Component component = new Component(); + component.setType(Component.Type.LIBRARY); + component.setName("test-lib"); + component.setVersion("1.0.0"); + component.setAuthor(author); + return component; + } + + private Component createComponentWithAuthorsList(List authors) { + Component component = new Component(); + component.setType(Component.Type.LIBRARY); + component.setName("test-lib"); + component.setVersion("1.0.0"); + component.setAuthors(authors); + return component; + } + + private OrganizationalContact createOrganizationalContact(String name, String email, String phone) { + OrganizationalContact contact = new OrganizationalContact(); + contact.setName(name); + if (email != null) { + contact.setEmail(email); + } + if (phone != null) { + contact.setPhone(phone); + } + return contact; + } + + private Bom createBom(Component component) { + Bom bom = new Bom(); + // Use a valid UUID format + bom.setSerialNumber("urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79"); + bom.setVersion(1); + bom.addComponent(component); + return bom; + } +} \ No newline at end of file diff --git a/src/test/java/org/cyclonedx/Issue638RegressionTest.java b/src/test/java/org/cyclonedx/Issue638RegressionTest.java new file mode 100644 index 000000000..8f5c8f490 --- /dev/null +++ b/src/test/java/org/cyclonedx/Issue638RegressionTest.java @@ -0,0 +1,177 @@ +/* + * 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; + +import org.cyclonedx.Version; +import org.cyclonedx.exception.ParseException; +import org.cyclonedx.generators.BomGeneratorFactory; +import org.cyclonedx.generators.xml.BomXmlGenerator; +import org.cyclonedx.model.Bom; +import org.cyclonedx.model.Component; +import org.cyclonedx.model.OrganizationalContact; +import org.cyclonedx.parsers.XmlParser; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Regression test for GitHub issue #638: XML serialization of components with authors results in invalid CycloneDX SBOM + * + * This test verifies that components with authors serialize correctly to XML format, + * producing valid CycloneDX SBOM structure instead of nested <authors> tags. + */ +public class Issue638RegressionTest { + + @Test + @DisplayName("Should serialize component authors correctly in XML format") + public void testComponentAuthorsXmlSerialization() throws Exception { + // Arrange - Create a component with authors similar to the issue example + Component component = new Component(); + component.setType(Component.Type.LIBRARY); + component.setBomRef("Maven:me.xdrop:fuzzywuzzy:1.4.0"); + component.setName("fuzzywuzzy"); + component.setVersion("1.4.0"); + + // Create authors + OrganizationalContact author1 = new OrganizationalContact(); + author1.setName("Panayiotis P"); + + OrganizationalContact author2 = new OrganizationalContact(); + author2.setName("Jane Doe"); + author2.setEmail("jane@example.com"); + + component.setAuthors(Arrays.asList(author1, author2)); + + // Create BOM + Bom bom = new Bom(); + bom.setSerialNumber("urn:uuid:12345678-1234-1234-1234-123456789abc"); + bom.setVersion(1); + bom.addComponent(component); + + // Act - Generate XML + BomXmlGenerator generator = BomGeneratorFactory.createXml(Version.VERSION_16, bom); + String xmlOutput = generator.toXmlString(); + + // Assert - Validate against schema first + XmlParser xmlParser = new XmlParser(); + List validationErrors = xmlParser.validate(xmlOutput.getBytes(), Version.VERSION_16); + assertTrue(validationErrors.isEmpty(), "Generated XML should validate against schema: " + validationErrors); + + // Verify correct XML structure + assertThat(xmlOutput).isNotNull(); + + // Should contain proper authors structure: ... + assertThat(xmlOutput).contains(""); + assertThat(xmlOutput).contains(""); + assertThat(xmlOutput).contains(""); + assertThat(xmlOutput).contains(""); + assertThat(xmlOutput).contains("Panayiotis P"); + assertThat(xmlOutput).contains("Jane Doe"); + assertThat(xmlOutput).contains("jane@example.com"); + + // Should NOT contain nested authors tags (the bug) + assertThat(xmlOutput).doesNotContain(""); + assertThat(xmlOutput).doesNotContain(""); + + // Verify the structure matches expected format from issue description + assertThat(xmlOutput).containsPattern("(?s)\\s*\\s*Panayiotis P\\s*"); + assertThat(xmlOutput).containsPattern("(?s).*\\s*Jane Doe\\s*jane@example.com\\s*"); + } + + @Test + @DisplayName("Should handle component with single author correctly") + public void testComponentSingleAuthorXmlSerialization() throws Exception { + // Arrange + Component component = new Component(); + component.setType(Component.Type.LIBRARY); + component.setBomRef("single-author-component"); + component.setName("example-lib"); + component.setVersion("1.0.0"); + + OrganizationalContact author = new OrganizationalContact(); + author.setName("John Smith"); + author.setEmail("john@example.com"); + author.setPhone("+1-555-1234"); + + component.setAuthors(Arrays.asList(author)); + + Bom bom = new Bom(); + bom.setSerialNumber("urn:uuid:12345678-1234-5678-9abc-123456789def"); + bom.setVersion(1); + bom.addComponent(component); + + // Act + BomXmlGenerator generator = BomGeneratorFactory.createXml(Version.VERSION_16, bom); + String xmlOutput = generator.toXmlString(); + + // Assert - Validate against schema first + XmlParser xmlParser = new XmlParser(); + List validationErrors = xmlParser.validate(xmlOutput.getBytes(), Version.VERSION_16); + assertTrue(validationErrors.isEmpty(), "Generated XML should validate against schema: " + validationErrors); + + // Verify structure + assertThat(xmlOutput).contains(""); + assertThat(xmlOutput).contains(""); + assertThat(xmlOutput).contains(""); + assertThat(xmlOutput).contains(""); + assertThat(xmlOutput).contains("John Smith"); + assertThat(xmlOutput).contains("john@example.com"); + assertThat(xmlOutput).contains("+1-555-1234"); + + // Should NOT contain the bug (nested authors) + assertThat(xmlOutput).doesNotContain(""); + } + + @Test + @DisplayName("Should handle component with no authors correctly") + public void testComponentNoAuthorsXmlSerialization() throws Exception { + // Arrange + Component component = new Component(); + component.setType(Component.Type.LIBRARY); + component.setBomRef("no-authors-component"); + component.setName("no-author-lib"); + component.setVersion("1.0.0"); + // No authors set + + Bom bom = new Bom(); + bom.setSerialNumber("urn:uuid:12345678-1234-5678-9abc-123456789fed"); + bom.setVersion(1); + bom.addComponent(component); + + // Act + BomXmlGenerator generator = BomGeneratorFactory.createXml(Version.VERSION_16, bom); + String xmlOutput = generator.toXmlString(); + + // Assert - Validate against schema first + XmlParser xmlParser = new XmlParser(); + List validationErrors = xmlParser.validate(xmlOutput.getBytes(), Version.VERSION_16); + assertTrue(validationErrors.isEmpty(), "Generated XML should validate against schema: " + validationErrors); + + // Verify no authors section + assertThat(xmlOutput).doesNotContain(""); + assertThat(xmlOutput).doesNotContain(""); + assertThat(xmlOutput).doesNotContain(""); + assertThat(xmlOutput).doesNotContain(""); + } +} \ No newline at end of file diff --git a/src/test/java/org/cyclonedx/SimpleAuthorTest.java b/src/test/java/org/cyclonedx/SimpleAuthorTest.java new file mode 100644 index 000000000..855ac50ed --- /dev/null +++ b/src/test/java/org/cyclonedx/SimpleAuthorTest.java @@ -0,0 +1,83 @@ +/* + * 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; + +import org.cyclonedx.Version; +import org.cyclonedx.exception.ParseException; +import org.cyclonedx.generators.BomGeneratorFactory; +import org.cyclonedx.generators.xml.BomXmlGenerator; +import org.cyclonedx.model.Bom; +import org.cyclonedx.model.Component; +import org.cyclonedx.model.OrganizationalContact; +import org.cyclonedx.parsers.XmlParser; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Simple test to debug author field serialization + */ +public class SimpleAuthorTest { + + @Test + public void testBasicAuthorSerialization() throws Exception { + // Create a very simple component with just the author field + Component component = new Component(); + component.setType(Component.Type.LIBRARY); + component.setName("test"); + component.setVersion("1.0"); + component.setAuthor("Test Author"); + + // Also set the new authors field for v1.6 testing + OrganizationalContact newAuthor = new OrganizationalContact(); + newAuthor.setName("New Style Author"); + newAuthor.setEmail("author@example.com"); + component.setAuthors(Arrays.asList(newAuthor)); + + System.out.println("Author field value: " + component.getAuthor()); + System.out.println("Authors field value: " + component.getAuthors().get(0).getName()); + + Bom bom = new Bom(); + bom.setSerialNumber("urn:uuid:12345678-1234-5678-9abc-123456789abc"); + bom.setVersion(1); + bom.addComponent(component); + + // Try different versions + XmlParser xmlParser = new XmlParser(); + for (Version version : new Version[]{Version.VERSION_12, Version.VERSION_13, Version.VERSION_14, Version.VERSION_15, Version.VERSION_16}) { + BomXmlGenerator generator = BomGeneratorFactory.createXml(version, bom); + String xml = generator.toXmlString(); + System.out.println("=== " + version.getVersionString() + " ==="); + System.out.println(xml); + System.out.println(); + + // Validate the generated XML against the schema + List validationErrors = xmlParser.validate(xml.getBytes(), version); + assertTrue(validationErrors.isEmpty(), + String.format("Validation failed for version %s: %s", version.getVersionString(), + validationErrors.toString())); + + System.out.println("✅ Validation passed for " + version.getVersionString()); + System.out.println(); + } + } +} \ No newline at end of file