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