Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Populate dependencies from module graph #109

Merged
merged 5 commits into from
Jan 13, 2025
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ project/plugins/project/
.history
.cache
.lib/
.bsp/
matmannion marked this conversation as resolved.
Show resolved Hide resolved

### Scala template
*.class
Expand Down
25 changes: 13 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,17 +51,18 @@ The `listBom` command can be used to generate the contents of the BOM without wr

### configuration

| Setting | Type | Default | Description |
|------------------------------|---------|------------------------------------------------------------------------|----------------------------------------------------------------|
| bomFileName | String | `"${artifactId}-${artifactVersion}.bom.xml"` | bom file name |
| bomFormat | String | `json` or `xml`, defaults to the format of bomFileName or else `json` | bom format |
| bomSchemaVersion | String | `"1.6"` | bom schema version |
| includeBomSerialNumber | Boolean | `false` | include serial number in bom |
| includeBomTimestamp | Boolean | `false` | include timestamp in bom |
| includeBomToolVersion | Boolean | `true` | include tool version in bom |
| includeBomHashes | Boolean | `true` | include artifact hashes in bom |
| enableBomSha3Hashes | Boolean | `true` | enable the generation of sha3 hashes (not available on java 8) |
| includeBomExternalReferences | Boolean | `true` | include external references in bom |
| Setting | Type | Default | Description |
|------------------------------|---------|------------------------------------------------------------------------|-----------------------------------------------------------------|
| bomFileName | String | `"${artifactId}-${artifactVersion}.bom.xml"` | bom file name |
| bomFormat | String | `json` or `xml`, defaults to the format of bomFileName or else `json` | bom format |
| bomSchemaVersion | String | `"1.6"` | bom schema version |
| includeBomSerialNumber | Boolean | `false` | include serial number in bom |
| includeBomTimestamp | Boolean | `false` | include timestamp in bom |
| includeBomToolVersion | Boolean | `true` | include tool version in bom |
| includeBomHashes | Boolean | `true` | include artifact hashes in bom |
| enableBomSha3Hashes | Boolean | `true` | enable the generation of sha3 hashes (not available on java 8) |
| includeBomExternalReferences | Boolean | `true` | include external references in bom |
| includeBomDependencyTree | Boolean | `true` | include dependency tree in bom (bomSchemaVersion 1.1 and later) |

Sample configuration:

