CycloneDxBomBuilder.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 static io.github.sbom.enforcer.internal.CollectionUtils.nullToEmpty;

import io.github.sbom.enforcer.BillOfMaterials;
import io.github.sbom.enforcer.BomBuilder;
import io.github.sbom.enforcer.BomBuilderRequest;
import io.github.sbom.enforcer.BomBuildingException;
import io.github.sbom.enforcer.Component;
import io.github.sbom.enforcer.Component.ChecksumAlgorithm;
import io.github.sbom.enforcer.internal.Artifacts;
import io.github.sbom.enforcer.support.DefaultBillOfMaterials;
import io.github.sbom.enforcer.support.DefaultComponent;
import java.util.ArrayList;
import java.util.Collection;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
import org.codehaus.plexus.logging.Logger;
import org.cyclonedx.model.Bom;
import org.cyclonedx.model.ExternalReference;
import org.cyclonedx.model.Hash;
import org.cyclonedx.model.Metadata;
import org.eclipse.aether.RepositorySystem;
import org.eclipse.aether.RepositorySystemSession;
import org.eclipse.aether.artifact.Artifact;
import org.eclipse.aether.repository.RemoteRepository;
import org.eclipse.aether.resolution.ArtifactResolutionException;

/**
 * Creates a {@link BillOfMaterials} model for a CycloneDX document.
 */
@Named("cyclonedx")
@Singleton
@org.codehaus.plexus.component.annotations.Component(role = BomBuilder.class, hint = "cyclonedx")
public class CycloneDxBomBuilder implements BomBuilder {

    private final RepositorySystem repoSystem;
    private final Logger logger;

    @Inject
    public CycloneDxBomBuilder(RepositorySystem repoSystem, Logger logger) {
        this.repoSystem = repoSystem;
        this.logger = logger;
    }

    @Override
    public boolean isSupported(Artifact billOfMaterials) {
        return "cyclonedx".equals(billOfMaterials.getClassifier());
    }

    @Override
    public BillOfMaterials build(RepositorySystemSession repoSession, BomBuilderRequest request)
            throws BomBuildingException {
        Bom bom = CycloneDxUtils.parseArtifact(request.getMainBillOfMaterials());
        org.cyclonedx.model.Component cdxComponent = getMainComponent(request, bom);
        Component mainComponent =
                processMainComponent(cdxComponent, request.getArtifact(), request.getAllBillsOfMaterials());
        DefaultBillOfMaterials.Builder builder = DefaultBillOfMaterials.newBuilder()
                .setBillOfMaterials(request.getMainBillOfMaterials())
                .setComponent(mainComponent);
        // Create dependencies
        for (org.cyclonedx.model.Component dependency : nullToEmpty(bom.getComponents())) {
            builder.addDependency(createDependency(repoSession, dependency));
        }
        return builder.get();
    }

    private static org.cyclonedx.model.Component getMainComponent(BomBuilderRequest request, Bom bom)
            throws BomBuildingException {
        Metadata metadata = bom.getMetadata();
        if (metadata == null) {
            throw new BomBuildingException(
                    "BOM artifact " + request.getMainBillOfMaterials() + " does not contain a `$.metadata` element.");
        }
        org.cyclonedx.model.Component cdxComponent = metadata.getComponent();
        if (cdxComponent == null) {
            throw new BomBuildingException("BOM artifact " + request.getMainBillOfMaterials()
                    + " does not contain a `$.metadata.component` element.");
        }
        return cdxComponent;
    }

    private Component processMainComponent(
            org.cyclonedx.model.Component component, Artifact artifact, Collection<Artifact> allBillsOfMaterials)
            throws BomBuildingException {
        Artifact mainArtifact = CycloneDxUtils.toArtifact(component);
        mainArtifact = mainArtifact.setFile(artifact.getFile());
        DefaultComponent.Builder builder = DefaultComponent.newBuilder().setArtifact(mainArtifact);
        allBillsOfMaterials.forEach(builder::addBillOfMaterials);
        processGenericComponent(builder, component);
        return builder.get();
    }

    private Component createDependency(RepositorySystemSession repoSession, org.cyclonedx.model.Component cdxComponent)
            throws BomBuildingException {
        Artifact artifact = CycloneDxUtils.toArtifact(cdxComponent);
        RemoteRepository remoteRepository = Artifacts.getRemoteRepository(artifact, repoSession);
        try {
            artifact = Artifacts.downloadArtifact(repoSystem, repoSession, artifact, remoteRepository);
        } catch (ArtifactResolutionException e) {
            // This usually happens for "aggregate" SBOMs and artifacts from the reactor that were not built yet.
            logger.warn("Failed to download artifact " + artifact);
        }
        DefaultComponent.Builder builder = DefaultComponent.newBuilder().setArtifact(artifact);
        processGenericComponent(builder, cdxComponent);
        for (Artifact bom : findBomArtifacts(repoSession, artifact, remoteRepository)) {
            builder.addBillOfMaterials(bom);
        }
        return builder.get();
    }

    private Collection<Artifact> findBomArtifacts(
            RepositorySystemSession repoSession, Artifact artifact, RemoteRepository remoteRepository) {
        Collection<Artifact> bomArtifacts = new ArrayList<>();
        Artifact cycloneDxArtifact =
                Artifacts.withClassifier(artifact.setFile(null), CycloneDxUtils.CYCLONE_DX_CLASSIFIER);
        for (String extension : new String[] {"xml", "json"}) {
            try {
                Artifact bom = Artifacts.downloadArtifact(
                        repoSystem,
                        repoSession,
                        Artifacts.withExtension(cycloneDxArtifact, extension),
                        remoteRepository);
                bomArtifacts.add(bom);
            } catch (ArtifactResolutionException e) {
                // The artifact is not present
            }
        }
        return bomArtifacts;
    }

    // package-private for testing
    static void processGenericComponent(DefaultComponent.Builder builder, org.cyclonedx.model.Component component)
            throws BomBuildingException {
        builder.setPurl(CycloneDxUtils.toPackageURL(component));
        for (Hash hash : nullToEmpty(component.getHashes())) {
            builder.addChecksum(ChecksumAlgorithm.fromCycloneDx(hash.getAlgorithm()), hash.getValue());
        }
        for (ExternalReference externalReference : nullToEmpty(component.getExternalReferences())) {
            builder.addExternalReference(externalReference.getType().getTypeName(), externalReference.getUrl());
        }
    }
}