Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion src/main/java/org/cyclonedx/model/Component.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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,
Expand Down
193 changes: 157 additions & 36 deletions src/main/java/org/cyclonedx/model/LicenseChoice.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,83 +21,204 @@
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> license;
private Expression expression;
private List<LicenseItem> 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<LicenseItem> getItems() {
return items;
}

@JacksonXmlProperty(localName = "license")
public List<License> getLicenses() {
return license;
public void setItems(List<LicenseItem> items) {
this.items = items;
}

public void setLicenses(List<License> 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<License> getLicenses() {
if (items == null) return null;
List<License> 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<License> 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
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);
}
}

Loading