Expand Down Expand Up @@ -102,7 +103,7 @@ executed.
[Scripted](https://www.scala-sbt.org/1.x/docs/Testing-sbt-plugins.html) is a tool that allow you to test sbt plugins.
For each test it is necessary to create a specially crafted project. These projects are inside src/sbt-test directory.

Scripted tests are run using `scripted` command.
Scripted tests are run using `scripted` command. Note that these fail on JDK 21 due to the old version of sbt.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we create an issue for this capturing what exactly is going wrong and what the plan to fix that should be?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think there's much we can do about it as it's the version of Scala 2.12 pulled in by sbt, so other than moving to a more recent baseline (which feels like a possibly breaking major change) I thought maybe documenting here for local development was sufficient?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documenting it is a great improvement. I've opened #110 to discuss going further


### Formatting

Expand Down
63 changes: 58 additions & 5 deletions src/main/scala/com/github/sbt/sbom/BomExtractor.scala
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,17 @@ package com.github.sbt.sbom
import com.github.packageurl.PackageURL
import com.github.sbt.sbom.licenses.LicensesArchive
import org.cyclonedx.Version
import org.cyclonedx.model.{ Bom, Component, ExternalReference, Hash, License, LicenseChoice, Metadata, Tool }
import org.cyclonedx.model.{
Bom,
Component,
Dependency,
ExternalReference,
Hash,
License,
LicenseChoice,
Metadata,
Tool
}
import org.cyclonedx.util.BomUtils
import sbt._
import sbt.librarymanagement.ModuleReport
Expand All @@ -16,7 +26,9 @@ import java.util
import java.util.UUID
import scala.collection.JavaConverters._

class BomExtractor(settings: BomExtractorParams, report: UpdateReport, log: Logger) {
import SbtUpdateReport.ModuleGraph

class BomExtractor(settings: BomExtractorParams, report: UpdateReport, rootModuleID: ModuleID, log: Logger) {
private val serialNumber: String = "urn:uuid:" + UUID.randomUUID.toString

def bom: Bom = {
Expand All @@ -28,6 +40,9 @@ class BomExtractor(settings: BomExtractorParams, report: UpdateReport, log: Logg
bom.setMetadata(metadata)
}
bom.setComponents(components.asJava)
if (settings.includeBomDependencyTree && settings.schemaVersion.getVersion >= Version.VERSION_11.getVersion) {
bom.setDependencies(dependencyTree.asJava)
}
bom
}

Expand Down Expand Up @@ -114,9 +129,7 @@ class BomExtractor(settings: BomExtractorParams, report: UpdateReport, log: Logg
component.setVersion(version)
component.setModified(false)
component.setType(Component.Type.LIBRARY)
component.setPurl(
new PackageURL(PackageURL.StandardTypes.MAVEN, group, name, version, new util.TreeMap(), null).canonicalize()
)
component.setPurl(purl(group, name, version))
if (settings.schemaVersion.getVersion >= Version.VERSION_11.getVersion) {
// component bom-refs must be unique
component.setBomRef(component.getPurl)
Expand Down Expand Up @@ -201,6 +214,46 @@ class BomExtractor(settings: BomExtractorParams, report: UpdateReport, log: Logg
}
}

private def purl(group: String, name: String, version: String): String =
new PackageURL(PackageURL.StandardTypes.MAVEN, group, name, version, new util.TreeMap(), null).canonicalize()

private def dependencyTree: Seq[Dependency] = {
val dependencyTree = configurationsForComponents(settings.configuration).flatMap { configuration =>
dependencyTreeForConfiguration(configuration)
}.distinct // deduplicate dependencies reported by multiple configurations

dependencyTree
}

private def dependencyTreeForConfiguration(configuration: Configuration): Seq[Dependency] = {
report
.configuration(configuration)
.toSeq
.flatMap { configurationReport =>
new DependencyTreeExtractor(configurationReport).dependencyTree
}
}

class DependencyTreeExtractor(configurationReport: ConfigurationReport) {
def dependencyTree: Seq[Dependency] =
moduleGraph.nodes
.sortBy(_.id.idString)
.map { node =>
val bomRef = purl(node.id.organization, node.id.name, node.id.version)

val dependency = new Dependency(bomRef)

val dependsOn = moduleGraph.dependencyMap.getOrElse(node.id, Nil).sortBy(_.id.idString)
dependsOn.foreach { module =>
val bomRef = purl(module.id.organization, module.id.name, module.id.version)
dependency.addDependency(new Dependency(bomRef))
}

dependency
}

private def moduleGraph: ModuleGraph = SbtUpdateReport.fromConfigurationReport(configurationReport, rootModuleID)
}
def logComponent(component: Component): Unit = {
log.info(s""""
|${component.getGroup}" % "${component.getName}" % "${component.getVersion}",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ final case class BomExtractorParams(
includeBomHashes: Boolean,
enableBomSha3Hashes: Boolean,
includeBomExternalReferences: Boolean,
includeBomDependencyTree: Boolean,
)
4 changes: 4 additions & 0 deletions src/main/scala/com/github/sbt/sbom/BomSbtPlugin.scala
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ object BomSbtPlugin extends AutoPlugin {
lazy val includeBomExternalReferences: SettingKey[Boolean] = settingKey[Boolean](
"should the resulting BOM contain external references? default is true"
)
lazy val includeBomDependencyTree: SettingKey[Boolean] = settingKey[Boolean](
"should the resulting BOM contain the dependency tree? default is true"
)
lazy val makeBom: TaskKey[sbt.File] = taskKey[sbt.File]("Generates bom file")
lazy val listBom: TaskKey[String] = taskKey[String]("Returns the bom")
lazy val components: TaskKey[Component] = taskKey[Component]("Returns the bom")
Expand Down Expand Up @@ -75,6 +78,7 @@ object BomSbtPlugin extends AutoPlugin {
includeBomHashes := true,
enableBomSha3Hashes := true,
includeBomExternalReferences := true,
includeBomDependencyTree := true,
makeBom := Def.taskDyn(BomSbtSettings.makeBomTask(Classpaths.updateTask.value, Compile)).value,
listBom := Def.taskDyn(BomSbtSettings.listBomTask(Classpaths.updateTask.value, Compile)).value,
Test / makeBom := Def.taskDyn(BomSbtSettings.makeBomTask(Classpaths.updateTask.value, Test)).value,
Expand Down
10 changes: 9 additions & 1 deletion src/main/scala/com/github/sbt/sbom/BomSbtSettings.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
package com.github.sbt.sbom

import com.github.sbt.sbom.BomSbtPlugin.autoImport._
import sbt.Keys.{ sLog, target }
import sbt.Keys.{ projectID, sLog, scalaBinaryVersion, scalaVersion, target }
import sbt._

object BomSbtSettings {
Expand All @@ -20,6 +20,9 @@ object BomSbtSettings {
BomTaskProperties(
report,
currentConfiguration,
CrossVersion(scalaVersion.value, scalaBinaryVersion.value)(
projectID.value
),
sLog.value,
bomSchemaVersion.value,
format,
Expand All @@ -29,6 +32,7 @@ object BomSbtSettings {
includeBomHashes.value,
enableBomSha3Hashes.value,
includeBomExternalReferences.value,
includeBomDependencyTree.value,
),
target.value / (currentConfiguration / bomFileName).value
).execute
Expand All @@ -45,6 +49,9 @@ object BomSbtSettings {
BomTaskProperties(
report,
currentConfiguration,
CrossVersion(scalaVersion.value, scalaBinaryVersion.value)(
projectID.value
),
sLog.value,
bomSchemaVersion.value,
format,
Expand All @@ -54,6 +61,7 @@ object BomSbtSettings {
includeBomHashes.value,
enableBomSha3Hashes.value,
includeBomExternalReferences.value,
includeBomDependencyTree.value,
)
).execute
}
Expand Down
9 changes: 8 additions & 1 deletion src/main/scala/com/github/sbt/sbom/BomTask.scala
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import scala.collection.JavaConverters._
final case class BomTaskProperties(
report: UpdateReport,
currentConfiguration: Configuration,
rootModuleID: ModuleID,
log: Logger,
schemaVersion: String,
bomFormat: BomFormat,
Expand All @@ -27,6 +28,7 @@ final case class BomTaskProperties(
includeBomHashes: Boolean,
enableBomSha3Hashes: Boolean,
includeBomExternalReferences: Boolean,
includeBomDependencyTree: Boolean,
)

abstract class BomTask[T](protected val properties: BomTaskProperties) {
Expand All @@ -35,7 +37,7 @@ abstract class BomTask[T](protected val properties: BomTaskProperties) {

protected def getBomText: String = {
val params: BomExtractorParams = extractorParams(currentConfiguration)
val bom: Bom = new BomExtractor(params, report, log).bom
val bom: Bom = new BomExtractor(params, report, rootModuleID, log).bom
val bomText: String = bomFormat match {
case BomFormat.Json => BomGeneratorFactory.createJson(schemaVersion, bom).toJsonString
case BomFormat.Xml => BomGeneratorFactory.createXml(schemaVersion, bom).toXmlString
Expand Down Expand Up @@ -81,6 +83,7 @@ abstract class BomTask[T](protected val properties: BomTaskProperties) {
includeBomHashes,
enableBomSha3Hashes,
includeBomExternalReferences,
includeBomDependencyTree,
)

protected def logBomInfo(params: BomExtractorParams, bom: Bom): Unit = {
Expand All @@ -93,6 +96,8 @@ abstract class BomTask[T](protected val properties: BomTaskProperties) {

protected def currentConfiguration: Configuration = properties.currentConfiguration

protected def rootModuleID: ModuleID = properties.rootModuleID

protected def log: Logger = properties.log

protected lazy val schemaVersion: Version =
Expand All @@ -117,4 +122,6 @@ abstract class BomTask[T](protected val properties: BomTaskProperties) {
protected lazy val enableBomSha3Hashes: Boolean = properties.enableBomSha3Hashes

protected lazy val includeBomExternalReferences: Boolean = properties.includeBomExternalReferences

protected lazy val includeBomDependencyTree: Boolean = properties.includeBomDependencyTree
}
93 changes: 93 additions & 0 deletions src/main/scala/com/github/sbt/sbom/SbtUpdateReport.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// SPDX-FileCopyrightText: 2023, Scala center, 2011 - 2022, Lightbend, Inc., 2008 - 2010, Mark Harrah
//
// SPDX-License-Identifier: Apache-2.0

package com.github.sbt.sbom

import sbt.librarymanagement.{ ConfigurationReport, ModuleID, ModuleReport }
import sbt.{ File, OrganizationArtifactReport }

import scala.collection.mutable

/*
* taken from sbt at https://github.com/sbt/sbt/blob/1.10.x/main/src/main/scala/sbt/internal/graph/backend/SbtUpdateReport.scala
*
* Copyright 2023, Scala center
* Copyright 2011 - 2022, Lightbend, Inc.
* Copyright 2008 - 2010, Mark Harrah
* Licensed under Apache License 2.0 (see LICENSE)
*/
object SbtUpdateReport {
case class Module(
id: GraphModuleId,
license: Option[String] = None,
extraInfo: String = "",
evictedByVersion: Option[String] = None,
jarFile: Option[File] = None,
error: Option[String] = None
)

private type Edge = (GraphModuleId, GraphModuleId)
private def Edge(from: GraphModuleId, to: GraphModuleId): Edge = from -> to

case class ModuleGraph(nodes: Seq[Module], edges: Seq[Edge]) {
lazy val modules: Map[GraphModuleId, Module] =
nodes.map(n => (n.id, n)).toMap

def module(id: GraphModuleId): Option[Module] = modules.get(id)

lazy val dependencyMap: Map[GraphModuleId, Seq[Module]] =
createMap(identity)

def createMap(
bindingFor: ((GraphModuleId, GraphModuleId)) => (GraphModuleId, GraphModuleId)
): Map[GraphModuleId, Seq[Module]] = {
val m = new mutable.HashMap[GraphModuleId, mutable.Set[Module]] with mutable.MultiMap[GraphModuleId, Module]
edges.foreach { entry =>
val (f, t) = bindingFor(entry)
module(t).foreach(m.addBinding(f, _))
}
m.toMap.mapValues(_.toSeq.sortBy(_.id.idString)).toMap.withDefaultValue(Nil)
}

def roots: Seq[Module] =
nodes.filter(n => !edges.exists(_._2 == n.id)).sortBy(_.id.idString)
}

case class GraphModuleId(organization: String, name: String, version: String) {
def idString: String = organization + ":" + name + ":" + version
}
object GraphModuleId {
def apply(sbtId: ModuleID): GraphModuleId =
GraphModuleId(sbtId.organization, sbtId.name, sbtId.revision)
}

def fromConfigurationReport(report: ConfigurationReport, rootInfo: ModuleID): ModuleGraph = {
def moduleEdges(orgArt: OrganizationArtifactReport): Seq[(Module, Seq[Edge])] = {
val chosenVersion = orgArt.modules.find(!_.evicted).map(_.module.revision)
orgArt.modules.map(moduleEdge(chosenVersion))
}

def moduleEdge(chosenVersion: Option[String])(report: ModuleReport): (Module, Seq[Edge]) = {
val evictedByVersion = if (report.evicted) chosenVersion else None
val jarFile = report.artifacts
.find(_._1.`type` == "jar")
.orElse(report.artifacts.find(_._1.extension == "jar"))
.map(_._2)
(
Module(
id = GraphModuleId(report.module),
license = report.licenses.headOption.map(_._1),
evictedByVersion = evictedByVersion,
jarFile = jarFile,
error = report.problem
),
report.callers.map(caller => Edge(GraphModuleId(caller.caller), GraphModuleId(report.module)))
)
}
val (nodes, edges) = report.details.flatMap(moduleEdges).unzip
val root = Module(GraphModuleId(rootInfo))

ModuleGraph(root +: nodes, edges.flatten)
}
}
Loading
Loading