diff --git a/src/main/java/org/cyclonedx/model/Component.java b/src/main/java/org/cyclonedx/model/Component.java index bc6a218a4..48f73f9ee 100644 --- a/src/main/java/org/cyclonedx/model/Component.java +++ b/src/main/java/org/cyclonedx/model/Component.java @@ -41,6 +41,7 @@ import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement; import com.github.packageurl.PackageURL; +import org.cyclonedx.util.deserializer.LicenseChoiceDeserializer; import org.cyclonedx.util.deserializer.LicenseDeserializer; import org.cyclonedx.util.deserializer.PropertiesDeserializer; @@ -363,11 +364,12 @@ public void addHash(Hash hash) { } @JsonDeserialize(using = LicenseDeserializer.class) + @JacksonXmlElementWrapper (useWrapping = false) public LicenseChoice getLicenses() { return licenses; } - @JacksonXmlElementWrapper (useWrapping = false) + @JsonDeserialize(using = LicenseChoiceDeserializer.class) public void setLicenses(LicenseChoice licenses) { this.licenses = licenses; } @@ -613,6 +615,44 @@ public void setManufacturer(final OrganizationalEntity manufacturer) { this.manufacturer = manufacturer; } + /** + * Validates that the component conforms to CycloneDX 1.7 choice group constraints. + * Specifically: + * - version and versionRange are mutually exclusive (xs:choice group) + * - versionRange should have isExternal=true + * + * @throws IllegalStateException if validation fails + */ + public void validate() { + validateVersionChoice(); + validateVersionRangeRequirements(); + } + + /** + * Validates that version and versionRange are not both set (xs:choice constraint) + */ + private void validateVersionChoice() { + if (version != null && versionRange != null) { + throw new IllegalStateException( + "Component cannot have both 'version' and 'versionRange' set. " + + "These fields are mutually exclusive per CycloneDX 1.7 schema (xs:choice group). " + + "Component: " + (name != null ? name : "unknown")); + } + } + + /** + * Validates that versionRange is used correctly with isExternal. + * Note: This is a warning-level validation. versionRange typically requires isExternal=true, + * but we don't enforce it as a hard error to allow flexibility. + */ + private void validateVersionRangeRequirements() { + if (versionRange != null && !Boolean.TRUE.equals(isExternal)) { + // This is informational - in 1.7, versionRange is typically used with isExternal=true + // to indicate external components with version ranges rather than fixed versions. + // We don't throw an exception here, just allow it through with this note. + } + } + @Override public int hashCode() { return Objects.hash(author, publisher, group, name, version, description, scope, hashes, licenses, copyright, diff --git a/src/main/java/org/cyclonedx/model/LicenseChoice.java b/src/main/java/org/cyclonedx/model/LicenseChoice.java index d0d905ca4..a019e4768 100644 --- a/src/main/java/org/cyclonedx/model/LicenseChoice.java +++ b/src/main/java/org/cyclonedx/model/LicenseChoice.java @@ -21,69 +21,191 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper; -import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; import org.cyclonedx.Version; import org.cyclonedx.model.license.Expression; import org.cyclonedx.model.license.ExpressionDetailed; -import org.cyclonedx.util.deserializer.LicenseDeserializer; +import org.cyclonedx.util.deserializer.LicenseChoiceDeserializer; +/** + * Represents a choice of licenses for a component or service. + * In CycloneDX 1.7+, this implements an item-level choice model where an array + * can contain a mix of License, Expression, and ExpressionDetailed objects. + * For earlier versions (1.6 and below), this enforces an array-level choice where + * the entire array must be either all licenses, or a single expression, or a single + * detailed expression. + * + * @since 9.0.0 + */ @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_EMPTY) -@JsonDeserialize(using = LicenseDeserializer.class) +@JsonDeserialize(using = LicenseChoiceDeserializer.class) public class LicenseChoice { - @JacksonXmlElementWrapper(useWrapping = false) - private List license; - private Expression expression; + private List items; - @VersionFilter(Version.VERSION_17) - @JacksonXmlProperty(localName = "expression-detailed") - private ExpressionDetailed expressionDetailed; + /** + * Gets the list of license items. Each item can be a License, Expression, or ExpressionDetailed. + * This is the primary API for CycloneDX 1.7+ support. + */ + public List getItems() { + return items; + } - @JacksonXmlProperty(localName = "license") - public List getLicenses() { - return license; + public void setItems(List items) { + this.items = items; } - public void setLicenses(List licenses) { - this.license = licenses; - this.expression = null; - this.expressionDetailed = null; + /** + * Adds a license item to the choice + */ + public void addItem(LicenseItem item) { + if (this.items == null) { + this.items = new ArrayList<>(); + } + this.items.add(item); } + /** + * Convenience method to add a License + */ public void addLicense(License license) { - if (this.license == null) { - this.license = new ArrayList<>(); + addItem(LicenseItem.ofLicense(license)); + } + + /** + * Convenience method to add an Expression + */ + public void addExpression(Expression expression) { + addItem(LicenseItem.ofExpression(expression)); + } + + /** + * Convenience method to add an ExpressionDetailed + */ + public void addExpressionDetailed(ExpressionDetailed expressionDetailed) { + addItem(LicenseItem.ofExpressionDetailed(expressionDetailed)); + } + + // ========== Backward Compatibility Methods ========== + + /** + * @deprecated Use {@link #getItems()} and filter by type instead. + * Returns only License items for backward compatibility with pre-1.7 API. + */ + @Deprecated + @JsonIgnore + public List getLicenses() { + if (items == null) return null; + List licenses = items.stream() + .filter(item -> item.getLicense() != null) + .map(LicenseItem::getLicense) + .collect(Collectors.toList()); + return licenses.isEmpty() ? null : licenses; + } + + /** + * @deprecated Use {@link #setItems(List)} with LicenseItem.ofLicense() instead. + * Sets licenses, clearing all other items. For backward compatibility with pre-1.7 API. + */ + @Deprecated + public void setLicenses(List licenses) { + if (licenses != null && !licenses.isEmpty()) { + this.items = new ArrayList<>(); + for (License license : licenses) { + this.items.add(LicenseItem.ofLicense(license)); + } + } else { + this.items = null; } - this.license.add(license); - this.expression = null; - this.expressionDetailed = null; } - @JacksonXmlProperty(localName = "expression") + /** + * @deprecated Use {@link #getItems()} and filter by type instead. + * Returns the first Expression item for backward compatibility with pre-1.7 API. + */ + @Deprecated + @JsonIgnore public Expression getExpression() { - return expression; + if (items == null) return null; + return items.stream() + .filter(item -> item.getExpression() != null) + .map(LicenseItem::getExpression) + .findFirst() + .orElse(null); } + /** + * @deprecated Use {@link #setItems(List)} with LicenseItem.ofExpression() instead. + * Sets a single expression, clearing all other items. For backward compatibility with pre-1.7 API. + */ + @Deprecated public void setExpression(Expression expression) { - this.expression = expression; - this.license = null; - this.expressionDetailed = null; + if (expression != null) { + this.items = new ArrayList<>(); + this.items.add(LicenseItem.ofExpression(expression)); + } else { + this.items = null; + } } - @VersionFilter(Version.VERSION_17) + + /** + * Returns the first ExpressionDetailed item. + * Note: This is part of the 1.7 API, not deprecated. + */ + @JsonIgnore public ExpressionDetailed getExpressionDetailed() { - return expressionDetailed; + if (items == null) return null; + return items.stream() + .filter(item -> item.getExpressionDetailed() != null) + .map(LicenseItem::getExpressionDetailed) + .findFirst() + .orElse(null); } + /** + * Sets a single detailed expression, clearing all other items. + * Note: This is part of the 1.7 API, not deprecated. + */ public void setExpressionDetailed(ExpressionDetailed expressionDetailed) { - this.expressionDetailed = expressionDetailed; - this.license = null; - this.expression = null; + if (expressionDetailed != null) { + this.items = new ArrayList<>(); + this.items.add(LicenseItem.ofExpressionDetailed(expressionDetailed)); + } else { + this.items = null; + } + } + + /** + * Validates whether this choice conforms to a specific version's constraints. + * For 1.6 and below: array-level choice (all licenses OR single expression OR single expressionDetailed) + * For 1.7+: item-level choice (mix allowed) + */ + public boolean isValidForVersion(Version version) { + if (items == null || items.isEmpty()) { + return true; + } + + if (version.getVersion() >= Version.VERSION_17.getVersion()) { + // 1.7+: All items must be valid + return items.stream().allMatch(LicenseItem::isValid); + } else { + // 1.6 and below: Array-level choice + long licenseCount = items.stream().filter(i -> i.getLicense() != null).count(); + long expressionCount = items.stream().filter(i -> i.getExpression() != null).count(); + long expressionDetailedCount = items.stream().filter(i -> i.getExpressionDetailed() != null).count(); + + // Must be: all licenses, OR single expression, OR single expressionDetailed + return (licenseCount > 0 && expressionCount == 0 && expressionDetailedCount == 0) || + (licenseCount == 0 && expressionCount == 1 && expressionDetailedCount == 0) || + (licenseCount == 0 && expressionCount == 0 && expressionDetailedCount == 1); + } } @Override @@ -91,13 +213,12 @@ public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof LicenseChoice)) return false; LicenseChoice that = (LicenseChoice) o; - return Objects.equals(license, that.license) && - Objects.equals(expression, that.expression) && - Objects.equals(expressionDetailed, that.expressionDetailed); + return Objects.equals(items, that.items); } @Override public int hashCode() { - return Objects.hash(license, expression, expressionDetailed); + return Objects.hash(items); } } + diff --git a/src/main/java/org/cyclonedx/model/LicenseItem.java b/src/main/java/org/cyclonedx/model/LicenseItem.java new file mode 100644 index 000000000..2bdd4efb2 --- /dev/null +++ b/src/main/java/org/cyclonedx/model/LicenseItem.java @@ -0,0 +1,166 @@ +/* + * 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.model; + +import java.util.Objects; + +import org.cyclonedx.model.license.Expression; +import org.cyclonedx.model.license.ExpressionDetailed; + +/** + * Represents a single item in a license choice, where each item can be one of: + * - A License + * - An Expression + * - An ExpressionDetailed + * + * This implements the CycloneDX 1.7 item-level choice model, where an array can + * contain a mix of different license types. + * + * @since 9.0.0 + */ +public class LicenseItem { + + private License license; + private Expression expression; + private ExpressionDetailed expressionDetailed; + + /** + * Default constructor for deserialization + */ + public LicenseItem() { + } + + /** + * Private constructor to enforce factory methods + */ + private LicenseItem(License license, Expression expression, ExpressionDetailed expressionDetailed) { + this.license = license; + this.expression = expression; + this.expressionDetailed = expressionDetailed; + } + + /** + * Creates a LicenseItem containing a License + */ + public static LicenseItem ofLicense(License license) { + if (license == null) { + throw new IllegalArgumentException("License cannot be null"); + } + return new LicenseItem(license, null, null); + } + + /** + * Creates a LicenseItem containing an Expression + */ + public static LicenseItem ofExpression(Expression expression) { + if (expression == null) { + throw new IllegalArgumentException("Expression cannot be null"); + } + return new LicenseItem(null, expression, null); + } + + /** + * Creates a LicenseItem containing an ExpressionDetailed + */ + public static LicenseItem ofExpressionDetailed(ExpressionDetailed expressionDetailed) { + if (expressionDetailed == null) { + throw new IllegalArgumentException("ExpressionDetailed cannot be null"); + } + return new LicenseItem(null, null, expressionDetailed); + } + + public License getLicense() { + return license; + } + + public void setLicense(License license) { + if (license != null) { + this.expression = null; + this.expressionDetailed = null; + } + this.license = license; + } + + public Expression getExpression() { + return expression; + } + + public void setExpression(Expression expression) { + if (expression != null) { + this.license = null; + this.expressionDetailed = null; + } + this.expression = expression; + } + + public ExpressionDetailed getExpressionDetailed() { + return expressionDetailed; + } + + public void setExpressionDetailed(ExpressionDetailed expressionDetailed) { + if (expressionDetailed != null) { + this.license = null; + this.expression = null; + } + this.expressionDetailed = expressionDetailed; + } + + /** + * Returns the type of license item + */ + public LicenseItemType getType() { + if (license != null) return LicenseItemType.LICENSE; + if (expression != null) return LicenseItemType.EXPRESSION; + if (expressionDetailed != null) return LicenseItemType.EXPRESSION_DETAILED; + return LicenseItemType.NONE; + } + + /** + * Validates that exactly one field is set + */ + public boolean isValid() { + int count = 0; + if (license != null) count++; + if (expression != null) count++; + if (expressionDetailed != null) count++; + return count == 1; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof LicenseItem)) return false; + LicenseItem that = (LicenseItem) o; + return Objects.equals(license, that.license) && + Objects.equals(expression, that.expression) && + Objects.equals(expressionDetailed, that.expressionDetailed); + } + + @Override + public int hashCode() { + return Objects.hash(license, expression, expressionDetailed); + } + + public enum LicenseItemType { + LICENSE, + EXPRESSION, + EXPRESSION_DETAILED, + NONE + } +} diff --git a/src/main/java/org/cyclonedx/model/OrganizationalChoice.java b/src/main/java/org/cyclonedx/model/OrganizationalChoice.java index 6bc2f0eaa..6e5a6e054 100644 --- a/src/main/java/org/cyclonedx/model/OrganizationalChoice.java +++ b/src/main/java/org/cyclonedx/model/OrganizationalChoice.java @@ -22,9 +22,12 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import org.cyclonedx.util.deserializer.OrganizationalChoiceDeserializer; @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_EMPTY) +@JsonDeserialize(using = OrganizationalChoiceDeserializer.class) public class OrganizationalChoice { private OrganizationalContact individual; diff --git a/src/main/java/org/cyclonedx/model/license/ExpressionDetail.java b/src/main/java/org/cyclonedx/model/license/ExpressionDetail.java index 5d54e59d2..c86cd2384 100644 --- a/src/main/java/org/cyclonedx/model/license/ExpressionDetail.java +++ b/src/main/java/org/cyclonedx/model/license/ExpressionDetail.java @@ -38,6 +38,8 @@ @JsonPropertyOrder({"licenseIdentifier", "bomRef", "text", "url"}) public class ExpressionDetail { + @JacksonXmlProperty(isAttribute = true, localName = "license-identifier") + @JsonProperty("licenseIdentifier") private String licenseIdentifier; @JacksonXmlProperty(isAttribute = true, localName = "bom-ref") diff --git a/src/main/java/org/cyclonedx/model/license/ExpressionDetailed.java b/src/main/java/org/cyclonedx/model/license/ExpressionDetailed.java index eadc4f6dd..8584cc353 100644 --- a/src/main/java/org/cyclonedx/model/license/ExpressionDetailed.java +++ b/src/main/java/org/cyclonedx/model/license/ExpressionDetailed.java @@ -18,6 +18,7 @@ */ package org.cyclonedx.model.license; +import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; @@ -44,10 +45,12 @@ }) public class ExpressionDetailed extends ExtensibleElement { + @JacksonXmlProperty(isAttribute = true) private String expression; - @JacksonXmlElementWrapper(localName = "expressionDetails") - @JacksonXmlProperty(localName = "expressionDetail") + @JacksonXmlElementWrapper(useWrapping = false) + @JacksonXmlProperty(localName = "details") + @JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) private List expressionDetails; @JacksonXmlProperty(isAttribute = true) diff --git a/src/main/java/org/cyclonedx/util/deserializer/LicenseChoiceDeserializer.java b/src/main/java/org/cyclonedx/util/deserializer/LicenseChoiceDeserializer.java new file mode 100644 index 000000000..b41fb02d1 --- /dev/null +++ b/src/main/java/org/cyclonedx/util/deserializer/LicenseChoiceDeserializer.java @@ -0,0 +1,166 @@ +/* + * 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 java.io.IOException; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import org.cyclonedx.model.License; +import org.cyclonedx.model.LicenseChoice; +import org.cyclonedx.model.LicenseItem; +import org.cyclonedx.model.license.Expression; +import org.cyclonedx.model.license.ExpressionDetailed; + +/** + * Deserializer for LicenseChoice that handles both CycloneDX 1.6 (array-level choice) + * and 1.7+ (item-level choice) formats. + */ +public class LicenseChoiceDeserializer extends JsonDeserializer +{ + + final ExpressionDeserializer expressionDeserializer = new ExpressionDeserializer(); + + @Override + public LicenseChoice deserialize( + final JsonParser p, final DeserializationContext ctxt) throws IOException + { + ObjectMapper codec = (ObjectMapper) p.getCodec(); + boolean isXml = codec instanceof XmlMapper; + JsonNode rootNode = p.getCodec().readTree(p); + + if (!rootNode.isEmpty()) { + LicenseChoice licenseChoice = new LicenseChoice(); + + if (isXml) { + // For XML, the root node contains all license choice items as fields + // (license, expression, expression-detailed) + processXmlNode(p, rootNode, licenseChoice, ctxt); + } else { + // For JSON, the root node is an array of individual license items + ArrayNode nodes = DeserializerUtils.getArrayNode(rootNode, null); + for (JsonNode node : nodes) { + processJsonNode(p, node, licenseChoice, ctxt); + } + } + return licenseChoice; + } + return null; + } + + private void processXmlNode(JsonParser p, JsonNode node, LicenseChoice licenseChoice, DeserializationContext ctxt) + throws IOException { + // XML format: node contains fields for "license", "expression", and/or "expression-detailed" + // Each field can be a single item or an array + + // Process all license elements + if (node.has("license")) { + processLicenseNode(p, node.get("license"), licenseChoice); + } + + // Process all expression elements + if (node.has("expression")) { + JsonNode exprNode = node.get("expression"); + if (exprNode.isArray()) { + for (JsonNode expr : exprNode) { + processExpression(p, expr, licenseChoice, ctxt); + } + } else { + processExpression(p, exprNode, licenseChoice, ctxt); + } + } + + // Process all expression-detailed elements + if (node.has("expression-detailed")) { + JsonNode exprDetailedNode = node.get("expression-detailed"); + if (exprDetailedNode.isArray()) { + for (JsonNode exprDetailed : exprDetailedNode) { + processExpressionDetailed(p, exprDetailed, licenseChoice); + } + } else { + processExpressionDetailed(p, exprDetailedNode, licenseChoice); + } + } + } + + private void processJsonNode(JsonParser p, JsonNode node, LicenseChoice licenseChoice, DeserializationContext ctxt) + throws IOException { + // JSON format for 1.7+: object with "license", "expression", or "expression-detailed" property + // JSON format for 1.6-: license/expression object directly in array + if (node.has("expression-detailed")) { + processExpressionDetailed(p, node.get("expression-detailed"), licenseChoice); + } + else if (node.has("expressionDetails") || + (node.has("expression") && (node.has("licensing") || node.has("properties")))) { + // ExpressionDetailed in JSON format: has expressionDetails, or expression with licensing/properties. + // These fields only exist on ExpressionDetailed, not on simple Expression. + processExpressionDetailed(p, node, licenseChoice); + } + else if (node.has("license")) { + // 1.7+ format: {"license": {...}} + processLicenseNode(p, node.get("license"), licenseChoice); + } + else if (node.has("expression")) { + // 1.6- format: expression object directly in array (e.g., {"expression": "MIT", "acknowledgement": "declared"}) + // The node itself IS the expression object + processExpression(p, node, licenseChoice, ctxt); + } + else { + // 1.6- format: license object directly in array + License license = p.getCodec().treeToValue(node, License.class); + licenseChoice.addLicense(license); + } + } + + private void processLicenseNode(JsonParser p, JsonNode licenseNode, LicenseChoice licenseChoice) + throws IOException { + ArrayNode licenseNodes = DeserializerUtils.getArrayNode(licenseNode, null); + + for (JsonNode license : licenseNodes) { + License licenseObj = p.getCodec().treeToValue(license, License.class); + licenseChoice.addLicense(licenseObj); + } + } + + private void processExpression( + final JsonParser p, + JsonNode node, + LicenseChoice licenseChoice, + DeserializationContext ctxt) throws IOException + { + JsonParser expressionParser = node.traverse(p.getCodec()); + expressionParser.nextToken(); + Expression expression = expressionDeserializer.deserialize(expressionParser, ctxt); + licenseChoice.addExpression(expression); + } + + private void processExpressionDetailed( + final JsonParser p, + JsonNode node, + LicenseChoice licenseChoice) throws IOException + { + ExpressionDetailed expressionDetailed = p.getCodec().treeToValue(node, ExpressionDetailed.class); + licenseChoice.addExpressionDetailed(expressionDetailed); + } +} diff --git a/src/main/java/org/cyclonedx/util/deserializer/OrganizationalChoiceDeserializer.java b/src/main/java/org/cyclonedx/util/deserializer/OrganizationalChoiceDeserializer.java index 451409a50..f5e734e3a 100644 --- a/src/main/java/org/cyclonedx/util/deserializer/OrganizationalChoiceDeserializer.java +++ b/src/main/java/org/cyclonedx/util/deserializer/OrganizationalChoiceDeserializer.java @@ -21,57 +21,47 @@ import java.io.IOException; import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.core.JsonToken; import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; import com.fasterxml.jackson.databind.JsonNode; import org.cyclonedx.model.OrganizationalChoice; import org.cyclonedx.model.OrganizationalContact; import org.cyclonedx.model.OrganizationalEntity; -public class OrganizationalChoiceDeserializer - extends JsonDeserializer +/** + * Deserializer for OrganizationalChoice that handles both: + * 1. Object format: {"individual": {...}} or {"organization": {...}} + * 2. String format: "bom-ref" (reference to an organization defined elsewhere) + */ +public class OrganizationalChoiceDeserializer extends JsonDeserializer { @Override - public OrganizationalChoice deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException { - JsonNode node = jp.getCodec().readTree(jp); - OrganizationalChoice organizationalChoice = new OrganizationalChoice(); + public OrganizationalChoice deserialize(JsonParser p, DeserializationContext ctxt) throws IOException + { + if (p.currentToken() == JsonToken.VALUE_STRING) { + // Simple string format - this is a bom-ref reference + String bomRef = p.getText(); + OrganizationalEntity org = new OrganizationalEntity(); + org.setBomRef(bomRef); - if (node.has("individual")) { - OrganizationalContact individual = jp.getCodec().treeToValue(node.get("individual"), OrganizationalContact.class); - organizationalChoice.setIndividual(individual); - } else if (node.has("organization")) { - JsonNode organizationNode = node.get("organization"); - OrganizationalEntity organization = deserializeOrganization(jp, organizationNode); - organizationalChoice.setOrganization(organization); + OrganizationalChoice choice = new OrganizationalChoice(); + choice.setOrganization(org); + return choice; } - return organizationalChoice; - } + // Object format - deserialize normally + JsonNode node = p.getCodec().readTree(p); + OrganizationalChoice choice = new OrganizationalChoice(); - private OrganizationalEntity deserializeOrganization(JsonParser jp, JsonNode organizationNode) throws JsonProcessingException { - OrganizationalEntity organization = new OrganizationalEntity(); - if (organizationNode.has("name")) { - organization.setName(organizationNode.get("name").asText()); - } - - if (organizationNode.has("contact")) { - JsonNode contactsNode = organizationNode.get("contact"); - if (contactsNode.isArray()) { - for (JsonNode contactNode : contactsNode) { - addContactToOrganization(jp, organization, contactNode); - } - } else if (contactsNode.isObject()) { - addContactToOrganization(jp, organization, contactsNode); - } + if (node.has("individual")) { + OrganizationalContact individual = p.getCodec().treeToValue(node.get("individual"), OrganizationalContact.class); + choice.setIndividual(individual); + } else if (node.has("organization")) { + OrganizationalEntity organization = p.getCodec().treeToValue(node.get("organization"), OrganizationalEntity.class); + choice.setOrganization(organization); } - return organization; - } - private void addContactToOrganization(JsonParser jp, OrganizationalEntity organization, JsonNode node) - throws JsonProcessingException - { - OrganizationalContact contact = jp.getCodec().treeToValue(node, OrganizationalContact.class); - organization.addContact(contact); + return choice; } } diff --git a/src/main/java/org/cyclonedx/util/deserializer/OrganizationalEntityDeserializer.java b/src/main/java/org/cyclonedx/util/deserializer/OrganizationalEntityDeserializer.java index 065e1197c..94b8ad7aa 100644 --- a/src/main/java/org/cyclonedx/util/deserializer/OrganizationalEntityDeserializer.java +++ b/src/main/java/org/cyclonedx/util/deserializer/OrganizationalEntityDeserializer.java @@ -52,16 +52,19 @@ public OrganizationalEntity deserialize(JsonParser jsonParser, DeserializationCo } private List parseUrls(JsonNode urlNode) { + if (urlNode == null) { + return null; + } + List urls = new ArrayList<>(); - if (urlNode != null) { - if (urlNode.isArray()) { - for (JsonNode urlElement : urlNode) { - urls.add(urlElement.asText()); - } - } else if (urlNode.isTextual()) { - urls.add(urlNode.asText()); + if (urlNode.isArray()) { + for (JsonNode urlElement : urlNode) { + urls.add(urlElement.asText()); } + } else if (urlNode.isTextual()) { + urls.add(urlNode.asText()); } - return urls; + + return urls.isEmpty() ? null : urls; } } diff --git a/src/main/java/org/cyclonedx/util/serializer/LicenseChoiceSerializer.java b/src/main/java/org/cyclonedx/util/serializer/LicenseChoiceSerializer.java index 5438d9110..0861f6bbb 100644 --- a/src/main/java/org/cyclonedx/util/serializer/LicenseChoiceSerializer.java +++ b/src/main/java/org/cyclonedx/util/serializer/LicenseChoiceSerializer.java @@ -29,9 +29,12 @@ import org.cyclonedx.Version; import org.cyclonedx.model.License; import org.cyclonedx.model.LicenseChoice; +import org.cyclonedx.model.LicenseItem; import org.cyclonedx.model.Property; import org.cyclonedx.model.license.Acknowledgement; import org.cyclonedx.model.license.Expression; +import org.cyclonedx.model.license.ExpressionDetailed; +import org.cyclonedx.model.license.ExpressionDetail; import static org.cyclonedx.util.serializer.SerializerUtils.shouldSerializeField; @@ -61,6 +64,10 @@ public void serialize( return; } + // Note: We don't throw an exception for version incompatibility. + // If a 1.7 BOM with mixed license types is being serialized to an earlier version, + // we'll serialize what we can. The schema validation will catch any issues. + if (isXml && gen instanceof ToXmlGenerator) { ToXmlGenerator toXmlGenerator = (ToXmlGenerator) gen; serializeXml(toXmlGenerator, licenseChoice, provider); @@ -73,58 +80,69 @@ public void serialize( private void serializeXml(ToXmlGenerator toXmlGenerator, LicenseChoice lc, final SerializerProvider provider) throws IOException { - if (CollectionUtils.isNotEmpty(lc.getLicenses())) { - toXmlGenerator.writeStartObject(); - toXmlGenerator.writeFieldName("license"); + if (CollectionUtils.isEmpty(lc.getItems())) { toXmlGenerator.writeStartArray(); - for (License l : lc.getLicenses()) { - serializeXmlAttributes(toXmlGenerator, l.getBomRef(), l.getAcknowledgement(), l); - - if (StringUtils.isNotBlank(l.getId())) { - toXmlGenerator.writeStringField("id", l.getId()); - } - else if (StringUtils.isNotBlank(l.getName())) { - toXmlGenerator.writeStringField("name", l.getName()); - } - - if (l.getLicensing() != null && shouldSerializeField(l, version,"licensing")) { - toXmlGenerator.writeObjectField("licensing", l.getLicensing()); - } - - if (l.getAttachmentText() != null) { - toXmlGenerator.writeObjectField("text", l.getAttachmentText()); - } - - if (StringUtils.isNotBlank(l.getUrl())) { - toXmlGenerator.writeStringField("url", l.getUrl()); - } - - if (CollectionUtils.isNotEmpty(l.getProperties()) && shouldSerializeField(l, version, "properties")) { - toXmlGenerator.writeFieldName("properties"); - toXmlGenerator.writeStartObject(); - - for (Property property : l.getProperties()) { - toXmlGenerator.writeObjectField("property", property); - } - toXmlGenerator.writeEndObject(); - } - - //It might have extensible types - if(CollectionUtils.isNotEmpty(l.getExtensibleTypes())) { - new ExtensibleTypesSerializer().serialize(l.getExtensibleTypes(), toXmlGenerator, provider); - } - - toXmlGenerator.writeEndObject(); - } toXmlGenerator.writeEndArray(); + return; + } + + toXmlGenerator.writeStartObject(); + + for (LicenseItem item : lc.getItems()) { + if (item.getLicense() != null) { + serializeLicenseToXml(toXmlGenerator, item.getLicense(), provider); + } else if (item.getExpression() != null) { + serializeExpressionToXml(toXmlGenerator, item.getExpression()); + } else if (item.getExpressionDetailed() != null) { + serializeExpressionDetailedToXml(toXmlGenerator, item.getExpressionDetailed(), provider); + } + } + + toXmlGenerator.writeEndObject(); + } + + private void serializeLicenseToXml(ToXmlGenerator toXmlGenerator, License l, final SerializerProvider provider) + throws IOException + { + toXmlGenerator.writeFieldName("license"); + toXmlGenerator.writeStartObject(); + serializeXmlAttributes(toXmlGenerator, l.getBomRef(), l.getAcknowledgement(), l); + + if (StringUtils.isNotBlank(l.getId())) { + toXmlGenerator.writeStringField("id", l.getId()); + } + else if (StringUtils.isNotBlank(l.getName())) { + toXmlGenerator.writeStringField("name", l.getName()); + } + + if (l.getLicensing() != null && shouldSerializeField(l, version,"licensing")) { + toXmlGenerator.writeObjectField("licensing", l.getLicensing()); + } + + if (l.getAttachmentText() != null) { + toXmlGenerator.writeObjectField("text", l.getAttachmentText()); + } + + if (StringUtils.isNotBlank(l.getUrl())) { + toXmlGenerator.writeStringField("url", l.getUrl()); + } + + if (CollectionUtils.isNotEmpty(l.getProperties()) && shouldSerializeField(l, version, "properties")) { + toXmlGenerator.writeFieldName("properties"); + toXmlGenerator.writeStartObject(); + + for (Property property : l.getProperties()) { + toXmlGenerator.writeObjectField("property", property); + } toXmlGenerator.writeEndObject(); } - else if (lc.getExpression() != null) { - serializeExpressionToXml(lc, toXmlGenerator); - } else { - toXmlGenerator.writeStartArray(); - toXmlGenerator.writeEndArray(); + + //It might have extensible types + if(CollectionUtils.isNotEmpty(l.getExtensibleTypes())) { + new ExtensibleTypesSerializer().serialize(l.getExtensibleTypes(), toXmlGenerator, provider); } + + toXmlGenerator.writeEndObject(); } private void serializeXmlAttributes( @@ -133,8 +151,6 @@ private void serializeXmlAttributes( final Acknowledgement acknowledgement, final Object object) throws IOException { - toXmlGenerator.writeStartObject(); - if (StringUtils.isNotBlank(bomRef) && shouldSerializeField(object, version, "bomRef")) { toXmlGenerator.setNextIsAttribute(true); toXmlGenerator.writeFieldName("bom-ref"); @@ -153,50 +169,87 @@ private void serializeJson( final LicenseChoice licenseChoice, final JsonGenerator gen, final SerializerProvider provider) throws IOException { - if (CollectionUtils.isNotEmpty(licenseChoice.getLicenses())) { - serializeLicensesToJsonArray(licenseChoice, gen, provider); - } - else if (licenseChoice.getExpression() != null && - StringUtils.isNotBlank(licenseChoice.getExpression().getValue())) { - serializeExpressionToJson(licenseChoice, gen); - } else { + if (CollectionUtils.isEmpty(licenseChoice.getItems())) { gen.writeStartArray(); gen.writeEndArray(); - } + return; + } + + gen.writeStartArray(); + for (LicenseItem item : licenseChoice.getItems()) { + gen.writeStartObject(); + if (item.getLicense() != null) { + provider.defaultSerializeField("license", item.getLicense(), gen); + } else if (item.getExpression() != null) { + serializeExpressionToJson(item.getExpression(), gen); + } else if (item.getExpressionDetailed() != null) { + serializeExpressionDetailedToJson(item.getExpressionDetailed(), gen, provider); + } + gen.writeEndObject(); + } + gen.writeEndArray(); } private void serializeExpressionToXml( - final LicenseChoice licenseChoice, final ToXmlGenerator toXmlGenerator) + final ToXmlGenerator toXmlGenerator, final Expression expression) throws IOException { - toXmlGenerator.writeStartObject(); - Expression expression = licenseChoice.getExpression(); toXmlGenerator.writeFieldName("expression"); + toXmlGenerator.writeStartObject(); serializeXmlAttributes(toXmlGenerator, expression.getBomRef(), expression.getAcknowledgement(), expression); toXmlGenerator.setNextIsUnwrapped(true); toXmlGenerator.writeStringField("", expression.getValue()); toXmlGenerator.writeEndObject(); - toXmlGenerator.writeEndObject(); } - private void serializeLicensesToJsonArray( - final LicenseChoice licenseChoice, final JsonGenerator gen, final SerializerProvider provider) + private void serializeExpressionDetailedToXml( + final ToXmlGenerator toXmlGenerator, + final ExpressionDetailed expressionDetailed, + final SerializerProvider provider) throws IOException { - gen.writeStartArray(); - for (License license : licenseChoice.getLicenses()) { - gen.writeStartObject(); - provider.defaultSerializeField("license", license, gen); - gen.writeEndObject(); + if (version.getVersion() < 1.7) { + return; // ExpressionDetailed is only for 1.7+ } - gen.writeEndArray(); + + toXmlGenerator.writeFieldName("expression-detailed"); + toXmlGenerator.writeStartObject(); + + // Write expression as an attribute (required) + if (StringUtils.isNotBlank(expressionDetailed.getExpression())) { + toXmlGenerator.setNextIsAttribute(true); + toXmlGenerator.writeFieldName("expression"); + toXmlGenerator.writeString(expressionDetailed.getExpression()); + toXmlGenerator.setNextIsAttribute(false); + } + + // Write other attributes (bom-ref, acknowledgement) + serializeXmlAttributes(toXmlGenerator, expressionDetailed.getBomRef(), expressionDetailed.getAcknowledgement(), expressionDetailed); + + if (CollectionUtils.isNotEmpty(expressionDetailed.getExpressionDetails())) { + for (ExpressionDetail detail : expressionDetailed.getExpressionDetails()) { + toXmlGenerator.writeObjectField("details", detail); + } + } + + if (expressionDetailed.getLicensing() != null && shouldSerializeField(expressionDetailed, version, "licensing")) { + toXmlGenerator.writeObjectField("licensing", expressionDetailed.getLicensing()); + } + + if (CollectionUtils.isNotEmpty(expressionDetailed.getProperties()) && shouldSerializeField(expressionDetailed, version, "properties")) { + toXmlGenerator.writeFieldName("properties"); + toXmlGenerator.writeStartObject(); + for (Property property : expressionDetailed.getProperties()) { + toXmlGenerator.writeObjectField("property", property); + } + toXmlGenerator.writeEndObject(); + } + + toXmlGenerator.writeEndObject(); } - private void serializeExpressionToJson(final LicenseChoice licenseChoice, final JsonGenerator gen) + private void serializeExpressionToJson(final Expression expression, final JsonGenerator gen) throws IOException { - Expression expression = licenseChoice.getExpression(); - gen.writeStartArray(); - gen.writeStartObject(); gen.writeStringField("expression", expression.getValue()); if (expression.getAcknowledgement() != null && shouldSerializeField(expression, version, "acknowledgement")) { gen.writeStringField("acknowledgement", expression.getAcknowledgement().getValue()); @@ -204,7 +257,33 @@ private void serializeExpressionToJson(final LicenseChoice licenseChoice, final if (StringUtils.isNotBlank(expression.getBomRef()) && shouldSerializeField(expression, version, "bomRef")) { gen.writeStringField("bom-ref", expression.getBomRef()); } - gen.writeEndObject(); - gen.writeEndArray(); + } + + private void serializeExpressionDetailedToJson( + final ExpressionDetailed expressionDetailed, final JsonGenerator gen, final SerializerProvider provider) + throws IOException { + if (version.getVersion() < 1.7) { + return; // ExpressionDetailed is only for 1.7+ + } + + // Flatten the expressionDetailed fields into the license item object + if (StringUtils.isNotBlank(expressionDetailed.getBomRef())) { + gen.writeStringField("bom-ref", expressionDetailed.getBomRef()); + } + if (expressionDetailed.getAcknowledgement() != null) { + gen.writeObjectField("acknowledgement", expressionDetailed.getAcknowledgement()); + } + if (StringUtils.isNotBlank(expressionDetailed.getExpression())) { + gen.writeStringField("expression", expressionDetailed.getExpression()); + } + if (CollectionUtils.isNotEmpty(expressionDetailed.getExpressionDetails())) { + gen.writeObjectField("expressionDetails", expressionDetailed.getExpressionDetails()); + } + if (expressionDetailed.getLicensing() != null) { + gen.writeObjectField("licensing", expressionDetailed.getLicensing()); + } + if (CollectionUtils.isNotEmpty(expressionDetailed.getProperties())) { + gen.writeObjectField("properties", expressionDetailed.getProperties()); + } } } diff --git a/src/test/java/org/cyclonedx/BomJsonGeneratorTest.java b/src/test/java/org/cyclonedx/BomJsonGeneratorTest.java index 14f2f4add..ad42c76c8 100644 --- a/src/test/java/org/cyclonedx/BomJsonGeneratorTest.java +++ b/src/test/java/org/cyclonedx/BomJsonGeneratorTest.java @@ -680,6 +680,104 @@ public void testVulnerabilityParsing14_xml() throws Exception { assertTrue(parser.isValid(jsonString.getBytes(StandardCharsets.UTF_8), version)); } + // ==================== CycloneDX 1.7 License Tests ==================== + + @Test + public void schema17_testLicenseMixedChoice() throws Exception { + Version version = Version.VERSION_17; + Bom bom = createCommonJsonBom("/1.7/valid-license-choice-1.7.json"); + + BomJsonGenerator generator = BomGeneratorFactory.createJson(version, bom); + File loadedFile = writeToFile(generator.toJsonString()); + + JsonParser parser = new JsonParser(); + assertTrue(parser.isValid(loadedFile, version)); + } + + @Test + public void schema17_testLicenseMixedChoice_xml() throws Exception { + Version version = Version.VERSION_17; + Bom bom = createCommonXmlBom("/1.7/valid-license-choice-1.7.xml"); + + BomJsonGenerator generator = BomGeneratorFactory.createJson(version, bom); + File loadedFile = writeToFile(generator.toJsonString()); + + JsonParser parser = new JsonParser(); + assertTrue(parser.isValid(loadedFile, version)); + } + + @Test + public void schema17_testLicenseExpressionWithText() throws Exception { + Version version = Version.VERSION_17; + Bom bom = createCommonJsonBom("/1.7/valid-license-expression-with-text-1.7.json"); + + BomJsonGenerator generator = BomGeneratorFactory.createJson(version, bom); + File loadedFile = writeToFile(generator.toJsonString()); + + JsonParser parser = new JsonParser(); + assertTrue(parser.isValid(loadedFile, version)); + } + + @Test + public void schema17_testLicenseExpressionWithText_xml() throws Exception { + Version version = Version.VERSION_17; + Bom bom = createCommonXmlBom("/1.7/valid-license-expression-with-text-1.7.xml"); + + BomJsonGenerator generator = BomGeneratorFactory.createJson(version, bom); + File loadedFile = writeToFile(generator.toJsonString()); + + JsonParser parser = new JsonParser(); + assertTrue(parser.isValid(loadedFile, version)); + } + + @Test + public void schema17_testLicenseExpressionWithLicensing() throws Exception { + Version version = Version.VERSION_17; + Bom bom = createCommonJsonBom("/1.7/valid-license-expression-with-licensing-1.7.json"); + + BomJsonGenerator generator = BomGeneratorFactory.createJson(version, bom); + File loadedFile = writeToFile(generator.toJsonString()); + + JsonParser parser = new JsonParser(); + assertTrue(parser.isValid(loadedFile, version)); + } + + @Test + public void schema17_testLicenseExpressionWithLicensing_xml() throws Exception { + Version version = Version.VERSION_17; + Bom bom = createCommonXmlBom("/1.7/valid-license-expression-with-licensing-1.7.xml"); + + BomJsonGenerator generator = BomGeneratorFactory.createJson(version, bom); + File loadedFile = writeToFile(generator.toJsonString()); + + JsonParser parser = new JsonParser(); + assertTrue(parser.isValid(loadedFile, version)); + } + + @Test + public void schema17_testLicenseDeclaredConcludedMix() throws Exception { + Version version = Version.VERSION_17; + Bom bom = createCommonJsonBom("/1.7/valid-license-declared-concluded-mix-1.7.json"); + + BomJsonGenerator generator = BomGeneratorFactory.createJson(version, bom); + File loadedFile = writeToFile(generator.toJsonString()); + + JsonParser parser = new JsonParser(); + assertTrue(parser.isValid(loadedFile, version)); + } + + @Test + public void schema17_testLicenseDeclaredConcludedMix_xml() throws Exception { + Version version = Version.VERSION_17; + Bom bom = createCommonXmlBom("/1.7/valid-license-declared-concluded-mix-1.7.xml"); + + BomJsonGenerator generator = BomGeneratorFactory.createJson(version, bom); + File loadedFile = writeToFile(generator.toJsonString()); + + JsonParser parser = new JsonParser(); + assertTrue(parser.isValid(loadedFile, version)); + } + private void assertExternalReferenceInfo(Bom bom) { assertEquals(3, bom.getExternalReferences().size()); assertEquals(3, bom.getComponents().get(0).getExternalReferences().size()); diff --git a/src/test/java/org/cyclonedx/BomXmlGeneratorTest.java b/src/test/java/org/cyclonedx/BomXmlGeneratorTest.java index 91debb101..3699464c5 100644 --- a/src/test/java/org/cyclonedx/BomXmlGeneratorTest.java +++ b/src/test/java/org/cyclonedx/BomXmlGeneratorTest.java @@ -869,6 +869,104 @@ public void testIssue408Regression_jsonToXml_externalReferenceBom() throws Excep assertTrue(parser.isValid(loadedFile, version)); } + // ==================== CycloneDX 1.7 License Tests ==================== + + @Test + public void schema17_testLicenseMixedChoice() throws Exception { + Version version = Version.VERSION_17; + Bom bom = createCommonJsonBom("/1.7/valid-license-choice-1.7.json"); + + BomXmlGenerator generator = BomGeneratorFactory.createXml(version, bom); + File loadedFile = writeToFile(generator.toXmlString()); + + XmlParser parser = new XmlParser(); + assertTrue(parser.isValid(loadedFile, version)); + } + + @Test + public void schema17_testLicenseMixedChoice_xml() throws Exception { + Version version = Version.VERSION_17; + Bom bom = createCommonBomXml("/1.7/valid-license-choice-1.7.xml"); + + BomXmlGenerator generator = BomGeneratorFactory.createXml(version, bom); + File loadedFile = writeToFile(generator.toXmlString()); + + XmlParser parser = new XmlParser(); + assertTrue(parser.isValid(loadedFile, version)); + } + + @Test + public void schema17_testLicenseExpressionWithText() throws Exception { + Version version = Version.VERSION_17; + Bom bom = createCommonJsonBom("/1.7/valid-license-expression-with-text-1.7.json"); + + BomXmlGenerator generator = BomGeneratorFactory.createXml(version, bom); + File loadedFile = writeToFile(generator.toXmlString()); + + XmlParser parser = new XmlParser(); + assertTrue(parser.isValid(loadedFile, version)); + } + + @Test + public void schema17_testLicenseExpressionWithText_xml() throws Exception { + Version version = Version.VERSION_17; + Bom bom = createCommonBomXml("/1.7/valid-license-expression-with-text-1.7.xml"); + + BomXmlGenerator generator = BomGeneratorFactory.createXml(version, bom); + File loadedFile = writeToFile(generator.toXmlString()); + + XmlParser parser = new XmlParser(); + assertTrue(parser.isValid(loadedFile, version)); + } + + @Test + public void schema17_testLicenseExpressionWithLicensing() throws Exception { + Version version = Version.VERSION_17; + Bom bom = createCommonJsonBom("/1.7/valid-license-expression-with-licensing-1.7.json"); + + BomXmlGenerator generator = BomGeneratorFactory.createXml(version, bom); + File loadedFile = writeToFile(generator.toXmlString()); + + XmlParser parser = new XmlParser(); + assertTrue(parser.isValid(loadedFile, version)); + } + + @Test + public void schema17_testLicenseExpressionWithLicensing_xml() throws Exception { + Version version = Version.VERSION_17; + Bom bom = createCommonBomXml("/1.7/valid-license-expression-with-licensing-1.7.xml"); + + BomXmlGenerator generator = BomGeneratorFactory.createXml(version, bom); + File loadedFile = writeToFile(generator.toXmlString()); + + XmlParser parser = new XmlParser(); + assertTrue(parser.isValid(loadedFile, version)); + } + + @Test + public void schema17_testLicenseDeclaredConcludedMix() throws Exception { + Version version = Version.VERSION_17; + Bom bom = createCommonJsonBom("/1.7/valid-license-declared-concluded-mix-1.7.json"); + + BomXmlGenerator generator = BomGeneratorFactory.createXml(version, bom); + File loadedFile = writeToFile(generator.toXmlString()); + + XmlParser parser = new XmlParser(); + assertTrue(parser.isValid(loadedFile, version)); + } + + @Test + public void schema17_testLicenseDeclaredConcludedMix_xml() throws Exception { + Version version = Version.VERSION_17; + Bom bom = createCommonBomXml("/1.7/valid-license-declared-concluded-mix-1.7.xml"); + + BomXmlGenerator generator = BomGeneratorFactory.createXml(version, bom); + File loadedFile = writeToFile(generator.toXmlString()); + + XmlParser parser = new XmlParser(); + assertTrue(parser.isValid(loadedFile, version)); + } + private void assertExternalReferenceInfo(Bom bom) { assertEquals(3, bom.getExternalReferences().size()); assertEquals(3, bom.getComponents().get(0).getExternalReferences().size()); diff --git a/src/test/java/org/cyclonedx/parsers/JsonParserTest.java b/src/test/java/org/cyclonedx/parsers/JsonParserTest.java index d0efdbbea..56e8e490e 100644 --- a/src/test/java/org/cyclonedx/parsers/JsonParserTest.java +++ b/src/test/java/org/cyclonedx/parsers/JsonParserTest.java @@ -63,6 +63,8 @@ import org.cyclonedx.model.definition.Standard; import org.cyclonedx.model.license.Acknowledgement; import org.cyclonedx.model.license.Expression; +import org.cyclonedx.model.license.ExpressionDetailed; +import org.cyclonedx.model.license.ExpressionDetail; import org.junit.jupiter.api.Test; import java.io.File; import java.util.ArrayList; @@ -301,6 +303,244 @@ public void schema16_license_expression_acknowledgement() throws Exception { assertEquals(Acknowledgement.DECLARED, expression.getAcknowledgement()); } + @Test + public void schema17_license_mixed_choice() throws Exception { + final Bom bom = getJsonBom("1.7/valid-license-choice-1.7.json"); + + assertNotNull(bom.getComponents()); + Component component = bom.getComponents().get(0); + LicenseChoice lc = component.getLicenses(); + assertNotNull(lc); + assertNotNull(lc.getItems()); + assertEquals(4, lc.getItems().size()); + + // First item: license with id + assertNotNull(lc.getItems().get(0).getLicense()); + assertEquals("Apache-2.0", lc.getItems().get(0).getLicense().getId()); + + // Second item: expression + assertNotNull(lc.getItems().get(1).getExpression()); + assertEquals("EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0", lc.getItems().get(1).getExpression().getValue()); + + // Third item: license with name and text + assertNotNull(lc.getItems().get(2).getLicense()); + assertEquals("My Own License", lc.getItems().get(2).getLicense().getName()); + assertNotNull(lc.getItems().get(2).getLicense().getAttachmentText()); + assertTrue(lc.getItems().get(2).getLicense().getAttachmentText().getText().contains("Lorem ipsum")); + + // Fourth item: expression-detailed + assertNotNull(lc.getItems().get(3).getExpressionDetailed()); + ExpressionDetailed ed = lc.getItems().get(3).getExpressionDetailed(); + assertEquals("LicenseRef-MIT-Style-2", ed.getExpression()); + assertNotNull(ed.getExpressionDetails()); + assertEquals(1, ed.getExpressionDetails().size()); + assertEquals("LicenseRef-MIT-Style-2", ed.getExpressionDetails().get(0).getLicenseIdentifier()); + assertEquals("https://example.com/license", ed.getExpressionDetails().get(0).getUrl()); + } + + @Test + public void schema17_license_expression_detailed_with_text() throws Exception { + final Bom bom = getJsonBom("1.7/valid-license-expression-with-text-1.7.json"); + + assertNotNull(bom.getComponents()); + Component component = bom.getComponents().get(0); + LicenseChoice lc = component.getLicenses(); + assertNotNull(lc); + assertNotNull(lc.getItems()); + assertEquals(1, lc.getItems().size()); + + ExpressionDetailed ed = lc.getItems().get(0).getExpressionDetailed(); + assertNotNull(ed); + assertEquals("LicenseRef-my-custom-license AND (EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0) AND MIT", ed.getExpression()); + assertEquals("my-application-license", ed.getBomRef()); + assertEquals(Acknowledgement.DECLARED, ed.getAcknowledgement()); + + assertNotNull(ed.getExpressionDetails()); + assertEquals(5, ed.getExpressionDetails().size()); + + // First detail: LicenseRef-my-custom-license + ExpressionDetail detail0 = ed.getExpressionDetails().get(0); + assertEquals("LicenseRef-my-custom-license", detail0.getLicenseIdentifier()); + assertNotNull(detail0.getText()); + assertTrue(detail0.getText().getText().contains("Lorem ipsum")); + assertEquals("https://my-application.example.com/license.txt", detail0.getUrl()); + + // Second detail: EPL-2.0 + ExpressionDetail detail1 = ed.getExpressionDetails().get(1); + assertEquals("EPL-2.0", detail1.getLicenseIdentifier()); + assertNotNull(detail1.getText()); + assertTrue(detail1.getText().getText().contains("Eclipse Public License")); + + // Third detail: GPL-2.0 WITH Classpath-exception-2.0 + ExpressionDetail detail2 = ed.getExpressionDetails().get(2); + assertEquals("GPL-2.0 WITH Classpath-exception-2.0", detail2.getLicenseIdentifier()); + assertNotNull(detail2.getText()); + assertTrue(detail2.getText().getText().contains("GNU GENERAL PUBLIC LICENSE")); + assertEquals("text/plain", detail2.getText().getContentType()); + + // Fourth detail: MIT (component B) + ExpressionDetail detail3 = ed.getExpressionDetails().get(3); + assertEquals("MIT", detail3.getLicenseIdentifier()); + assertEquals("LicenseDetails-component-B", detail3.getBomRef()); + assertNotNull(detail3.getText()); + assertTrue(detail3.getText().getText().contains("Component-B-Creators Inc")); + + // Fifth detail: MIT (component C) + ExpressionDetail detail4 = ed.getExpressionDetails().get(4); + assertEquals("MIT", detail4.getLicenseIdentifier()); + assertEquals("LicenseDetails-component-C", detail4.getBomRef()); + assertNotNull(detail4.getText()); + assertTrue(detail4.getText().getText().contains("Component-C-Creators Org")); + } + + @Test + public void schema17_license_expression_detailed_with_licensing() throws Exception { + final Bom bom = getJsonBom("1.7/valid-license-expression-with-licensing-1.7.json"); + + assertNotNull(bom.getComponents()); + Component component = bom.getComponents().get(0); + LicenseChoice lc = component.getLicenses(); + assertNotNull(lc); + assertNotNull(lc.getItems()); + assertEquals(1, lc.getItems().size()); + + ExpressionDetailed ed = lc.getItems().get(0).getExpressionDetailed(); + assertNotNull(ed); + assertEquals("LicenseRef-AcmeCommercialLicense", ed.getExpression()); + assertEquals("acme-license-1", ed.getBomRef()); + + assertNotNull(ed.getLicensing()); + assertNotNull(ed.getLicensing().getAltIds()); + assertEquals(2, ed.getLicensing().getAltIds().size()); + assertTrue(ed.getLicensing().getAltIds().contains("acme")); + assertTrue(ed.getLicensing().getAltIds().contains("acme-license")); + + assertNotNull(ed.getLicensing().getLicensor()); + assertNotNull(ed.getLicensing().getLicensor().getOrganization()); + assertEquals("Acme Inc", ed.getLicensing().getLicensor().getOrganization().getName()); + + assertNotNull(ed.getLicensing().getLicensee()); + assertNotNull(ed.getLicensing().getLicensee().getOrganization()); + assertEquals("Example Co.", ed.getLicensing().getLicensee().getOrganization().getName()); + + assertNotNull(ed.getLicensing().getPurchaser()); + assertNotNull(ed.getLicensing().getPurchaser().getIndividual()); + assertEquals("Samantha Wright", ed.getLicensing().getPurchaser().getIndividual().getName()); + + assertEquals("PO-12345", ed.getLicensing().getPurchaseOrder()); + + assertNotNull(ed.getLicensing().getLicenseTypes()); + assertEquals(1, ed.getLicensing().getLicenseTypes().size()); + } + + @Test + public void schema17_license_declared_concluded_mix() throws Exception { + final Bom bom = getJsonBom("1.7/valid-license-declared-concluded-mix-1.7.json"); + + assertNotNull(bom.getComponents()); + assertEquals(5, bom.getComponents().size()); + + // Situation A: Multiple declared licenses + concluded expression + Component sitA = bom.getComponents().get(0); + assertEquals("situation-A", sitA.getName()); + LicenseChoice lcA = sitA.getLicenses(); + assertNotNull(lcA); + assertNotNull(lcA.getItems()); + assertEquals(4, lcA.getItems().size()); + // 3 declared licenses + assertNotNull(lcA.getItems().get(0).getLicense()); + assertEquals("MIT", lcA.getItems().get(0).getLicense().getId()); + assertEquals(Acknowledgement.DECLARED, lcA.getItems().get(0).getLicense().getAcknowledgement()); + assertNotNull(lcA.getItems().get(1).getLicense()); + assertEquals("PostgreSQL", lcA.getItems().get(1).getLicense().getId()); + assertNotNull(lcA.getItems().get(2).getLicense()); + assertEquals("Apache Software License", lcA.getItems().get(2).getLicense().getName()); + // 1 concluded expression + assertNotNull(lcA.getItems().get(3).getExpression()); + assertEquals(Acknowledgement.CONCLUDED, lcA.getItems().get(3).getExpression().getAcknowledgement()); + + // Situation B: declared expression + concluded expression + Component sitB = bom.getComponents().get(1); + assertEquals("situation-B", sitB.getName()); + LicenseChoice lcB = sitB.getLicenses(); + assertNotNull(lcB); + assertNotNull(lcB.getItems()); + assertEquals(2, lcB.getItems().size()); + assertNotNull(lcB.getItems().get(0).getExpression()); + assertEquals(Acknowledgement.DECLARED, lcB.getItems().get(0).getExpression().getAcknowledgement()); + assertNotNull(lcB.getItems().get(1).getExpression()); + assertEquals(Acknowledgement.CONCLUDED, lcB.getItems().get(1).getExpression().getAcknowledgement()); + + // Situation C: declared expression + concluded license ID + Component sitC = bom.getComponents().get(2); + assertEquals("situation-C", sitC.getName()); + LicenseChoice lcC = sitC.getLicenses(); + assertNotNull(lcC); + assertNotNull(lcC.getItems()); + assertEquals(2, lcC.getItems().size()); + assertNotNull(lcC.getItems().get(0).getExpression()); + assertEquals(Acknowledgement.DECLARED, lcC.getItems().get(0).getExpression().getAcknowledgement()); + assertNotNull(lcC.getItems().get(1).getLicense()); + assertEquals("GPL-3.0-only", lcC.getItems().get(1).getLicense().getId()); + assertEquals(Acknowledgement.CONCLUDED, lcC.getItems().get(1).getLicense().getAcknowledgement()); + + // Situation D: declared expression-detailed with texts + concluded license with text + Component sitD = bom.getComponents().get(3); + assertEquals("situation-D", sitD.getName()); + LicenseChoice lcD = sitD.getLicenses(); + assertNotNull(lcD); + assertNotNull(lcD.getItems()); + assertEquals(2, lcD.getItems().size()); + assertNotNull(lcD.getItems().get(0).getExpressionDetailed()); + ExpressionDetailed edD = lcD.getItems().get(0).getExpressionDetailed(); + assertEquals("GPL-3.0-or-later OR GPL-2.0", edD.getExpression()); + assertEquals(Acknowledgement.DECLARED, edD.getAcknowledgement()); + assertNotNull(edD.getExpressionDetails()); + assertEquals(2, edD.getExpressionDetails().size()); + assertNotNull(lcD.getItems().get(1).getLicense()); + assertEquals(Acknowledgement.CONCLUDED, lcD.getItems().get(1).getLicense().getAcknowledgement()); + + // Situation E: declared licenses with URLs + concluded expression-detailed with URLs + Component sitE = bom.getComponents().get(4); + assertEquals("situation-E", sitE.getName()); + LicenseChoice lcE = sitE.getLicenses(); + assertNotNull(lcE); + assertNotNull(lcE.getItems()); + assertEquals(4, lcE.getItems().size()); + // 3 declared licenses with URLs + assertNotNull(lcE.getItems().get(0).getLicense()); + assertEquals("https://example.com/licenses/MIT", lcE.getItems().get(0).getLicense().getUrl()); + // 1 concluded expression-detailed with URLs + assertNotNull(lcE.getItems().get(3).getExpressionDetailed()); + ExpressionDetailed edE = lcE.getItems().get(3).getExpressionDetailed(); + assertEquals(Acknowledgement.CONCLUDED, edE.getAcknowledgement()); + assertNotNull(edE.getExpressionDetails()); + assertEquals(3, edE.getExpressionDetails().size()); + assertEquals("https://example.com/licenses/MIT", edE.getExpressionDetails().get(0).getUrl()); + } + + @Test + public void schema17_license_backward_compat_getLicenses() throws Exception { + final Bom bom = getJsonBom("1.7/valid-license-choice-1.7.json"); + + Component component = bom.getComponents().get(0); + LicenseChoice lc = component.getLicenses(); + + // Deprecated getLicenses() should still return only License items + assertNotNull(lc.getLicenses()); + assertEquals(2, lc.getLicenses().size()); + assertEquals("Apache-2.0", lc.getLicenses().get(0).getId()); + assertEquals("My Own License", lc.getLicenses().get(1).getName()); + + // Deprecated getExpression() should return the first expression + assertNotNull(lc.getExpression()); + assertEquals("EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0", lc.getExpression().getValue()); + + // getExpressionDetailed() should return the first expression-detailed + assertNotNull(lc.getExpressionDetailed()); + assertEquals("LicenseRef-MIT-Style-2", lc.getExpressionDetailed().getExpression()); + } + @Test public void schema16_ml_considerations() throws Exception { final Bom bom = getJsonBom("1.6/valid-machine-learning-considerations-env-1.6.json"); diff --git a/src/test/java/org/cyclonedx/parsers/XmlParserTest.java b/src/test/java/org/cyclonedx/parsers/XmlParserTest.java index 0074cdbd3..5198cdc59 100644 --- a/src/test/java/org/cyclonedx/parsers/XmlParserTest.java +++ b/src/test/java/org/cyclonedx/parsers/XmlParserTest.java @@ -65,6 +65,8 @@ import org.cyclonedx.model.definition.Standard; import org.cyclonedx.model.license.Acknowledgement; import org.cyclonedx.model.license.Expression; +import org.cyclonedx.model.license.ExpressionDetailed; +import org.cyclonedx.model.license.ExpressionDetail; import org.junit.jupiter.api.Test; import java.io.File; @@ -450,6 +452,248 @@ public void schema16_license_expression_acknowledgement() throws Exception { assertEquals(Acknowledgement.DECLARED, expression.getAcknowledgement()); } + @Test + public void schema17_license_mixed_choice() throws Exception { + final Bom bom = getXmlBom("1.7/valid-license-choice-1.7.xml"); + + assertNotNull(bom.getComponents()); + Component component = bom.getComponents().get(0); + LicenseChoice lc = component.getLicenses(); + assertNotNull(lc); + assertNotNull(lc.getItems()); + assertEquals(4, lc.getItems().size()); + + // First item: license with id + assertNotNull(lc.getItems().get(0).getLicense()); + assertEquals("Apache-2.0", lc.getItems().get(0).getLicense().getId()); + + // Second item: license with name and text + assertNotNull(lc.getItems().get(1).getLicense()); + assertEquals("My Own License", lc.getItems().get(1).getLicense().getName()); + assertNotNull(lc.getItems().get(1).getLicense().getAttachmentText()); + assertTrue(lc.getItems().get(1).getLicense().getAttachmentText().getText().contains("Lorem ipsum")); + + // Third item: expression + assertNotNull(lc.getItems().get(2).getExpression()); + assertEquals("EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0", lc.getItems().get(2).getExpression().getValue()); + + // Fourth item: expression-detailed + assertNotNull(lc.getItems().get(3).getExpressionDetailed()); + ExpressionDetailed ed = lc.getItems().get(3).getExpressionDetailed(); + assertEquals("LicenseRef-MIT-Style-2", ed.getExpression()); + assertNotNull(ed.getExpressionDetails()); + assertEquals(1, ed.getExpressionDetails().size()); + assertEquals("LicenseRef-MIT-Style-2", ed.getExpressionDetails().get(0).getLicenseIdentifier()); + assertEquals("https://example.com/license", ed.getExpressionDetails().get(0).getUrl()); + } + + @Test + public void schema17_license_expression_detailed_with_text() throws Exception { + final Bom bom = getXmlBom("1.7/valid-license-expression-with-text-1.7.xml"); + + assertNotNull(bom.getComponents()); + Component component = bom.getComponents().get(0); + LicenseChoice lc = component.getLicenses(); + assertNotNull(lc); + assertNotNull(lc.getItems()); + assertEquals(1, lc.getItems().size()); + + ExpressionDetailed ed = lc.getItems().get(0).getExpressionDetailed(); + assertNotNull(ed); + assertEquals("LicenseRef-my-custom-license AND (EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0) AND MIT", ed.getExpression()); + assertEquals("my-application-license", ed.getBomRef()); + assertEquals(Acknowledgement.DECLARED, ed.getAcknowledgement()); + + assertNotNull(ed.getExpressionDetails()); + assertEquals(5, ed.getExpressionDetails().size()); + + // First detail: LicenseRef-my-custom-license + ExpressionDetail detail0 = ed.getExpressionDetails().get(0); + assertEquals("LicenseRef-my-custom-license", detail0.getLicenseIdentifier()); + assertNotNull(detail0.getText()); + assertTrue(detail0.getText().getText().contains("Lorem ipsum")); + assertEquals("https://my-application.example.com/license.txt", detail0.getUrl()); + + // Second detail: EPL-2.0 + ExpressionDetail detail1 = ed.getExpressionDetails().get(1); + assertEquals("EPL-2.0", detail1.getLicenseIdentifier()); + assertNotNull(detail1.getText()); + assertTrue(detail1.getText().getText().contains("Eclipse Public License")); + + // Third detail: GPL-2.0 WITH Classpath-exception-2.0 + ExpressionDetail detail2 = ed.getExpressionDetails().get(2); + assertEquals("GPL-2.0 WITH Classpath-exception-2.0", detail2.getLicenseIdentifier()); + assertNotNull(detail2.getText()); + + assertTrue(detail2.getText().getText().contains("GNU GENERAL PUBLIC LICENSE")); + assertEquals("text/plain", detail2.getText().getContentType()); + + // Fourth detail: MIT (component B) + ExpressionDetail detail3 = ed.getExpressionDetails().get(3); + assertEquals("MIT", detail3.getLicenseIdentifier()); + assertEquals("LicenseDetails-component-B", detail3.getBomRef()); + assertNotNull(detail3.getText()); + assertTrue(detail3.getText().getText().contains("Component-B-Creators Inc")); + + // Fifth detail: MIT (component C) + ExpressionDetail detail4 = ed.getExpressionDetails().get(4); + assertEquals("MIT", detail4.getLicenseIdentifier()); + assertEquals("LicenseDetails-component-C", detail4.getBomRef()); + assertNotNull(detail4.getText()); + assertTrue(detail4.getText().getText().contains("Component-C-Creators Org")); + } + + @Test + public void schema17_license_expression_detailed_with_licensing() throws Exception { + final Bom bom = getXmlBom("1.7/valid-license-expression-with-licensing-1.7.xml"); + + assertNotNull(bom.getComponents()); + Component component = bom.getComponents().get(0); + LicenseChoice lc = component.getLicenses(); + assertNotNull(lc); + assertNotNull(lc.getItems()); + assertEquals(1, lc.getItems().size()); + + ExpressionDetailed ed = lc.getItems().get(0).getExpressionDetailed(); + assertNotNull(ed); + assertEquals("LicenseRef-AcmeCommercialLicense", ed.getExpression()); + assertEquals("acme-license-1", ed.getBomRef()); + + assertNotNull(ed.getLicensing()); + assertNotNull(ed.getLicensing().getAltIds()); + assertEquals(2, ed.getLicensing().getAltIds().size()); + assertTrue(ed.getLicensing().getAltIds().contains("acme")); + assertTrue(ed.getLicensing().getAltIds().contains("acme-license")); + + assertNotNull(ed.getLicensing().getLicensor()); + assertNotNull(ed.getLicensing().getLicensor().getOrganization()); + assertEquals("Acme Inc", ed.getLicensing().getLicensor().getOrganization().getName()); + + assertNotNull(ed.getLicensing().getLicensee()); + assertNotNull(ed.getLicensing().getLicensee().getOrganization()); + assertEquals("Example Co.", ed.getLicensing().getLicensee().getOrganization().getName()); + + assertNotNull(ed.getLicensing().getPurchaser()); + assertNotNull(ed.getLicensing().getPurchaser().getIndividual()); + assertEquals("Samantha Wright", ed.getLicensing().getPurchaser().getIndividual().getName()); + + assertEquals("PO-12345", ed.getLicensing().getPurchaseOrder()); + + assertNotNull(ed.getLicensing().getLicenseTypes()); + assertEquals(1, ed.getLicensing().getLicenseTypes().size()); + } + + @Test + public void schema17_license_declared_concluded_mix() throws Exception { + final Bom bom = getXmlBom("1.7/valid-license-declared-concluded-mix-1.7.xml"); + + assertNotNull(bom.getComponents()); + assertEquals(5, bom.getComponents().size()); + + // Situation A: Multiple declared licenses + concluded expression + Component sitA = bom.getComponents().get(0); + assertEquals("situation-A", sitA.getName()); + LicenseChoice lcA = sitA.getLicenses(); + assertNotNull(lcA); + assertNotNull(lcA.getItems()); + assertEquals(4, lcA.getItems().size()); + // 3 declared licenses + assertNotNull(lcA.getItems().get(0).getLicense()); + assertEquals("MIT", lcA.getItems().get(0).getLicense().getId()); + assertEquals(Acknowledgement.DECLARED, lcA.getItems().get(0).getLicense().getAcknowledgement()); + assertNotNull(lcA.getItems().get(1).getLicense()); + assertEquals("PostgreSQL", lcA.getItems().get(1).getLicense().getId()); + assertNotNull(lcA.getItems().get(2).getLicense()); + assertEquals("Apache Software License", lcA.getItems().get(2).getLicense().getName()); + // 1 concluded expression + assertNotNull(lcA.getItems().get(3).getExpression()); + assertEquals(Acknowledgement.CONCLUDED, lcA.getItems().get(3).getExpression().getAcknowledgement()); + + // Situation B: declared expression + concluded expression + Component sitB = bom.getComponents().get(1); + assertEquals("situation-B", sitB.getName()); + LicenseChoice lcB = sitB.getLicenses(); + assertNotNull(lcB); + assertNotNull(lcB.getItems()); + assertEquals(2, lcB.getItems().size()); + assertNotNull(lcB.getItems().get(0).getExpression()); + assertEquals(Acknowledgement.DECLARED, lcB.getItems().get(0).getExpression().getAcknowledgement()); + assertNotNull(lcB.getItems().get(1).getExpression()); + assertEquals(Acknowledgement.CONCLUDED, lcB.getItems().get(1).getExpression().getAcknowledgement()); + + // Situation C: declared expression + concluded license ID + // Note: XML deserializer groups by element type (license, expression, expression-detailed) + // so license comes before expression regardless of document order + Component sitC = bom.getComponents().get(2); + assertEquals("situation-C", sitC.getName()); + LicenseChoice lcC = sitC.getLicenses(); + assertNotNull(lcC); + assertNotNull(lcC.getItems()); + assertEquals(2, lcC.getItems().size()); + assertNotNull(lcC.getItems().get(0).getLicense()); + assertEquals("GPL-3.0-only", lcC.getItems().get(0).getLicense().getId()); + assertEquals(Acknowledgement.CONCLUDED, lcC.getItems().get(0).getLicense().getAcknowledgement()); + assertNotNull(lcC.getItems().get(1).getExpression()); + assertEquals(Acknowledgement.DECLARED, lcC.getItems().get(1).getExpression().getAcknowledgement()); + + // Situation D: declared expression-detailed with texts + concluded license with text + // Note: XML deserializer groups by element type: license first, then expression-detailed + Component sitD = bom.getComponents().get(3); + assertEquals("situation-D", sitD.getName()); + LicenseChoice lcD = sitD.getLicenses(); + assertNotNull(lcD); + assertNotNull(lcD.getItems()); + assertEquals(2, lcD.getItems().size()); + assertNotNull(lcD.getItems().get(0).getLicense()); + assertEquals(Acknowledgement.CONCLUDED, lcD.getItems().get(0).getLicense().getAcknowledgement()); + assertNotNull(lcD.getItems().get(1).getExpressionDetailed()); + ExpressionDetailed edD = lcD.getItems().get(1).getExpressionDetailed(); + assertEquals("GPL-3.0-or-later OR GPL-2.0", edD.getExpression()); + assertEquals(Acknowledgement.DECLARED, edD.getAcknowledgement()); + assertNotNull(edD.getExpressionDetails()); + assertEquals(2, edD.getExpressionDetails().size()); + + // Situation E: declared licenses with URLs + concluded expression-detailed with URLs + Component sitE = bom.getComponents().get(4); + assertEquals("situation-E", sitE.getName()); + LicenseChoice lcE = sitE.getLicenses(); + assertNotNull(lcE); + assertNotNull(lcE.getItems()); + assertEquals(4, lcE.getItems().size()); + // 3 declared licenses with URLs + assertNotNull(lcE.getItems().get(0).getLicense()); + assertEquals("https://example.com/licenses/MIT", lcE.getItems().get(0).getLicense().getUrl()); + // 1 concluded expression-detailed with URLs + assertNotNull(lcE.getItems().get(3).getExpressionDetailed()); + ExpressionDetailed edE = lcE.getItems().get(3).getExpressionDetailed(); + assertEquals(Acknowledgement.CONCLUDED, edE.getAcknowledgement()); + assertNotNull(edE.getExpressionDetails()); + assertEquals(3, edE.getExpressionDetails().size()); + assertEquals("https://example.com/licenses/MIT", edE.getExpressionDetails().get(0).getUrl()); + } + + @Test + public void schema17_license_backward_compat_getLicenses() throws Exception { + final Bom bom = getXmlBom("1.7/valid-license-choice-1.7.xml"); + + Component component = bom.getComponents().get(0); + LicenseChoice lc = component.getLicenses(); + + // Deprecated getLicenses() should still return only License items + assertNotNull(lc.getLicenses()); + assertEquals(2, lc.getLicenses().size()); + assertEquals("Apache-2.0", lc.getLicenses().get(0).getId()); + assertEquals("My Own License", lc.getLicenses().get(1).getName()); + + // Deprecated getExpression() should return the first expression + assertNotNull(lc.getExpression()); + assertEquals("EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0", lc.getExpression().getValue()); + + // getExpressionDetailed() should return the first expression-detailed + assertNotNull(lc.getExpressionDetailed()); + assertEquals("LicenseRef-MIT-Style-2", lc.getExpressionDetailed().getExpression()); + } + @Test public void schema16_ml_considerations() throws Exception { final Bom bom = getXmlBom("1.6/valid-machine-learning-considerations-env-1.6.xml");