diff --git a/.gitignore b/.gitignore index eb5a316..3fa9721 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ target +/report diff --git a/report.sh b/report.sh new file mode 100755 index 0000000..91b353f --- /dev/null +++ b/report.sh @@ -0,0 +1,12 @@ +#!/bin/sh + +set -e + +rm -r report || true +mkdir report + +find . -name "reproducible-builds-report.md" | sort | xargs cat > report/index.md +find . -name "reproducible-builds-diffoscope-output-*" | sort | while read line ; do cp -r $line report ; done + +markdown report/index.md > report/index.html + diff --git a/src/main/scala/ReproducibleBuildsPlugin.scala b/src/main/scala/ReproducibleBuildsPlugin.scala index 7017cbf..ffa3cf4 100644 --- a/src/main/scala/ReproducibleBuildsPlugin.scala +++ b/src/main/scala/ReproducibleBuildsPlugin.scala @@ -5,7 +5,7 @@ import java.nio.charset.Charset import java.nio.file.Files import scala.concurrent.duration._ -import gigahorse.GigahorseSupport +import gigahorse.{GigahorseSupport, StatusError} import sbt.{io => _, _} import sbt.Keys._ import sbt.Classpaths._ @@ -40,98 +40,151 @@ object ReproducibleBuildsPlugin extends AutoPlugin { val reproducibleBuildsCertification = taskKey[File]("Create a Reproducible Builds certification") val reproducibleBuildsCheckCertification = taskKey[Unit]("Download and compare Reproducible Builds certifications") + val reproducibleBuildsCheckMavenCentral = taskKey[File]("Compare Reproducible Build certifications against those published on Maven Central") val bzztNetResolver = Resolver.url("repo.bzzt.net", url("http://repo.bzzt.net:8000"))(Patterns().withArtifactPatterns(Vector( // We default to a Maven-style pattern with host and timestamp to reduce naming collisions, and branch if populated "[organisation]/[module](_[scalaVersion])(_[sbtVersion])/([branch]/)[revision]/[artifact]-[revision](-[classifier])(-[host])(-[timestamp]).[ext]" ))) + lazy val ourCertification = Def.task[Certification] { + Certification( + organization.value, + reproducibleBuildsPackageName.value, + version.value, + scmInfo.value, + (packagedArtifacts in Compile).value, + (scalaVersion in artifactName).value, + (scalaBinaryVersion in artifactName).value, + sbtVersion.value + ) + } + + lazy val ourCertificationFile = Def.task[File] { + val certification = ourCertification.value + + val targetDirPath = crossTarget.value + val targetFilePath = targetDirPath.toPath.resolve(targetFilename(certification.artifactId, certification.version, certification.classifier)) + + Files.write(targetFilePath, certification.asPropertyString.getBytes(Charset.forName("UTF-8"))) + + targetFilePath.toFile + } + + sealed trait ArtifactUrlTarget + case object MavenCentral extends ArtifactUrlTarget + case object PublishToPrefix extends ArtifactUrlTarget + + def artifactUrl(target: ArtifactUrlTarget, ext: String) = Def.task { + import scala.collection.JavaConverters._ + val extraModuleAttributes = { + val scalaVer = Map("scalaVersion" -> scalaBinaryVersion.value) + if (sbtPlugin.value) scalaVer + ("sbtVersion" -> sbtBinaryVersion.value) + else scalaVer + }.asJava + + val pattern: String = target match { + case MavenCentral => + "http://repo1.maven.org/maven2/[organisation]/[module](_[scalaVersion])/[revision]/[artifact](_[scalaVersion])-[revision](-[classifier]).[ext]" + case PublishToPrefix => + val pattern = (publishTo in ReproducibleBuilds).value.getOrElse(bzztNetResolver).asInstanceOf[URLRepository].patterns.artifactPatterns.head + pattern.substring(0, pattern.lastIndexOf("/") + 1) + } + + IvyPatternHelper.substitute( + pattern, + organization.value.replace('.', '/'), + reproducibleBuildsPackageName.value, + version.value, + reproducibleBuildsPackageName.value, + ext, + ext, + "compile", + extraModuleAttributes, + null + ) + } + override lazy val projectSettings = Seq( publishCertification := true, hostname := InetAddress.getLocalHost.getHostName, resolvers += bzztNetResolver, packageBin in Compile := postProcessJar((packageBin in Compile).value), reproducibleBuildsPackageName := moduleName.value, - reproducibleBuildsCertification := { - val certification = Certification( - organization.value, - reproducibleBuildsPackageName.value, - version.value, - scmInfo.value, - (packagedArtifacts in Compile).value, - (scalaVersion in artifactName).value, - (scalaBinaryVersion in artifactName).value, - sbtVersion.value - ) - - val targetDirPath = crossTarget.value - val targetFilePath = targetDirPath.toPath.resolve(targetFilename(certification.artifactId, certification.version, certification.classifier)) - - Files.write(targetFilePath, certification.asPropertyString.getBytes(Charset.forName("UTF-8"))) - - targetFilePath.toFile - }, + reproducibleBuildsCertification := ourCertificationFile.value, artifact in ReproducibleBuilds := Artifact(reproducibleBuildsPackageName.value, "buildinfo", "buildinfo"), packagedArtifacts ++= { val generatedArtifact = Map( - (artifact in ReproducibleBuilds).value -> - { - val certification = Certification( - organization.value, - reproducibleBuildsPackageName.value, - version.value, - scmInfo.value, - (packagedArtifacts in Compile).value, - (scalaVersion in artifactName).value, - (scalaBinaryVersion in artifactName).value, - sbtVersion.value - ) - - val targetDirPath = crossTarget.value - val targetFilePath = targetDirPath.toPath.resolve(targetFilename(certification.artifactId, certification.version, certification.classifier)) - - Files.write(targetFilePath, certification.asPropertyString.getBytes(Charset.forName("UTF-8"))) - - targetFilePath.toFile - } + (artifact in ReproducibleBuilds).value -> ourCertificationFile.value ) if (publishCertification.value) generatedArtifact else Map.empty[Artifact, File] }, + reproducibleBuildsCheckMavenCentral := { + val ourArtifacts = (packagedArtifacts in Compile).value + val url = artifactUrl(MavenCentral,"buildinfo").value + + val log = streams.value.log + log.info(s"Downloading certification from [$url]") + val targetDirPath = crossTarget.value + + val report: Future[String] = checkVerification(ourCertification.value, uri(url)) + .flatMap(result => { + showResult(log, result) + if (result.ok) Future.successful(result.asMarkdown) + else Future.sequence({ + result.verdicts + .collect { case (filename: String, m: Mismatch) => { + val ext = filename.substring(filename.lastIndexOf('.') + 1) + val mavenArtifactUrl = artifactUrl(MavenCentral, "").value + ext + + val artifactName = mavenArtifactUrl.substring(mavenArtifactUrl.lastIndexOf('/') + 1) + + val ourArtifact = ourArtifacts.collect { case (art, file) if art.`type` == ext => file }.toList match { + case List() => throw new IllegalStateException(s"Did not find local artifact for $artifactName ($ext)") + case List(artifact) => artifact + case multiple => throw new IllegalStateException(s"Found multiple artifacts for $ext") + } + + http.run(GigahorseSupport.url(mavenArtifactUrl)).map { entity => + val mavenCentralArtifactsPath = targetDirPath.toPath.resolve("mavenCentralArtifact") + mavenCentralArtifactsPath.toFile.mkdirs() + val mavenCentralArtifact = mavenCentralArtifactsPath.resolve(artifactName) + Files.write( + mavenCentralArtifact, + entity.bodyAsByteBuffer.array() + ) + val diffoscopeOutputDir = targetDirPath.toPath.resolve(s"reproducible-builds-diffoscope-output-$artifactName") + val cmd = s"diffoscope --html-dir $diffoscopeOutputDir $ourArtifact $mavenCentralArtifact" + new ProcessBuilder( + "diffoscope", + "--html-dir", + diffoscopeOutputDir.toFile.getAbsolutePath, + ourArtifact.getAbsolutePath, + mavenCentralArtifact.toFile.getAbsolutePath + ).start().waitFor() + log.info(s"Running '$cmd' for a detailed report on the differences") + s"See the [diffoscope report](reproducible-builds-diffoscope-output-$artifactName/index.html) for a detailed explanation " + + " of the differences between the freshly built artifact and the one published to Maven Central" + }.recover { + case s: StatusError if s.status == 404 => + s"Unfortunately no artifact was found at $mavenArtifactUrl to diff against." + } + } + } + }).map { verdicts => result.asMarkdown + "\n\n" + verdicts.mkString("", "\n\n", "\n\n") } + }) + + val targetFilePath = targetDirPath.toPath.resolve("reproducible-builds-report.md") + + Files.write(targetFilePath, Await.result(report, 30.seconds).getBytes(Charset.forName("UTF-8"))) + + targetFilePath.toFile + }, reproducibleBuildsCheckCertification := { - val ours = Certification( - organization.value, - reproducibleBuildsPackageName.value, - version.value, - scmInfo.value, - (packagedArtifacts in Compile).value, - (scalaVersion in artifactName).value, - (scalaBinaryVersion in artifactName).value, - sbtVersion.value - ) + val ours = ourCertification.value val groupId = organization.value - // TODO also check against the 'official' published buildinfo - val pattern = (publishTo in ReproducibleBuilds).value.getOrElse(bzztNetResolver).asInstanceOf[URLRepository].patterns.artifactPatterns.head - val prefixPattern = pattern.substring(0, pattern.lastIndexOf("/") + 1) - import scala.collection.JavaConverters._ - val extraModuleAttributes = { - val scalaVer = Map("scalaVersion" -> scalaBinaryVersion.value) - if (sbtPlugin.value) scalaVer + ("sbtVersion" -> sbtBinaryVersion.value) - else scalaVer - }.asJava - - val prefix = IvyPatternHelper.substitute( - prefixPattern, - organization.value.replace('.', '/'), - reproducibleBuildsPackageName.value, - version.value, - reproducibleBuildsPackageName.value, - "buildinfo", - "buildinfo", - "compile", - extraModuleAttributes, - null - ) + val prefix = artifactUrl(PublishToPrefix, "buildinfo").value val log = streams.value.log log.info(s"Discovering certifications at [$prefix]") // TODO add Accept header to request JSON-formatted @@ -147,13 +200,7 @@ object ReproducibleBuildsPlugin extends AutoPlugin { Future.sequence(results) }.map { resultList => log.info(s"Processed ${resultList.size} results. ${resultList.count(_.ok)} matching attestations, ${resultList.filterNot(_.ok).size} mismatches"); - resultList.foreach { result => - log.info(s"${result.uri}:") - log.info("- " + (if (result.ok) "OK" else "NOT OK")) - result.verdicts.foreach { - case (filename, verdict) => log.info(s"- $filename: $verdict") - } - } + resultList.foreach { result => showResult(log, result) } } Await.result(done, 30.seconds) }, @@ -218,6 +265,9 @@ object ReproducibleBuildsPlugin extends AutoPlugin { publishM2 := publishTask(publishM2Configuration).value )) + private def showResult(log: Logger, result: VerificationResult): Unit = + log.info(result.asMarkdown) + private def gpgPluginSettings = if (gpgPluginOnClasspath) GpgHelpers.settings else Seq.empty @@ -242,6 +292,8 @@ object ReproducibleBuildsPlugin extends AutoPlugin { http.run(GigahorseSupport.url(uri.toASCIIString)).map { entity => val theirs = Certification(entity.bodyAsString) VerificationResult(uri, ourSums, theirs.checksums) + }.recover { + case e: StatusError if e.status == 404 => VerificationResult(uri, ourSums, Seq.empty) } } diff --git a/src/main/scala/VerificationResult.scala b/src/main/scala/VerificationResult.scala index 896d728..5e2c389 100644 --- a/src/main/scala/VerificationResult.scala +++ b/src/main/scala/VerificationResult.scala @@ -2,23 +2,49 @@ package net.bzzt.reproduciblebuilds import java.net.URI +sealed trait Verdict { + val description: String +} +case object Match extends Verdict { + val description = "Match" +} +case object MissingInTheirs extends Verdict { + val description = "Missing in theirs but present in ours" +} +case object MissingInOurs extends Verdict { + val description = "Missing in ours but present in theirs" +} +case class Mismatch(our: Checksum, their: Checksum) extends Verdict { + val description = s"Mismatch: our ${our.hexChecksum} did not match their ${their.hexChecksum}" +} + case class VerificationResult( uri: URI, ourSums: Map[String, Checksum], remoteSums: Map[String, Checksum], ) { - def verdicts: Seq[(String, String)] = + def asMarkdown = { + val artifactName = uri.toASCIIString.substring(uri.toASCIIString.lastIndexOf('/') + 1) + s"""# `$artifactName`: ${if (ok) "OK" else "NOT OK"} + | + |${verdicts.map { case (filename, verdict) => s"- $filename: ${verdict.description}" }.mkString("\n")} + """.stripMargin + } + + /** + * filename -> verdict + */ + def verdicts: Seq[(String, Verdict)] = ourSums.map { case (key, value) => (key, verdict(key, value)) }.toSeq ++ - ourSums.keySet.diff(remoteSums.keySet).map { missingInTheirs => (missingInTheirs, "Missing in their checksums but present in ours") } ++ - remoteSums.keySet.diff(ourSums.keySet).map { missingInOurs => (missingInOurs, "Missing in our checksums but present in theirs") } + remoteSums.keySet.diff(ourSums.keySet).map { missingInOurs => (missingInOurs, MissingInOurs) } - def verdict(filename: String, ourSum: Checksum): String = remoteSums.get(filename) match { - case None => "Not found in remote attestation" + def verdict(filename: String, ourSum: Checksum): Verdict = remoteSums.get(filename) match { + case None => MissingInTheirs case Some(checksum) => - if (checksum == ourSum) "Match" - else s"Mismatch: our ${ourSum.hexChecksum} did not match their ${checksum.hexChecksum}" + if (checksum == ourSum) Match + else Mismatch(ourSum, checksum) } def ok = ourSums == remoteSums