CycloneDxUtils.java

/*
 * Copyright © 2025 Christian Grobmeier, Piotr P. Karwasz
 *
 * 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
 *
 *     https://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.
 */
package io.github.sbom.enforcer.internal.cyclonedx;

import com.github.packageurl.MalformedPackageURLException;
import com.github.packageurl.PackageURL;
import com.github.packageurl.PackageURLBuilder;
import io.github.sbom.enforcer.BomBuildingException;
import io.github.sbom.enforcer.Component.Properties;
import io.github.sbom.enforcer.internal.CollectionUtils;
import java.io.File;
import java.util.HashMap;
import java.util.Map;
import org.cyclonedx.exception.ParseException;
import org.cyclonedx.model.Bom;
import org.cyclonedx.model.Component;
import org.cyclonedx.parsers.JsonParser;
import org.cyclonedx.parsers.Parser;
import org.cyclonedx.parsers.XmlParser;
import org.eclipse.aether.artifact.Artifact;
import org.eclipse.aether.artifact.ArtifactProperties;
import org.eclipse.aether.artifact.DefaultArtifact;

public final class CycloneDxUtils {

    // Package URL qualifiers
    private static final String CLASSIFIER = "classifier";
    private static final String REPOSITORY_URL = "repository_url";

    static final String CYCLONE_DX_CLASSIFIER = "cyclonedx";
    private static final String XML = "xml";
    private static final String JSON = "json";

    public static Bom parseArtifact(Artifact artifact) throws BomBuildingException {
        File file = artifact.getFile();
        try {
            Parser parser = XML.equals(getCycloneDxFormat(artifact)) ? new XmlParser() : new JsonParser();
            return parser.parse(file);
        } catch (IllegalArgumentException | ParseException e) {
            throw new BomBuildingException("Failed to parse BOM file: " + file, e);
        }
    }

    public static Artifact toArtifact(Component component) throws BomBuildingException {
        PackageURL packageURL = toPackageURL(component);
        Map<String, String> qualifiers = CollectionUtils.nullToEmpty(packageURL.getQualifiers());
        String type = qualifiers.getOrDefault(ArtifactProperties.TYPE, "jar");
        String classifier = qualifiers.get(CLASSIFIER);
        String repositoryUrl = qualifiers.get(REPOSITORY_URL);
        // Set up properties of Aether artifact
        Map<String, String> properties = new HashMap<>();
        properties.put(ArtifactProperties.TYPE, type);
        if (repositoryUrl != null) {
            properties.put(Properties.REPOSITORY_URL, repositoryUrl);
        }
        return new DefaultArtifact(
                        packageURL.getNamespace(), packageURL.getName(), classifier, type, packageURL.getVersion())
                .setProperties(properties);
    }

    public static PackageURL toPackageURL(Component component) throws BomBuildingException {
        String purl = component.getPurl();
        try {
            if (purl != null) {
                return new PackageURL(purl);
            }
            String group = component.getGroup();
            if (group != null) {
                return PackageURLBuilder.aPackageURL()
                        .withType(PackageURL.StandardTypes.MAVEN)
                        .withNamespace(group)
                        .withName(component.getName())
                        .withVersion(component.getVersion())
                        .build();
            }
            throw new BomBuildingException("Missing PURL and group for component " + component);
        } catch (MalformedPackageURLException e) {
            throw new BomBuildingException("Invalid PURL for component: " + component, e);
        }
    }

    private static String getCycloneDxFormat(Artifact artifact) {
        if (CYCLONE_DX_CLASSIFIER.equals(artifact.getClassifier())) {
            switch (artifact.getExtension()) {
                case XML:
                    return XML;
                case JSON:
                    return JSON;
                default:
                    throw new IllegalArgumentException(
                            "Unsupported CycloneDX artifact type: " + artifact.getExtension() + ".");
            }
        }
        throw new IllegalArgumentException("Artifact " + artifact + " is not a CycloneDX document.");
    }

    private CycloneDxUtils() {}
}