dependencies, String ref) {
+ return dependencies.stream().filter(dependency -> match(dependency, ref)).findFirst().orElseThrow();
+ }
+
+ private boolean match(Dependency dependency, String ref) {
+ return URLDecoder.decode(dependency.getRef(), StandardCharsets.UTF_8).equals(ref);
+ }
+
+ private String getBomPath(String projectName) {
+ return projectName + "/target/bom.xml";
+ }
+
+ private Bom getBom(String bomPath) throws ParseException {
+ Parser parser = new XmlParser();
+ File bom = new File(verifier.getBasedir(), bomPath);
+ return parser.parse(bom);
+ }
+}
diff --git a/tycho-sbom/.settings/org.eclipse.jdt.core.prefs b/tycho-sbom/.settings/org.eclipse.jdt.core.prefs
new file mode 100644
index 0000000000..cf2cd4590a
--- /dev/null
+++ b/tycho-sbom/.settings/org.eclipse.jdt.core.prefs
@@ -0,0 +1,8 @@
+eclipse.preferences.version=1
+org.eclipse.jdt.core.compiler.codegen.targetPlatform=17
+org.eclipse.jdt.core.compiler.compliance=17
+org.eclipse.jdt.core.compiler.problem.enablePreviewFeatures=disabled
+org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning
+org.eclipse.jdt.core.compiler.problem.reportPreviewFeatures=ignore
+org.eclipse.jdt.core.compiler.release=disabled
+org.eclipse.jdt.core.compiler.source=17
diff --git a/tycho-sbom/pom.xml b/tycho-sbom/pom.xml
new file mode 100644
index 0000000000..4ec09d123a
--- /dev/null
+++ b/tycho-sbom/pom.xml
@@ -0,0 +1,50 @@
+
+ 4.0.0
+
+ org.eclipse.tycho
+ tycho
+ 5.0.0-SNAPSHOT
+
+ tycho-sbom
+ Tycho SBOM model extension
+
+
+
+ org.eclipse.tycho
+ tycho-core
+ ${project.version}
+
+
+ org.eclipse.tycho
+ tycho-api
+ ${project.version}
+
+
+
+ org.apache.maven
+ maven-plugin-api
+
+
+
+ org.cyclonedx
+ cyclonedx-maven-plugin
+ 2.7.10
+
+
+
+
+
+
+ org.codehaus.plexus
+ plexus-component-metadata
+
+
+
+ generate-metadata
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tycho-sbom/src/main/java/org/eclipse/tycho/sbom/TychoModelConverter.java b/tycho-sbom/src/main/java/org/eclipse/tycho/sbom/TychoModelConverter.java
new file mode 100644
index 0000000000..4cd9ca814a
--- /dev/null
+++ b/tycho-sbom/src/main/java/org/eclipse/tycho/sbom/TychoModelConverter.java
@@ -0,0 +1,302 @@
+/*******************************************************************************
+ * Copyright (c) 2024 Patrick Ziegler and others.
+ * This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License 2.0
+ * which accompanies this distribution, and is available at
+ * https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *
+ * Contributors:
+ * Patrick Ziegler - initial API and implementation
+ *******************************************************************************/
+package org.eclipse.tycho.sbom;
+
+import java.net.URI;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.function.Supplier;
+
+import javax.inject.Inject;
+
+import org.apache.maven.RepositoryUtils;
+import org.apache.maven.execution.MavenSession;
+import org.apache.maven.plugin.LegacySupport;
+import org.apache.maven.project.MavenProject;
+import org.codehaus.plexus.component.annotations.Component;
+import org.cyclonedx.maven.DefaultModelConverter;
+import org.cyclonedx.maven.ModelConverter;
+import org.eclipse.aether.artifact.Artifact;
+import org.eclipse.equinox.p2.core.ProvisionException;
+import org.eclipse.equinox.p2.metadata.IArtifactKey;
+import org.eclipse.equinox.p2.repository.artifact.IArtifactRepository;
+import org.eclipse.tycho.ArtifactKey;
+import org.eclipse.tycho.DefaultArtifactKey;
+import org.eclipse.tycho.MavenRepositoryLocation;
+import org.eclipse.tycho.ReactorProject;
+import org.eclipse.tycho.core.TargetPlatformConfiguration;
+import org.eclipse.tycho.core.TychoProjectManager;
+import org.eclipse.tycho.core.maven.TychoReactorReader;
+import org.eclipse.tycho.core.osgitools.DefaultReactorProject;
+import org.eclipse.tycho.core.resolver.target.ArtifactTypeHelper;
+import org.eclipse.tycho.p2maven.repository.P2RepositoryManager;
+import org.eclipse.tycho.targetplatform.TargetDefinition.InstallableUnitLocation;
+import org.eclipse.tycho.targetplatform.TargetDefinition.Location;
+import org.eclipse.tycho.targetplatform.TargetDefinition.Repository;
+import org.eclipse.tycho.targetplatform.TargetDefinitionFile;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Custom implementation of the CycloneDX model converter with support for both
+ * Maven and p2 artifacts. The generated PURL is usually of the form:
+ *
+ *
+ * pkg:/p2/<id>@<version>?classifier=<classifier>&location=<download-url>
+ *
+ *
+ * This converter can be used with the {@code cyclonedx-maven-plugin} by adding
+ * it as a dependency as follows:
+ *
+ *
+ * <plugin>
+ * <groupId>org.cyclonedx</groupId>
+ * <artifactId>cyclonedx-maven-plugin</artifactId>
+ * <dependencies>
+ * <dependency>
+ * <groupId>org.eclipse.tycho.extras</groupId>
+ * <artifactId>tycho-sbom</artifactId>
+ * </dependency>
+ * </dependencies>
+ * </plugin>
+ *
+ */
+@Component(role = ModelConverter.class)
+public class TychoModelConverter extends DefaultModelConverter {
+ private static final Logger LOG = LoggerFactory.getLogger(TychoModelConverter.class);
+
+ @Inject
+ private P2RepositoryManager repositoryManager;
+
+ @Inject
+ private TychoProjectManager projectManager;
+
+ @Inject
+ private TychoReactorReader reactorReader;
+
+ @Inject
+ private LegacySupport legacySupport;
+
+ @Override
+ public String generatePackageUrl(org.apache.maven.artifact.Artifact mavenArtifact) {
+ Artifact artifact = RepositoryUtils.toArtifact(mavenArtifact);
+ return generatePackageUrl(artifact, true, true, () -> super.generatePackageUrl(mavenArtifact));
+ }
+
+ @Override
+ public String generatePackageUrl(Artifact artifact) {
+ return generatePackageUrl(RepositoryUtils.toArtifact(artifact));
+ }
+
+ @Override
+ public String generateVersionlessPackageUrl(org.apache.maven.artifact.Artifact mavenArtifact) {
+ Artifact artifact = RepositoryUtils.toArtifact(mavenArtifact);
+ return generatePackageUrl(artifact, false, true, () -> super.generateVersionlessPackageUrl(mavenArtifact));
+ }
+
+ @Override
+ public String generateVersionlessPackageUrl(Artifact artifact) {
+ return generateVersionlessPackageUrl(artifact);
+ }
+
+ @Override
+ public String generateClassifierlessPackageUrl(Artifact artifact) {
+ return generatePackageUrl(artifact, true, false, () -> super.generateClassifierlessPackageUrl(artifact));
+ }
+
+ /**
+ * Calculates the package URL for the given artifact. For OSGi artifacts, the
+ * {@code p2} type is chosen, otherwise {@code maven}.
+ *
+ * @param artifact One of the artifacts available in the current reactor
+ * build.
+ * @param withVersion Whether the version should be included in the PURL. For
+ * OSGi bundles, the {@code -SNAPSHOT} is replaced by the
+ * actual build qualifier.
+ * @param withClassifier Whether the classifier should be included in the PURL.
+ * @param fallback The method for generating the Maven PURL, in case the
+ * artifact is not an OSGi bundle.
+ * @return
+ */
+ private String generatePackageUrl(Artifact artifact, boolean withVersion, boolean withClassifier,
+ Supplier fallback) {
+ if (reactorReader.isTychoReactorArtifact(artifact)) {
+ ArtifactKey artifactKey = getQualifiedArtifactKey(artifact);
+ IArtifactKey p2artifactKey = ArtifactTypeHelper.toP2ArtifactKey(artifactKey);
+ boolean isReactorProject = reactorReader.getTychoReactorProject(artifact).isPresent();
+ if (p2artifactKey != null) {
+ String p2purl = generateP2PackageUrl(p2artifactKey, withVersion, withClassifier, isReactorProject);
+ if (p2purl != null) {
+ return p2purl;
+ }
+ }
+ }
+ return fallback.get();
+ }
+
+ /**
+ * Creates the actual package URL for the p2 artifact key, using the following
+ * format:
+ * scheme:type/namespace/name@version?qualifiers#subpath
+ * The scheme and type of the artifact are always {@code pkg} and {@code p2},
+ * respectively. The namespace is optional and not used. The name and version
+ * corresponds to {@link IArtifactKey#getId()} and
+ * {@link ArtifactKey#getVersion()}. The qualifier contains the optional key
+ * {@code classifier} with value {@link IArtifactKey#getClassifier()}and the
+ * mandatory key {@code location} with the percentage-encoded repository URL
+ * containing this artifact.
+ *
+ * @param p2artifactKey One of the p2 artifacts available in the current
+ * reactor build.
+ * @param withVersion Whether the version should be included in the PURL.
+ * For OSGi bundles, the {@code -SNAPSHOT} is replaced
+ * by the actual build qualifier.
+ * @param withClassifier Whether the classifier should be included in the
+ * PURL.
+ * @param isReactorProject if {@code true}, then the given artifact key
+ * corresponds to a reactor project.
+ * @return
+ * @see here
+ */
+ /* package */ String generateP2PackageUrl(IArtifactKey p2artifactKey, boolean withVersion, boolean withClassifier,
+ boolean isReactorProject) {
+ String location = getRepositoryLocation(p2artifactKey, isReactorProject);
+ if (location == null) {
+ LOG.warn("Unknown p2 repository for artifact: " + p2artifactKey.getId());
+ return null;
+ }
+ String encodedLocation = URLEncoder.encode(location, StandardCharsets.UTF_8);
+ StringBuilder builder = new StringBuilder();
+ builder.append("pkg:p2/");
+ builder.append(p2artifactKey.getId());
+ if (withVersion) {
+ builder.append('@');
+ builder.append(p2artifactKey.getVersion());
+ }
+ builder.append('?');
+ if (withClassifier) {
+ builder.append("classifier=");
+ builder.append(p2artifactKey.getClassifier());
+ builder.append('&');
+ }
+ builder.append("location=");
+ builder.append(encodedLocation);
+ return builder.toString();
+ }
+
+ /**
+ * Calculates the first repository from which the given artifact can be
+ * downloaded. If the artifact is part of the local reactor build, the URL
+ * specified in the {@code tycho.sbom.url} property is returned (as it has not
+ * yet been deployed to any update site). If the artifact is not available on
+ * any of the repositories, {@code null} is returned.
+ *
+ * @param p2artifactKey The P2 coordinates of the artifact. Used to check
+ * whether the artifact is available on one of the p2
+ * update sites.
+ * @param isReactorProject if {@code true}, then the given artifact key
+ * corresponds to a reactor project.
+ * @return The base URl of the repository or {@code null}, if the artifact isn't
+ * hosted on any known repository.
+ */
+ private final String getRepositoryLocation(IArtifactKey p2artifactKey, boolean isReactorProject) {
+ MavenSession currentSession = legacySupport.getSession();
+ if (currentSession == null) {
+ LOG.error("Maven session couldn't be found.");
+ return null;
+ }
+
+ MavenProject currentProject = currentSession.getCurrentProject();
+
+ // Artifacts from the current reactor build haven't been deployed yet. Use a
+ // build property to determine which repository they are published to.
+ if (isReactorProject) {
+ String defaultUrl = currentProject.getProperties().getProperty("tycho.sbom.url");
+ if (defaultUrl == null) {
+ LOG.error("'tycho.sbom.url' property not set.");
+ }
+ return defaultUrl;
+ }
+
+ // Iterate over all p2 repository and return the first one containing the
+ // artifact. Note that the location might be arbitrary, if the artifact is
+ // contained by multiple repositories.
+ for (Repository repository : getTargetRepositories(currentProject)) {
+ String id = repository.getId();
+ URI location = URI.create(repository.getLocation());
+ MavenRepositoryLocation mavenRepository = new MavenRepositoryLocation(id, location);
+ try {
+ IArtifactRepository artifactRepository = repositoryManager.getArtifactRepository(mavenRepository);
+ if (artifactRepository.contains(p2artifactKey)) {
+ return repository.getLocation();
+ }
+ } catch (ProvisionException e) {
+ LOG.error(e.getMessage(), e);
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Returns the Eclipse/OSGi {@link ArtifactKey} of the given artifact. For
+ * reactor projects, the (optional) {@code -SNAPSHOT} of suffix of the version
+ * string is replaced by its expanded version.
+ *
+ * @param artifact A Maven artifact with valid GAV
+ * @return The Eclipse/OSGi artifact key of the given artifact.
+ */
+ private ArtifactKey getQualifiedArtifactKey(Artifact artifact) {
+ String expandedVersion = artifact.getVersion();
+
+ MavenProject mavenProject = reactorReader.getTychoReactorProject(artifact).orElse(null);
+ if (mavenProject != null) {
+ ReactorProject reactorProject = DefaultReactorProject.adapt(mavenProject);
+ if (reactorProject != null) {
+ expandedVersion = reactorProject.getExpandedVersion();
+ } else {
+ LOG.error(mavenProject + " is not a Tycho project.");
+ }
+ }
+
+ String type = reactorReader.getPackagingType(artifact);
+ return new DefaultArtifactKey(type, artifact.getArtifactId(), expandedVersion);
+ }
+
+ /**
+ * Returns a list of all target repositories which are accessible by the given
+ * Maven project. All IUs required by this proejct should be accessible via one
+ * of those repositories.
+ *
+ * @param currentProject The current project of the reactor build, for which the
+ * SBOM is generated.
+ * @return An unmodifiable list of all target repositories.
+ */
+ private List getTargetRepositories(MavenProject currentProject) {
+ TargetPlatformConfiguration targetConfiguration = projectManager.getTargetPlatformConfiguration(currentProject);
+ List p2repositories = new ArrayList<>();
+
+ for (TargetDefinitionFile targetFile : targetConfiguration.getTargets()) {
+ for (Location location : targetFile.getLocations()) {
+ if (location instanceof InstallableUnitLocation iuLocation) {
+ p2repositories.addAll(iuLocation.getRepositories());
+ }
+ }
+ }
+
+ return Collections.unmodifiableList(p2repositories);
+ }
+}
diff --git a/tycho-sbom/src/main/java/org/eclipse/tycho/sbom/TychoProjectDependenciesConverter.java b/tycho-sbom/src/main/java/org/eclipse/tycho/sbom/TychoProjectDependenciesConverter.java
new file mode 100644
index 0000000000..28cd2c73ca
--- /dev/null
+++ b/tycho-sbom/src/main/java/org/eclipse/tycho/sbom/TychoProjectDependenciesConverter.java
@@ -0,0 +1,151 @@
+/*******************************************************************************
+ * Copyright (c) 2024 Patrick Ziegler and others.
+ * This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License 2.0
+ * which accompanies this distribution, and is available at
+ * https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *
+ * Contributors:
+ * Patrick Ziegler - initial API and implementation
+ *******************************************************************************/
+package org.eclipse.tycho.sbom;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+
+import javax.inject.Inject;
+
+import org.apache.maven.execution.MavenSession;
+import org.apache.maven.plugin.LegacySupport;
+import org.apache.maven.project.MavenProject;
+import org.cyclonedx.maven.DefaultProjectDependenciesConverter;
+import org.cyclonedx.maven.ProjectDependenciesConverter;
+import org.cyclonedx.model.Component;
+import org.cyclonedx.model.Dependency;
+import org.cyclonedx.model.Metadata;
+import org.eclipse.core.runtime.CoreException;
+import org.eclipse.equinox.p2.metadata.IArtifactKey;
+import org.eclipse.equinox.p2.metadata.IInstallableUnit;
+import org.eclipse.tycho.p2.tools.P2DependencyTreeGenerator;
+import org.eclipse.tycho.p2.tools.P2DependencyTreeGenerator.DependencyTreeNode;
+import org.eclipse.tycho.p2maven.MavenProjectDependencyProcessor;
+import org.eclipse.tycho.p2maven.MavenProjectDependencyProcessor.ProjectDependencyClosure;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@org.codehaus.plexus.component.annotations.Component(role = ProjectDependenciesConverter.class)
+public class TychoProjectDependenciesConverter extends DefaultProjectDependenciesConverter {
+ private static final Logger LOG = LoggerFactory.getLogger(TychoProjectDependenciesConverter.class);
+
+ @Inject
+ private LegacySupport legacySupport;
+
+ @Inject
+ private TychoModelConverter modelConverter;
+
+ @Inject
+ private P2DependencyTreeGenerator dependencyGenerator;
+
+ @Inject
+ private MavenProjectDependencyProcessor dependencyProcessor;
+
+ @Override
+ public void cleanupBomDependencies(Metadata metadata, Map components,
+ Map dependencies) {
+ final MavenSession mavenSession = legacySupport.getSession();
+ final MavenProject currentProject = mavenSession.getCurrentProject();
+ //
+ try {
+ Set unmapped = new HashSet<>();
+ List rootNodes = dependencyGenerator.buildDependencyTree(currentProject, unmapped);
+ // Something doesn't seem right...
+ if (rootNodes.isEmpty() && unmapped.isEmpty()) {
+ LOG.info("Project " + currentProject + " doesn't seem to be a Tycho project. Skip...");
+ return;
+ }
+ // Synchronize with dependency tree node
+ TreeSet newDependencies = new TreeSet<>(Comparator.comparing(Dependency::getRef));
+ for (DependencyTreeNode rootNode : rootNodes) {
+ convertToDependency(rootNode, newDependencies);
+ }
+ for (IInstallableUnit iu : unmapped) {
+ for (String bomRef : getBomRepresentation(iu)) {
+ newDependencies.add(new Dependency(bomRef));
+ }
+ }
+ for (Dependency dependency : newDependencies) {
+ dependencies.put(dependency.getRef(), dependency);
+ }
+ } catch (CoreException e) {
+ LOG.error(e.getMessage());
+ }
+ }
+
+ private void convertToDependency(DependencyTreeNode node, Set dependencies) {
+ for (String bomRef : getBomRepresentation(node.getInstallableUnit())) {
+ Dependency dependency = new Dependency(bomRef);
+ for (DependencyTreeNode childNode : node.getChildren()) {
+ for (String childBomRef : getBomRepresentation(childNode.getInstallableUnit())) {
+ dependency.addDependency(new Dependency(childBomRef));
+ }
+ convertToDependency(childNode, dependencies);
+ }
+ dependencies.add(dependency);
+ }
+ }
+
+ /**
+ * Calculates the BOM representation of the give {@link IInstallableUnit}. We
+ * need to distinguish between IUs that are part of the current reactor build
+ * and IUs external IUs, in order to properly handle e.g. the version qualifier.
+ * For reactor IUs, the BOM is calculated using the Maven artifact, otherwise
+ * via the {@link IArtifactKey}s. If no BOM representation can be calculated, an
+ * empty list is returned.
+ *
+ * @param iu The installable unit for which to generate
+ * @return A {@code mutable} list of all bom representation. matching the given
+ * IU.
+ */
+ private List getBomRepresentation(IInstallableUnit iu) {
+ final MavenSession mavenSession = legacySupport.getSession();
+ final List reactorProjects = mavenSession.getAllProjects();
+ // mutable!
+ List bomRefs = new ArrayList<>();
+ // (I) IU describes local reactor project
+ try {
+ ProjectDependencyClosure dependencyClosure = dependencyProcessor
+ .computeProjectDependencyClosure(reactorProjects, mavenSession);
+ MavenProject iuProject = dependencyClosure.getProject(iu).orElse(null);
+ if (iuProject != null) {
+ String bomRef = modelConverter.generatePackageUrl(iuProject.getArtifact());
+ if (bomRef == null) {
+ LOG.error("Unable to calculate BOM for: " + iuProject);
+ return bomRefs;
+ }
+ bomRefs.add(bomRef);
+ return bomRefs;
+ }
+ } catch (CoreException e) {
+ LOG.error(e.getMessage(), e);
+ return Collections.emptyList();
+ }
+ // (II) IU describes external artifact
+ for (IArtifactKey p2artifactKey : iu.getArtifacts()) {
+ String bomRef = modelConverter.generateP2PackageUrl(p2artifactKey, true, true, false);
+ if (bomRef == null) {
+ LOG.error("Unable to calculate BOM for: " + p2artifactKey);
+ continue;
+ }
+ bomRefs.add(bomRef);
+ }
+ return bomRefs; // mutable!
+ }
+}