diff --git a/README.md b/README.md index c672a2e..cc13208 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ Gradle plugin for [Embed Code][embed-code], an application that keeps code examples in Markdown and HTML synchronized with their source files. The plugin downloads the released Embed Code executable for the current -platform, so developers and CI jobs do not have to install it manually. It -adds two tasks: +platform, so developers and CI jobs do not have to install it manually. +It adds two tasks: - `checkEmbedding` checks that embedded code is up to date. - `embedCode` updates embedded code in place. @@ -99,6 +99,26 @@ embedCode { } ``` +Before installing an executable, the plugin verifies the release asset's SHA-256 +digest from the GitHub Releases API. Only these metadata requests use the +optional token; release asset downloads remain unauthenticated. API requests are +unauthenticated unless a token provider is configured explicitly: + +```kotlin +embedCode { + githubToken.set(providers.environmentVariable("EMBED_CODE_GITHUB_TOKEN")) +} +``` + +For CI, configure `githubToken` to avoid GitHub's unauthenticated API rate limit, +or pin both `version` and `sha256`. Pairing the digest with a fixed version keeps +the pin valid when GitHub publishes a newer release. + +The digest applies to the downloaded release asset. For macOS, this means the +ZIP archive rather than the extracted executable. Verified cache metadata is +stored under `build/embed-code`; offline mode reuses the executable only when +its current digest, release source, and platform asset still match that metadata. + Check that documentation is up to date: ```bash diff --git a/gradle-plugin/src/functionalTest/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt b/gradle-plugin/src/functionalTest/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt index 14eb780..c01342e 100644 --- a/gradle-plugin/src/functionalTest/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt +++ b/gradle-plugin/src/functionalTest/kotlin/io/spine/embedcode/gradle/EmbedCodePluginSpec.kt @@ -42,6 +42,7 @@ import java.net.InetSocketAddress import java.nio.file.Files import java.nio.file.Path import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference import java.util.zip.ZipEntry @@ -145,12 +146,40 @@ internal class EmbedCodePluginSpec { result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS versionChecks.get() shouldBe 2 downloads.get() shouldBe 1 - result.output shouldContain "Reusing Embed Code v$TEST_RELEASE_VERSION" + result.output shouldContain "Reusing verified Embed Code v$TEST_RELEASE_VERSION" } finally { server.stop(0) } } + @Test + fun `ignore an ambient GitHub token unless explicitly configured`() { + Files.writeString( + projectDirectory.resolve("build.gradle.kts"), + """ + + tasks.named("installEmbedCode") { + doFirst { + val token = javaClass.getMethod("getGithubToken").invoke(this) + as org.gradle.api.provider.Property<*> + check(!token.isPresent) { + "The plugin must not use GITHUB_TOKEN implicitly." + } + } + } + """.trimIndent(), + StandardOpenOption.APPEND, + ) + val environment = System.getenv().toMutableMap() + environment["GITHUB_TOKEN"] = "ambient-token" + + val result = runner(":installEmbedCode") + .withEnvironment(environment) + .build() + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + } + @Test fun `use a resolved latest release tag without modification`() { val releaseTag = "release-$TEST_RELEASE_VERSION" @@ -184,10 +213,17 @@ internal class EmbedCodePluginSpec { val downloads = AtomicInteger() val server = startReleaseServer(latestTag, versionChecks, downloads) try { - writeBuildFile(downloadBaseUrl = server.releaseBaseUrl) + writeBuildFile( + downloadBaseUrl = server.releaseBaseUrl, + sha256 = releaseAssetSha256(version = TEST_RELEASE_VERSION), + ) runner(":installEmbedCode").build() latestTag.set("v$nextVersion") + writeBuildFile( + downloadBaseUrl = server.releaseBaseUrl, + sha256 = releaseAssetSha256(version = nextVersion), + ) runner(":installEmbedCode").build() versionChecks.get() shouldBe 2 @@ -218,7 +254,7 @@ internal class EmbedCodePluginSpec { val result = runner(":installEmbedCode", "--offline").build() result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS - result.output shouldContain "Reusing cached Embed Code executable" + result.output shouldContain "Reusing verified cached Embed Code executable" } @Test @@ -260,10 +296,57 @@ internal class EmbedCodePluginSpec { val result = runner(":installEmbedCode", "--offline").buildAndFail() result.output shouldContain - "Cannot install the latest Embed Code release in offline mode because " + + "Cannot install Embed Code in offline mode because " + "no cached executable exists" } + @Test + fun `keep explicit versions offline when no verified executable is cached`() { + writeBuildFile(version = TEST_RELEASE_VERSION) + + val result = runner(":installEmbedCode", "--offline").buildAndFail() + + result.output shouldContain "Cannot install Embed Code in offline mode" + result.output shouldNotContain "Downloading Embed Code" + } + + @Test + fun `reject a release asset whose SHA-256 digest does not match`() { + writeBuildFile(sha256 = "0".repeat(64)) + + val result = runner(":installEmbedCode").buildAndFail() + + result.output shouldContain "SHA-256 verification failed for Embed Code asset" + } + + @Test + fun `reject a modified cached executable in offline mode`() { + runner(":installEmbedCode").build() + val executableName = EmbedCodePlatform.installedExecutableName( + System.getProperty("os.name"), + ) + Files.writeString( + projectDirectory.resolve("build/embed-code/latest/$executableName"), + "modified after verification", + ) + + val result = runner(":installEmbedCode", "--offline").buildAndFail() + + result.output shouldContain "SHA-256 integrity metadata is missing or does not match" + } + + @Test + fun `accept an explicitly pinned release asset`() { + writeBuildFile( + version = TEST_RELEASE_VERSION, + sha256 = releaseAssetSha256(version = TEST_RELEASE_VERSION), + ) + + val result = runner(":installEmbedCode").build() + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + } + @Test @EnabledOnOs(OS.LINUX, OS.MAC) fun `trim an overridden Embed Code version`() { @@ -360,7 +443,8 @@ internal class EmbedCodePluginSpec { val result = runner(":embedCode").build() - result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.UP_TO_DATE + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + result.output shouldContain "Reusing verified Embed Code $TEST_RELEASE_VERSION" result.task(":embedCode")?.outcome shouldBe TaskOutcome.SUCCESS Files.readString(projectDirectory.resolve("mode.txt")).trim() shouldBe "embed" } @@ -538,8 +622,10 @@ internal class EmbedCodePluginSpec { private fun writeBuildFile( version: String? = null, downloadBaseUrl: String = releaseDirectory.toUri().toString().trimEnd('/'), + sha256: String? = null, ) { val versionConfiguration = version?.let { "version.set(\"$it\")" }.orEmpty() + val configuredSha256 = sha256 ?: releaseAssetSha256(version = version) Files.writeString( projectDirectory.resolve("build.gradle.kts"), """ @@ -549,6 +635,7 @@ internal class EmbedCodePluginSpec { embedCode { $versionConfiguration + sha256.set("$configuredSha256") downloadBaseUrl.set("$downloadBaseUrl") codePath.set(layout.projectDirectory.dir("code")) docsPath.set(layout.projectDirectory.dir("docs")) @@ -591,6 +678,7 @@ internal class EmbedCodePluginSpec { embedCode { downloadBaseUrl.set("$baseUrl") + sha256.set("${releaseAssetSha256()}") $directSource namedSource("$firstSourceName", layout.projectDirectory.dir("company-site")) $secondSource @@ -621,6 +709,7 @@ internal class EmbedCodePluginSpec { executable, """ #!/bin/sh + # release-marker: $version : > arguments.txt for argument in "${'$'}@"; do printf '%s\n' "${'$'}argument" >> arguments.txt @@ -643,13 +732,39 @@ internal class EmbedCodePluginSpec { } else { Files.copy(executable, asset) } + val latestAsset = latestDirectory.resolve(platform.assetName) Files.copy( asset, - latestDirectory.resolve(platform.assetName), + latestAsset, StandardCopyOption.REPLACE_EXISTING, ) } + /** + * Returns the digest of a fake release asset. + */ + private fun releaseAssetSha256( + root: Path = releaseDirectory, + version: String? = null, + ): String { + val platform = EmbedCodePlatform.detect( + System.getProperty("os.name"), + System.getProperty("os.arch"), + ) + val asset = if (version == null) { + root.resolve("latest/download/${platform.assetName}") + } else { + val normalizedVersion = version.trim() + val tag = if (normalizedVersion.startsWith('v')) { + normalizedVersion + } else { + "v$normalizedVersion" + } + root.resolve("download/$tag/${platform.assetName}") + } + return sha256(asset) + } + /** * Starts a release server whose latest endpoint redirects to a mutable tag. */ @@ -673,8 +788,8 @@ internal class EmbedCodePluginSpec { exchange.close() } server.createContext("/releases/download/") { exchange -> - downloads.incrementAndGet() val relativePath = exchange.requestURI.path.removePrefix("/releases/download/") + downloads.incrementAndGet() val asset = releaseDirectory.resolve("download").resolve(relativePath).normalize() if (!asset.startsWith(releaseDirectory.resolve("download")) || !Files.isRegularFile(asset)) { exchange.sendResponseHeaders(404, -1) diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/Checksum.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/Checksum.kt new file mode 100644 index 0000000..1be298d --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/Checksum.kt @@ -0,0 +1,162 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * 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://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.embedcode.gradle + +import groovy.json.JsonSlurper +import org.gradle.api.GradleException +import java.net.URI +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.security.MessageDigest +import java.util.HexFormat + +private const val SHA256_LENGTH = 64 + +/** + * Calculates the lowercase SHA-256 digest of [file]. + */ +internal fun sha256(file: Path): String { + val digest = MessageDigest.getInstance("SHA-256") + Files.newInputStream(file).use { input -> + val buffer = ByteArray(8_192) + var count = input.read(buffer) + while (count >= 0) { + digest.update(buffer, 0, count) + count = input.read(buffer) + } + } + return HexFormat.of().formatHex(digest.digest()) +} + +/** + * Calculates the lowercase SHA-256 digest of [value]. + */ +internal fun sha256(value: String): String { + val digest = MessageDigest.getInstance("SHA-256") + digest.update(value.toByteArray(StandardCharsets.UTF_8)) + return HexFormat.of().formatHex(digest.digest()) +} + +/** + * Returns the cache identity for [releaseBaseUrl] and [assetName]. + */ +internal fun releaseAssetIdentity(releaseBaseUrl: String, assetName: String): String = + sha256("$releaseBaseUrl\u0000$assetName") + +/** + * Validates and normalizes a SHA-256 [value]. + */ +internal fun normalizeSha256(value: String): String { + val trimmed = value.trim() + val digest = if (trimmed.startsWith("sha256:", ignoreCase = true)) { + trimmed.substringAfter(':') + } else { + trimmed + } + val isInvalid = digest.length != SHA256_LENGTH || + digest.any { it !in '0'..'9' && it.lowercaseChar() !in 'a'..'f' } + if (isInvalid) { + throw GradleException("Invalid SHA-256 digest `$value`.") + } + return digest.lowercase() +} + +/** + * Returns the GitHub release API URI represented by [releaseBaseUrl]. + */ +internal fun githubReleaseApi(releaseBaseUrl: String, releaseTag: String): URI? { + val base = URI.create(releaseBaseUrl) + if ( + base.scheme != "https" || + base.host?.equals("github.com", ignoreCase = true) != true + ) { + return null + } + val segments = base.path.trim('/').split('/') + if (segments.size != 3 || segments[2] != "releases") { + return null + } + val encodedTag = URLEncoder.encode(releaseTag, StandardCharsets.UTF_8).replace("+", "%20") + return URI.create( + "https://api.github.com/repos/${segments[0]}/${segments[1]}/releases/tags/$encodedTag", + ) +} + +/** + * Extracts [assetName]'s SHA-256 digest from GitHub release JSON. + */ +internal fun parseGitHubAssetSha256(json: String, assetName: String): String { + val release = JsonSlurper().parseText(json) as? Map<*, *> + ?: throw GradleException("The GitHub release response is not a JSON object.") + val assets = release["assets"] as? Iterable<*> + ?: throw GradleException("The GitHub release response does not contain assets.") + val asset = assets + .filterIsInstance>() + .firstOrNull { it["name"] == assetName } + ?: throw GradleException("The GitHub release does not contain asset `$assetName`.") + val digest = asset["digest"] as? String + ?: throw GradleException( + "GitHub does not provide a SHA-256 digest for release asset `$assetName`. " + + "Configure `embedCode.sha256` explicitly.", + ) + return normalizeSha256(digest) +} + +/** + * Resolves the trusted digest for a downloaded release asset. + * + * The configured digest takes precedence. Otherwise, the digest is read from + * the GitHub Releases API for releases hosted on `github.com`. + */ +internal fun resolveExpectedAssetSha256( + configuredSha256: String?, + releaseBaseUrl: String, + releaseTag: String?, + assetName: String, + readMetadata: (URI) -> String, +): String { + if (configuredSha256 != null) { + return configuredSha256 + } + val githubApi = releaseTag?.let { githubReleaseApi(releaseBaseUrl, it) } + ?: throw GradleException( + "Automatic SHA-256 resolution is available only for github.com releases. " + + "Configure `embedCode.sha256` for asset `$assetName`.", + ) + return try { + parseGitHubAssetSha256(readMetadata(githubApi), assetName) + } catch (exception: GradleException) { + throw GradleException( + "Could not resolve a SHA-256 digest for Embed Code asset `$assetName` " + + "from `$githubApi`. Configure `embedCode.sha256` explicitly, or " + + "`embedCode.githubToken` if the GitHub API rate limit was exceeded.", + exception, + ) + } +} diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeExtension.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeExtension.kt index ff19a61..4f1818e 100644 --- a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeExtension.kt +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeExtension.kt @@ -48,6 +48,24 @@ public abstract class EmbedCodeExtension { /** An optional release version, with the latest release used when absent. */ public abstract val version: Property + /** + * An optional SHA-256 digest of the configured release asset. + * + * The plugin resolves this digest automatically for releases hosted on + * `github.com`. Configure this property together with [version] to pin a + * release asset explicitly. + */ + public abstract val sha256: Property + + /** + * An optional GitHub token used to read release checksum metadata. + * + * Configure this property explicitly when authenticated GitHub API access + * is intended. The plugin does not read a token from the environment by + * default. + */ + public abstract val githubToken: Property + /** The root directory containing source files used by embedding instructions. */ public abstract val codePath: DirectoryProperty @@ -107,8 +125,7 @@ public abstract class EmbedCodeExtension { * * The plugin appends `/latest/download/` when no version is * configured, or `/download/v/` for an explicit - * version. This property primarily supports release mirrors and functional - * testing. + * version. This property primarily supports functional testing. */ public abstract val downloadBaseUrl: Property diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt index cb8801d..a9501c2 100644 --- a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt @@ -67,6 +67,8 @@ public class EmbedCodePlugin : Plugin { ) { task -> task.description = "Installs the requested Embed Code executable" task.version.set(requestedVersion) + task.sha256.set(extension.sha256) + task.githubToken.set(extension.githubToken) task.downloadBaseUrl.set(extension.downloadBaseUrl) task.operatingSystem.set(operatingSystem) task.architecture.set(architecture) @@ -81,7 +83,29 @@ public class EmbedCodePlugin : Plugin { task.resolvedVersionFile.set( project.layout.buildDirectory.file("embed-code/latest/version.txt"), ) - task.outputs.upToDateWhen { task.version.isPresent } + task.assetChecksumFile.set( + project.layout.buildDirectory.file( + requestedVersion.map { version -> + "embed-code/$version/asset.sha256" + }.orElse("embed-code/latest/asset.sha256"), + ), + ) + task.executableChecksumFile.set( + project.layout.buildDirectory.file( + requestedVersion.map { version -> + "embed-code/$version/executable.sha256" + }.orElse("embed-code/latest/executable.sha256"), + ), + ) + task.sourceIdentityFile.set( + project.layout.buildDirectory.file( + requestedVersion.map { version -> + "embed-code/$version/source.sha256" + }.orElse("embed-code/latest/source.sha256"), + ), + ) + // Intentionally rerun and re-hash the cached executable before every execution. + task.outputs.upToDateWhen { false } } registerExecutionTask( diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt index fcd14d8..3286a38 100644 --- a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt @@ -31,6 +31,7 @@ import org.gradle.api.GradleException import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Internal import org.gradle.api.tasks.LocalState import org.gradle.api.tasks.Optional import org.gradle.api.tasks.OutputFile @@ -42,6 +43,7 @@ import java.io.OutputStream import java.net.HttpURLConnection import java.net.URI import java.net.URLConnection +import java.nio.charset.StandardCharsets import java.nio.file.AtomicMoveNotSupportedException import java.nio.file.Files import java.nio.file.Path @@ -51,10 +53,10 @@ import java.util.zip.ZipInputStream /** * Downloads and prepares the Embed Code executable selected for the host. * - * An explicitly selected version is reused using Gradle's normal up-to-date - * behavior. For the latest release, the remote version is checked before an - * existing executable is replaced. When the check fails, for example without - * network access, a previously installed executable is reused. + * Cached executables are reused only after their SHA-256 integrity metadata is + * checked. For the latest release, the remote version is checked before an + * existing executable is reused. When that check fails, a previously verified + * executable remains available. */ @DisableCachingByDefault(because = "Release assets come from external URLs that may change") public abstract class InstallEmbedCodeTask : DefaultTask() { @@ -64,6 +66,15 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { @get:Optional public abstract val version: Property + /** An optional user-provided SHA-256 digest of the release asset. */ + @get:Input + @get:Optional + public abstract val sha256: Property + + /** An optional token used for GitHub release metadata requests. */ + @get:Internal + public abstract val githubToken: Property + /** The base URL of the Embed Code releases. */ @get:Input public abstract val downloadBaseUrl: Property @@ -88,6 +99,18 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { @get:LocalState public abstract val resolvedVersionFile: RegularFileProperty + /** Stores the verified digest of the downloaded release asset. */ + @get:LocalState + public abstract val assetChecksumFile: RegularFileProperty + + /** Stores the digest of the prepared executable used from the local cache. */ + @get:LocalState + public abstract val executableChecksumFile: RegularFileProperty + + /** Stores the selected release source and asset identity. */ + @get:LocalState + public abstract val sourceIdentityFile: RegularFileProperty + /** * Downloads, extracts when necessary, and marks the executable runnable. */ @@ -97,6 +120,7 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { if (requestedVersion != null && requestedVersion.isEmpty()) { throw GradleException("Embed Code version must not be empty.") } + val configuredSha256 = sha256.orNull?.let(::normalizeSha256) val hostOperatingSystem = operatingSystem.get() val hostArchitecture = architecture.get() logger.info( @@ -109,8 +133,19 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { val baseUrl = trimTrailingSlashes(downloadBaseUrl.get()) val destination = executableFile.get().asFile.toPath() val versionFile = resolvedVersionFile.get().asFile.toPath() - if (requestedVersion == null && offline.get()) { - reuseOfflineInstallation(destination) + val assetChecksum = assetChecksumFile.get().asFile.toPath() + val executableChecksum = executableChecksumFile.get().asFile.toPath() + val sourceIdentity = sourceIdentityFile.get().asFile.toPath() + val expectedSourceIdentity = releaseAssetIdentity(baseUrl, asset) + if (offline.get()) { + reuseOfflineInstallation( + destination, + assetChecksum, + executableChecksum, + sourceIdentity, + expectedSourceIdentity, + configuredSha256, + ) return } val resolvedVersion = if (requestedVersion == null) { @@ -118,7 +153,16 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { try { resolveLatestVersion(baseUrl) } catch (exception: GradleException) { - if (!Files.isRegularFile(destination)) { + if ( + !isTrustedCachedInstallation( + destination, + assetChecksum, + executableChecksum, + sourceIdentity, + expectedSourceIdentity, + configuredSha256, + ) + ) { throw exception } logger.warn( @@ -136,11 +180,25 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { logger.info("Resolved the latest Embed Code release as {}.", resolvedVersion) } if ( - resolvedVersion != null && - Files.isRegularFile(destination) && - readResolvedVersion(versionFile) == resolvedVersion + ( + requestedVersion != null || + resolvedVersion != null && + readResolvedVersion(versionFile) == resolvedVersion + ) && + isTrustedCachedInstallation( + destination, + assetChecksum, + executableChecksum, + sourceIdentity, + expectedSourceIdentity, + configuredSha256, + ) ) { - logger.lifecycle("Reusing Embed Code {} from {}", resolvedVersion, destination) + logger.lifecycle( + "Reusing verified Embed Code {} from {}", + selectedVersionName(requestedVersion, resolvedVersion), + destination, + ) return } val selectedReleaseTag = if (requestedVersion != null) { @@ -157,6 +215,29 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { val release = requestedVersion ?: "latest release" logger.lifecycle("Downloading Embed Code {} from {}", release, source) download(source, download) + val expectedAssetSha256 = resolveExpectedAssetSha256( + configuredSha256, + baseUrl, + selectedReleaseTag, + asset, + ) { metadataSource -> + val token = if ( + metadataSource.host.equals("api.github.com", ignoreCase = true) + ) { + githubToken.orNull?.trim()?.ifEmpty { null } + } else { + null + } + readText(metadataSource, token) + } + val downloadedSha256 = sha256(download) + if (downloadedSha256 != expectedAssetSha256) { + throw GradleException( + "SHA-256 verification failed for Embed Code asset `$asset`: " + + "expected `$expectedAssetSha256`, but downloaded `$downloadedSha256`.", + ) + } + logger.info("Verified SHA-256 digest `{}` for {}.", downloadedSha256, asset) if (asset.endsWith(".zip")) { extractExecutable(download, platform.executableName, preparedExecutable) @@ -167,7 +248,11 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { if (!preparedExecutable.toFile().setExecutable(true, false)) { throw GradleException("Could not make `$preparedExecutable` executable.") } + val preparedExecutableSha256 = sha256(preparedExecutable) moveAtomically(preparedExecutable, destination) + writeResolvedVersion(assetChecksum, expectedAssetSha256) + writeResolvedVersion(executableChecksum, preparedExecutableSha256) + writeResolvedVersion(sourceIdentity, expectedSourceIdentity) if (resolvedVersion != null) { writeResolvedVersion(versionFile, resolvedVersion) } @@ -184,14 +269,39 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { /** * Reuses an installed executable while Gradle is offline. */ - private fun reuseOfflineInstallation(destination: Path) { + private fun reuseOfflineInstallation( + destination: Path, + assetChecksum: Path, + executableChecksum: Path, + sourceIdentity: Path, + expectedSourceIdentity: String, + configuredSha256: String?, + ) { if (!Files.isRegularFile(destination)) { throw GradleException( - "Cannot install the latest Embed Code release in offline mode because " + + "Cannot install Embed Code in offline mode because " + "no cached executable exists at `$destination`.", ) } - logger.lifecycle("Reusing cached Embed Code executable from {} in offline mode", destination) + if ( + !isTrustedCachedInstallation( + destination, + assetChecksum, + executableChecksum, + sourceIdentity, + expectedSourceIdentity, + configuredSha256, + ) + ) { + throw GradleException( + "Cannot reuse cached Embed Code executable `$destination` in offline mode " + + "because its SHA-256 integrity metadata is missing or does not match.", + ) + } + logger.lifecycle( + "Reusing verified cached Embed Code executable from {} in offline mode", + destination, + ) } private companion object { @@ -200,11 +310,55 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { const val READ_TIMEOUT_MILLIS = 120_000 const val BUFFER_SIZE = 8_192 + fun selectedVersionName(requestedVersion: String?, resolvedVersion: String?): String = + requestedVersion ?: resolvedVersion ?: "latest release" + + /** + * Checks both the trusted release-asset digest and cached executable contents. + */ + fun isTrustedCachedInstallation( + destination: Path, + assetChecksumFile: Path, + executableChecksumFile: Path, + sourceIdentityFile: Path, + expectedSourceIdentity: String, + configuredSha256: String?, + ): Boolean { + if (!Files.isRegularFile(destination)) { + return false + } + val storedAssetSha256 = readStoredSha256(assetChecksumFile) ?: return false + val storedExecutableSha256 = readStoredSha256(executableChecksumFile) ?: return false + val storedSourceIdentity = readStoredSha256(sourceIdentityFile) ?: return false + if (storedSourceIdentity != expectedSourceIdentity) { + return false + } + if (configuredSha256 != null && configuredSha256 != storedAssetSha256) { + return false + } + return try { + sha256(destination) == storedExecutableSha256 + } catch (_: IOException) { + false + } + } + + /** + * Reads a locally stored SHA-256 digest, ignoring invalid state. + */ + fun readStoredSha256(file: Path): String? { + val value = readResolvedVersion(file) ?: return null + return try { + normalizeSha256(value) + } catch (_: GradleException) { + null + } + } + /** * Returns the tag of the release targeted by the latest-release redirect. * - * Non-HTTP release mirrors cannot expose an HTTP redirect, so they keep - * using the latest asset URL directly. + * Non-HTTP sources have no redirect response, so their tag is unknown. */ fun resolveLatestVersion(baseUrl: String): String? { val source = URI.create("$baseUrl/latest") @@ -215,6 +369,7 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { try { connection.connectTimeout = CONNECT_TIMEOUT_MILLIS connection.readTimeout = READ_TIMEOUT_MILLIS + connection.setRequestProperty("User-Agent", "embed-code-gradle-plugin") connection.instanceFollowRedirects = false connection.requestMethod = "HEAD" val status = connection.responseCode @@ -273,6 +428,7 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { connection = source.toURL().openConnection() connection.connectTimeout = CONNECT_TIMEOUT_MILLIS connection.readTimeout = READ_TIMEOUT_MILLIS + connection.setRequestProperty("User-Agent", "embed-code-gradle-plugin") if (connection is HttpURLConnection) { connection.instanceFollowRedirects = true @@ -298,6 +454,41 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { } } + /** + * Reads UTF-8 text from [source], reporting HTTP failures clearly. + */ + fun readText(source: URI, githubToken: String? = null): String { + var connection: URLConnection? = null + try { + connection = source.toURL().openConnection() + connection.connectTimeout = CONNECT_TIMEOUT_MILLIS + connection.readTimeout = READ_TIMEOUT_MILLIS + connection.setRequestProperty("User-Agent", "embed-code-gradle-plugin") + connection.setRequestProperty("Accept", "application/vnd.github+json") + if (githubToken != null) { + connection.setRequestProperty("Authorization", "Bearer $githubToken") + } + if (connection is HttpURLConnection) { + connection.instanceFollowRedirects = false + val status = connection.responseCode + if (status < 200 || status > 299) { + throw GradleException( + "Could not read checksum metadata: HTTP $status from $source.", + ) + } + } + return connection.getInputStream() + .bufferedReader(StandardCharsets.UTF_8) + .use { reader -> reader.readText() } + } catch (exception: IOException) { + throw GradleException("Could not read checksum metadata from $source.", exception) + } finally { + if (connection is HttpURLConnection) { + connection.disconnect() + } + } + } + /** * Returns the recorded latest release version, if available. */ diff --git a/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/ChecksumSpec.kt b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/ChecksumSpec.kt new file mode 100644 index 0000000..c31ae5f --- /dev/null +++ b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/ChecksumSpec.kt @@ -0,0 +1,197 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * 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://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.embedcode.gradle + +import org.gradle.api.GradleException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import java.net.URI + +@DisplayName("Checksum support should") +internal class ChecksumSpec { + + @Test + fun `normalize a prefixed uppercase digest`() { + val digest = "A".repeat(64) + + assertEquals("a".repeat(64), normalizeSha256("SHA256:$digest")) + } + + @Test + fun `reject non-ASCII digits in a digest`() { + val digest = "٣" + "0".repeat(63) + + val error = assertThrows(GradleException::class.java) { + normalizeSha256(digest) + } + + assertEquals("Invalid SHA-256 digest `$digest`.", error.message) + } + + @Test + fun `distinguish release assets in cache metadata`() { + val baseUrl = "https://github.com/SpineEventEngine/embed-code-go/releases" + + assertNotEquals( + releaseAssetIdentity(baseUrl, "embed-code-macos-x64.zip"), + releaseAssetIdentity(baseUrl, "embed-code-macos-arm64.zip"), + ) + assertNotEquals( + releaseAssetIdentity(baseUrl, "embed-code-linux"), + releaseAssetIdentity("https://releases.example.com/embed-code", "embed-code-linux"), + ) + } + + @Test + fun `derive a GitHub release API URI`() { + assertEquals( + "https://api.github.com/repos/SpineEventEngine/embed-code-go/" + + "releases/tags/v1.2.4", + githubReleaseApi( + "https://github.com/SpineEventEngine/embed-code-go/releases", + "v1.2.4", + ).toString(), + ) + } + + @Test + fun `ignore a non-GitHub release URL`() { + assertNull(githubReleaseApi("https://releases.example.com/embed-code", "v1.2.4")) + assertNull(githubReleaseApi("https:///releases", "v1.2.4")) + } + + @Test + fun `read an asset digest from GitHub JSON`() { + val digest = "2".repeat(64) + val json = + """{"assets":[{"name":"embed-code-linux","digest":"sha256:$digest"}]}""" + + assertEquals(digest, parseGitHubAssetSha256(json, "embed-code-linux")) + } + + @Test + fun `resolve an asset digest from GitHub release metadata`() { + val digest = "3".repeat(64) + val baseUrl = "https://github.com/SpineEventEngine/embed-code-go/releases" + val githubApi = githubReleaseApi(baseUrl, "v1.2.4")!! + val requests = mutableListOf() + + val resolved = resolveExpectedAssetSha256( + null, + baseUrl, + "v1.2.4", + "embed-code-linux", + ) { source -> + requests.add(source) + """{"assets":[{"name":"embed-code-linux","digest":"sha256:$digest"}]}""" + } + + assertEquals(digest, resolved) + assertEquals(listOf(githubApi), requests) + } + + @Test + fun `use a configured checksum without reading metadata`() { + val digest = "4".repeat(64) + + val resolved = resolveExpectedAssetSha256( + digest, + "file:///tmp/embed-code/releases", + null, + "embed-code-linux", + ) { + throw AssertionError("Metadata must not be read for a configured checksum.") + } + + assertEquals(digest, resolved) + } + + @Test + fun `require a configured checksum outside GitHub`() { + val error = assertThrows(GradleException::class.java) { + resolveExpectedAssetSha256( + null, + "file:///tmp/embed-code/releases", + null, + "embed-code-linux", + ) { + throw AssertionError("Metadata must not be read outside GitHub.") + } + } + + assertEquals( + "Automatic SHA-256 resolution is available only for github.com releases. " + + "Configure `embedCode.sha256` for asset `embed-code-linux`.", + error.message, + ) + } + + @Test + fun `report a GitHub metadata failure`() { + val baseUrl = "https://github.com/SpineEventEngine/embed-code-go/releases" + + val error = assertThrows(GradleException::class.java) { + resolveExpectedAssetSha256( + null, + baseUrl, + "v1.2.4", + "embed-code-linux", + ) { + throw GradleException("GitHub metadata is unavailable.") + } + } + + assertEquals( + "Could not resolve a SHA-256 digest for Embed Code asset `embed-code-linux` " + + "from `https://api.github.com/repos/SpineEventEngine/embed-code-go/" + + "releases/tags/v1.2.4`. Configure `embedCode.sha256` explicitly, or " + + "`embedCode.githubToken` if the GitHub API rate limit was exceeded.", + error.message, + ) + assertEquals("GitHub metadata is unavailable.", error.cause?.message) + } + + @Test + fun `reject a missing GitHub asset digest`() { + val error = assertThrows(GradleException::class.java) { + parseGitHubAssetSha256( + """{"assets":[{"name":"embed-code-linux","digest":null}]}""", + "embed-code-linux", + ) + } + + assertEquals( + "GitHub does not provide a SHA-256 digest for release asset `embed-code-linux`. " + + "Configure `embedCode.sha256` explicitly.", + error.message, + ) + } +}