Repository: CycloneDX/cyclonedx-core-java
Pull request #235 (2022-11-14 13:48:38)

Optional encoding of license text
Per specification, the license text encoding is optional (since v1.1). If stated, it must be one choice of a list of pre-defined values, the only current specified choice is base64.

The Java core code always specified the encoding as base64 with no way to disable the encoding. This was  not coherent within the cycloneDX ecosystem, e.g. the npm generator generates a string in plain text. Generating BOMs in a mixed situation leads to differently styled files.

The change at hand offers control over the used encoding, current options are none and base64. For better extensibility and to resemble the specification those choices are modeled as an Enum instead of a toggling boolean. For backwards compatibility old interfaces with their default behavior base64 stay in place. I recommend to deprecate them in the future, to keep the public interface of the LicenseResolver lighter.

Additional test validations have been added for the encoding field. In the future, additional tests could verify that the chosen encoding is actually applied correctly.

If accepted, I'll create a follow-up pull request for the cycloneDX maven plugin to make use of this new functionality.




Patch:
diff --git a/src/main/java/org/cyclonedx/CycloneDxSchema.java b/src/main/java/org/cyclonedx/CycloneDxSchema.java
index 7e235a24a..cf0aa69cf 100644
--- a/src/main/java/org/cyclonedx/CycloneDxSchema.java
+++ b/src/main/java/org/cyclonedx/CycloneDxSchema.java
@@ -82,6 +82,7 @@ public abstract class CycloneDxSchema {
     /**
      * Returns the CycloneDX JsonSchema for the specified schema version.
      * @param schemaVersion The version to return the schema for
+     * @param mapper is to provide a Jackson ObjectMapper
      * @return a Schema
      * @throws IOException when errors are encountered
      * @since 6.0.0
diff --git a/src/main/java/org/cyclonedx/model/AttachmentText.java b/src/main/java/org/cyclonedx/model/AttachmentText.java
index f649ec315..55b3d512e 100644
--- a/src/main/java/org/cyclonedx/model/AttachmentText.java
+++ b/src/main/java/org/cyclonedx/model/AttachmentText.java
@@ -19,8 +19,11 @@
 package org.cyclonedx.model;
 
 import java.util.Objects;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
 import com.fasterxml.jackson.annotation.JsonProperty;
 import com.fasterxml.jackson.annotation.JsonPropertyOrder;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
 import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
 import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlText;
 
@@ -28,6 +31,7 @@ import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlText;
 @JsonPropertyOrder({
     "content-type",
     "encoding"})
+@JsonInclude(JsonInclude.Include.NON_NULL)
 public class AttachmentText {
 
     @JacksonXmlProperty(isAttribute = true)
diff --git a/src/main/java/org/cyclonedx/util/LicenseResolver.java b/src/main/java/org/cyclonedx/util/LicenseResolver.java
index 160d302d3..84cc09865 100644
--- a/src/main/java/org/cyclonedx/util/LicenseResolver.java
+++ b/src/main/java/org/cyclonedx/util/LicenseResolver.java
@@ -25,6 +25,7 @@ import org.cyclonedx.model.LicenseChoice;
 import org.cyclonedx.model.AttachmentText;
 import java.io.IOException;
 import java.io.InputStream;
+import java.nio.charset.Charset;
 import java.nio.charset.StandardCharsets;
 import java.util.Base64;
 import java.util.List;
@@ -44,7 +45,7 @@ public final class LicenseResolver {
      * @return a LicenseChoice object if resolution was successful, or null if unresolved
      */
     public static LicenseChoice resolve(final String licenseString) {
-        return resolve(licenseString, true);
+        return resolve(licenseString,  new LicenseTextSettings(true, LicenseEncoding.BASE64));
     }
 
     /**
@@ -55,17 +56,32 @@ public final class LicenseResolver {
      * @return a LicenseChoice object if resolution was successful, or null if unresolved
      */
     public static LicenseChoice resolve(final String licenseString, final boolean includeLicenseText) {
+        return resolve(licenseString, new LicenseTextSettings( includeLicenseText, LicenseEncoding.BASE64));
+    }
+
+    static LicenseChoice resolve(final String licenseString, final boolean includeLicenseText, final ObjectMapper mapper) {
+        return resolve(licenseString, new LicenseTextSettings( includeLicenseText, LicenseEncoding.BASE64), mapper);
+    }
+
+    /**
+     * Attempts to resolve the specified license string via SPDX license identifier and expression
+     * parsing first. If SPDX resolution is not successful, the method will attempt fuzzy matching.
+     * @param licenseString the license string to resolve
+     * @param licenseTextSettings specifies settings regarding the entire text of the resolved license
+     * @return a LicenseChoice object if resolution was successful, or null if unresolved
+     */
+    public static LicenseChoice resolve(final String licenseString, final LicenseTextSettings licenseTextSettings) {
         final ObjectMapper mapper = new ObjectMapper();
 
-        return resolve(licenseString, includeLicenseText, mapper);
+        return resolve(licenseString, licenseTextSettings, mapper);
     }
 
-    static LicenseChoice resolve(final String licenseString, final boolean includeLicenseText, final ObjectMapper mapper) {
+    static LicenseChoice resolve(final String licenseString, final LicenseTextSettings licenseTextSettings, final ObjectMapper mapper) {
         try {
-            LicenseChoice licenseChoice = resolveLicenseString(licenseString, includeLicenseText, mapper);
+            LicenseChoice licenseChoice = resolveLicenseString(licenseString, licenseTextSettings, mapper);
 
             if (licenseChoice == null) {
-                licenseChoice = resolveFuzzyMatching(licenseString, includeLicenseText, mapper);
+                licenseChoice = resolveFuzzyMatching(licenseString, licenseTextSettings, mapper);
             }
             return licenseChoice;
         } catch (IOException ex) {
@@ -77,12 +93,12 @@ public final class LicenseResolver {
      * Given an SPDX license ID or expression, this method will resolve the license(s) and
      * return a LicenseChoice object.
      * @param licenseString the license string to resolve
-     * @param includeLicenseText specifies is the resolved license will include the entire text of the license
+     * @param licenseTextSettings specifies settings regarding the entire text of the resolved license
      * @param mapper is to provide a Jackson ObjectMapper
      * @return a LicenseChoice object if resolved, or null
      * @throws IOException an exception while parsing the license string
      */
-    private static LicenseChoice resolveLicenseString(String licenseString, boolean includeLicenseText, final ObjectMapper mapper)
+    private static LicenseChoice resolveLicenseString(String licenseString, LicenseTextSettings licenseTextSettings, final ObjectMapper mapper)
         throws IOException
     {
         final InputStream is = LicenseResolver.class.getResourceAsStream("/licenses/licenses.json");
@@ -95,9 +111,9 @@ public final class LicenseResolver {
                 final String primaryLicenseUrl = (licenseDetail.seeAlso != null && !licenseDetail.seeAlso.isEmpty()) ? licenseDetail.seeAlso.get(0) : null;
 
                 if (licenseString.trim().equalsIgnoreCase(licenseDetail.licenseId)) {
-                    return createLicenseChoice(licenseDetail.licenseId, primaryLicenseUrl, licenseDetail.isDeprecatedLicenseId, includeLicenseText);
+                    return createLicenseChoice(licenseDetail.licenseId, primaryLicenseUrl, licenseDetail.isDeprecatedLicenseId, licenseTextSettings);
                 } else if (licenseString.trim().equalsIgnoreCase(licenseDetail.name)) {
-                    return createLicenseChoice(licenseDetail.licenseId, primaryLicenseUrl, licenseDetail.isDeprecatedLicenseId, includeLicenseText);
+                    return createLicenseChoice(licenseDetail.licenseId, primaryLicenseUrl, licenseDetail.isDeprecatedLicenseId, licenseTextSettings);
                 } else {
 
                     if (licenseDetail.isDeprecatedLicenseId) {
@@ -110,7 +126,7 @@ public final class LicenseResolver {
                                 final String licenseStringModified = urlNormalize(licenseString);
 
                                 if (licenseStringModified.equalsIgnoreCase(urlNormalize(url))) {
-                                    return createLicenseChoice(licenseDetail.licenseId, url, licenseDetail.isDeprecatedLicenseId, includeLicenseText);
+                                    return createLicenseChoice(licenseDetail.licenseId, url, licenseDetail.isDeprecatedLicenseId, licenseTextSettings);
                                 }
                             }
                         }
@@ -125,11 +141,11 @@ public final class LicenseResolver {
     /**
      * Attempts to perform high-confidence license resolution with unstructured text as input.
      * @param licenseString the license string (not the actual license text)
-     * @param includeLicenseText specifies is the resolved license will include the entire text of the license
+     * @param licenseTextSettings specifies settings regarding the entire text of the resolved license
      * @param mapper is to provide a Jackson ObjectMapper
      * @return a LicenseChoice object if resolved, otherwise null
      */
-    private static LicenseChoice resolveFuzzyMatching(final String licenseString, final boolean includeLicenseText, final ObjectMapper mapper) throws IOException {
+    private static LicenseChoice resolveFuzzyMatching(final String licenseString, final LicenseTextSettings licenseTextSettings, final ObjectMapper mapper) throws IOException {
         if (licenseString == null) {
             return null;
         }
@@ -148,7 +164,7 @@ public final class LicenseResolver {
                                 lc.setExpression(licenseMapping.exp);
                                 return lc;
                             } else {
-                                return createLicenseChoice(licenseMapping.exp, null, false, includeLicenseText);
+                                return createLicenseChoice(licenseMapping.exp, null, false, licenseTextSettings);
                             }
                         }
                     }
@@ -167,19 +183,29 @@ public final class LicenseResolver {
                 .replace("http://", "");
     }
 
-    private static LicenseChoice createLicenseChoice(String licenseId, String primaryLicenseUrl, boolean isDeprecatedLicenseId, boolean includeLicenseText) throws IOException {
+    private static LicenseChoice createLicenseChoice(String licenseId, String primaryLicenseUrl, boolean isDeprecatedLicenseId, LicenseTextSettings licenseTextSettings ) throws IOException {
         final LicenseChoice choice = new LicenseChoice();
         final License license = new License();
         license.setId(licenseId);
         license.setUrl(primaryLicenseUrl);
-        if (!isDeprecatedLicenseId && includeLicenseText) {
+        if (!isDeprecatedLicenseId && licenseTextSettings.isTextIncluded()) {
             final InputStream is = LicenseResolver.class.getResourceAsStream("/licenses/" + licenseId + ".txt");
             if (is != null) {
                 final String text = IOUtils.toString(is, StandardCharsets.UTF_8);
                 final AttachmentText attachment = new AttachmentText();
                 attachment.setContentType("plain/text");
-                attachment.setEncoding("base64");
-                attachment.setText(Base64.getEncoder().encodeToString(text.getBytes()));
+                switch(licenseTextSettings.getEncoding()){
+                    case NONE:
+                        attachment.setEncoding(null);
+                        attachment.setText(text);
+                        break;
+                    case BASE64:
+                        attachment.setEncoding(licenseTextSettings.getEncoding().toString());
+                        attachment.setText(Base64.getEncoder().encodeToString(text.getBytes(Charset.defaultCharset())));
+                        break;
+                    default:
+                        throw new IllegalArgumentException("Unhandled License Encoding:" + licenseTextSettings.getEncoding().toString() );
+                }
                 license.setLicenseText(attachment);
             }
         }
@@ -187,6 +213,52 @@ public final class LicenseResolver {
         return choice;
     }
 
+    /**
+     * Lists possible choices for license text encoding
+     */
+    public enum LicenseEncoding{
+        BASE64("base64"),
+        NONE("none");
+
+        private final String encodingName;
+
+        /**
+         * Constructor with a string representation of the enum value
+         * @param encodingName The string representation of the enum value
+         */
+        LicenseEncoding(String encodingName) {
+            this.encodingName = encodingName;
+        }
+        public String toString() {
+            return encodingName;
+        }
+    }
+
+    /**
+     * Data class aggregating settings for license text output
+     */
+    public static class LicenseTextSettings {
+        public boolean isTextIncluded;
+        public LicenseEncoding encoding;
+
+        public LicenseTextSettings(boolean includeLicenseText, LicenseEncoding encoding) {
+            this.isTextIncluded = includeLicenseText;
+            this.encoding = encoding;
+        }
+        public boolean isTextIncluded() {
+            return isTextIncluded;
+        }
+        public void setTextIncluded(boolean include) {
+            this.isTextIncluded = include;
+        }
+        public LicenseEncoding getEncoding() {
+            return encoding;
+        }
+        public void setEncoding(LicenseEncoding encoding) {
+            this.encoding = encoding;
+        }
+    }
+
     private static class LicenseDetail {
         public String reference;
         public boolean isDeprecatedLicenseId;


Test patch:
diff --git a/src/test/java/org/cyclonedx/util/LicenseResolverTest.java b/src/test/java/org/cyclonedx/util/LicenseResolverTest.java
index 8553d3cf4..85e25e3a9 100644
--- a/src/test/java/org/cyclonedx/util/LicenseResolverTest.java
+++ b/src/test/java/org/cyclonedx/util/LicenseResolverTest.java
@@ -23,6 +23,7 @@ import org.junit.jupiter.api.Test;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
 
 public class LicenseResolverTest {
 
@@ -49,6 +50,24 @@ public class LicenseResolverTest {
         assertNotNull(c1.getLicenses().get(0).getAttachmentText().getText());
         assertEquals("plain/text", c1.getLicenses().get(0).getAttachmentText().getContentType());
         assertEquals("base64", c1.getLicenses().get(0).getAttachmentText().getEncoding());
+
+        LicenseResolver.LicenseTextSettings textSettings = new LicenseResolver.LicenseTextSettings( true, LicenseResolver.LicenseEncoding.NONE);
+        LicenseChoice c2 = LicenseResolver.resolve("GPL-3.0-only", textSettings);
+        assertEquals(1, c2.getLicenses().size());
+        assertEquals("GPL-3.0-only", c2.getLicenses().get(0).getId());
+        assertEquals("https://www.gnu.org/licenses/gpl-3.0-standalone.html", c2.getLicenses().get(0).getUrl());
+        assertNotNull(c2.getLicenses().get(0).getAttachmentText().getText());
+        assertEquals("plain/text", c2.getLicenses().get(0).getAttachmentText().getContentType());
+        assertNull(c2.getLicenses().get(0).getAttachmentText().getEncoding());
+
+        textSettings = new LicenseResolver.LicenseTextSettings( true, LicenseResolver.LicenseEncoding.BASE64);
+        LicenseChoice c3 = LicenseResolver.resolve("GPL-3.0-only", textSettings);
+        assertEquals(1, c3.getLicenses().size());
+        assertEquals("GPL-3.0-only", c3.getLicenses().get(0).getId());
+        assertEquals("https://www.gnu.org/licenses/gpl-3.0-standalone.html", c3.getLicenses().get(0).getUrl());
+        assertNotNull(c3.getLicenses().get(0).getAttachmentText().getText());
+        assertEquals("plain/text", c3.getLicenses().get(0).getAttachmentText().getContentType());
+        assertEquals("base64", c3.getLicenses().get(0).getAttachmentText().getEncoding());
     }
 
     @Test