From e8e447b30ab6b613875060037dc4880a6420ff2b Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Tue, 21 Jul 2026 11:52:46 +0200 Subject: [PATCH 1/4] Provide binary checksum verification. --- README.md | 26 ++- .../embedcode/gradle/EmbedCodePluginSpec.kt | 93 +++++++- .../io/spine/embedcode/gradle/Checksum.kt | 119 ++++++++++ .../embedcode/gradle/EmbedCodeExtension.kt | 9 + .../spine/embedcode/gradle/EmbedCodePlugin.kt | 18 +- .../embedcode/gradle/InstallEmbedCodeTask.kt | 211 ++++++++++++++++-- .../io/spine/embedcode/gradle/ChecksumSpec.kt | 93 ++++++++ 7 files changed, 546 insertions(+), 23 deletions(-) create mode 100644 gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/Checksum.kt create mode 100644 gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/ChecksumSpec.kt diff --git a/README.md b/README.md index c672a2e..46e002b 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,28 @@ embedCode { } ``` +Before installing an executable, the plugin verifies the release asset's SHA-256 +digest. For releases hosted in the default GitHub repository, the digest is read +automatically from the GitHub Releases API. A custom release mirror can publish a +companion checksum file next to each asset, for example +`embed-code-linux.sha256`. + +If a custom mirror provides neither GitHub-compatible release metadata nor a +companion checksum file, configure the release asset digest explicitly: + +```kotlin +embedCode { + version.set("1.2.4") + downloadBaseUrl.set("https://releases.example.com/embed-code") + sha256.set("5ee7f23ece8dfd4de293e0fcbd45a8b08a709aaa9a952f4e2d47ed21f1122a6b") +} +``` + +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 still matches 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..e99bcb6 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 @@ -145,7 +145,7 @@ 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) } @@ -218,7 +218,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 +260,72 @@ 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`() { + val platform = EmbedCodePlatform.detect( + System.getProperty("os.name"), + System.getProperty("os.arch"), + ) + Files.writeString( + releaseDirectory.resolve("latest/download/${platform.assetName}.sha256"), + "${"0".repeat(64)} ${platform.assetName}\n", + ) + + 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 a user-provided checksum for a custom mirror`() { + val platform = EmbedCodePlatform.detect( + System.getProperty("os.name"), + System.getProperty("os.arch"), + ) + val asset = releaseDirectory.resolve( + "download/$TEST_RELEASE_TAG/${platform.assetName}", + ) + Files.delete(asset.resolveSibling("${asset.fileName}.sha256")) + writeBuildFile( + version = TEST_RELEASE_VERSION, + sha256 = sha256(asset), + ) + + 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 +422,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 +601,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 checksumConfiguration = sha256?.let { "sha256.set(\"$it\")" }.orEmpty() Files.writeString( projectDirectory.resolve("build.gradle.kts"), """ @@ -549,6 +614,7 @@ internal class EmbedCodePluginSpec { embedCode { $versionConfiguration + $checksumConfiguration downloadBaseUrl.set("$downloadBaseUrl") codePath.set(layout.projectDirectory.dir("code")) docsPath.set(layout.projectDirectory.dir("docs")) @@ -643,11 +709,24 @@ internal class EmbedCodePluginSpec { } else { Files.copy(executable, asset) } + writeChecksumFile(asset) + val latestAsset = latestDirectory.resolve(platform.assetName) Files.copy( asset, - latestDirectory.resolve(platform.assetName), + latestAsset, StandardCopyOption.REPLACE_EXISTING, ) + writeChecksumFile(latestAsset) + } + + /** + * Writes a companion SHA-256 file for [asset]. + */ + private fun writeChecksumFile(asset: Path) { + Files.writeString( + asset.resolveSibling("${asset.fileName}.sha256"), + "${sha256(asset)} ${asset.fileName}\n", + ) } /** @@ -673,8 +752,10 @@ internal class EmbedCodePluginSpec { exchange.close() } server.createContext("/releases/download/") { exchange -> - downloads.incrementAndGet() val relativePath = exchange.requestURI.path.removePrefix("/releases/download/") + if (!relativePath.endsWith(".sha256")) { + 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..f027364 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/Checksum.kt @@ -0,0 +1,119 @@ +/* + * 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()) +} + +/** + * 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.isDigit() && it.lowercaseChar() !in 'a'..'f' } + if (isInvalid) { + throw GradleException("Invalid SHA-256 digest `$value`.") + } + return digest.lowercase() +} + +/** + * Reads the digest from common checksum-file formats. + */ +internal fun parseSha256File(contents: String): String { + val firstLine = contents.lineSequence().firstOrNull { it.isNotBlank() } + ?: throw GradleException("The SHA-256 checksum file is empty.") + return normalizeSha256(firstLine.trim().split(Regex("\\s+"), limit = 2).first()) +} + +/** + * 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)) { + 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`.", + ) + return normalizeSha256(digest) +} 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..94081d8 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,15 @@ 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 GitHub releases and + * mirrors that publish a companion `.sha256` file. Configure this property + * only when a custom release mirror does not expose either mechanism. + */ + public abstract val sha256: Property + /** The root directory containing source files used by embedding instructions. */ public abstract val codePath: DirectoryProperty 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..c327769 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,7 @@ public class EmbedCodePlugin : Plugin { ) { task -> task.description = "Installs the requested Embed Code executable" task.version.set(requestedVersion) + task.sha256.set(extension.sha256) task.downloadBaseUrl.set(extension.downloadBaseUrl) task.operatingSystem.set(operatingSystem) task.architecture.set(architecture) @@ -81,7 +82,22 @@ 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"), + ), + ) + // Always verify cached executable contents before allowing 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..2ebccf9 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 @@ -42,6 +42,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 +52,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 +65,11 @@ 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 + /** The base URL of the Embed Code releases. */ @get:Input public abstract val downloadBaseUrl: Property @@ -88,6 +94,14 @@ 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 + /** * Downloads, extracts when necessary, and marks the executable runnable. */ @@ -97,6 +111,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 +124,15 @@ 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() + if (offline.get()) { + reuseOfflineInstallation( + destination, + assetChecksum, + executableChecksum, + configuredSha256, + ) return } val resolvedVersion = if (requestedVersion == null) { @@ -118,7 +140,14 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { try { resolveLatestVersion(baseUrl) } catch (exception: GradleException) { - if (!Files.isRegularFile(destination)) { + if ( + !isTrustedCachedInstallation( + destination, + assetChecksum, + executableChecksum, + configuredSha256, + ) + ) { throw exception } logger.warn( @@ -136,11 +165,22 @@ 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, + 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 +197,21 @@ 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, + source, + ) + 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 +222,10 @@ 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) if (resolvedVersion != null) { writeResolvedVersion(versionFile, resolvedVersion) } @@ -184,14 +242,35 @@ 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, + 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, + 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,6 +279,76 @@ 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" + + /** + * Resolves the trusted digest for a downloaded release asset. + */ + fun resolveExpectedAssetSha256( + configuredSha256: String?, + baseUrl: String, + releaseTag: String?, + asset: String, + source: URI, + ): String { + if (configuredSha256 != null) { + return configuredSha256 + } + if (releaseTag != null) { + val githubApi = githubReleaseApi(baseUrl, releaseTag) + if (githubApi != null) { + return parseGitHubAssetSha256(readText(githubApi), asset) + } + } + val checksumSource = URI.create("$source.sha256") + return try { + parseSha256File(readText(checksumSource)) + } catch (exception: GradleException) { + throw GradleException( + "Could not resolve a SHA-256 digest for Embed Code asset `$asset`. " + + "Publish `$checksumSource` or configure `embedCode.sha256`.", + exception, + ) + } + } + + /** + * Checks both the trusted release-asset digest and cached executable contents. + */ + fun isTrustedCachedInstallation( + destination: Path, + assetChecksumFile: Path, + executableChecksumFile: Path, + configuredSha256: String?, + ): Boolean { + if (!Files.isRegularFile(destination)) { + return false + } + val storedAssetSha256 = readStoredSha256(assetChecksumFile) ?: return false + val storedExecutableSha256 = readStoredSha256(executableChecksumFile) ?: 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. * @@ -215,6 +364,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 +423,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 +449,38 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { } } + /** + * Reads UTF-8 text from [source], reporting HTTP failures clearly. + */ + fun readText(source: URI): 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 (connection is HttpURLConnection) { + connection.instanceFollowRedirects = true + 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..fcd8001 --- /dev/null +++ b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/ChecksumSpec.kt @@ -0,0 +1,93 @@ +/* + * 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.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@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 `parse a conventional checksum file`() { + val digest = "1".repeat(64) + + assertEquals(digest, parseSha256File("$digest embed-code-linux\n")) + } + + @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")) + } + + @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 `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`.", + error.message, + ) + } +} From 783e51b3f9e154ac462fabab5cd37c470a974f21 Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Tue, 21 Jul 2026 13:48:50 +0200 Subject: [PATCH 2/4] Provide optional GitHub authentication. --- README.md | 20 +++-- .../embedcode/gradle/EmbedCodePluginSpec.kt | 59 +++++++++++++++ .../io/spine/embedcode/gradle/Checksum.kt | 57 ++++++++++++++ .../embedcode/gradle/EmbedCodeExtension.kt | 9 +++ .../spine/embedcode/gradle/EmbedCodePlugin.kt | 8 ++ .../embedcode/gradle/InstallEmbedCodeTask.kt | 75 +++++++++++-------- .../io/spine/embedcode/gradle/ChecksumSpec.kt | 67 +++++++++++++++++ 7 files changed, 255 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 46e002b..108e84f 100644 --- a/README.md +++ b/README.md @@ -100,13 +100,19 @@ embedCode { ``` Before installing an executable, the plugin verifies the release asset's SHA-256 -digest. For releases hosted in the default GitHub repository, the digest is read -automatically from the GitHub Releases API. A custom release mirror can publish a -companion checksum file next to each asset, for example -`embed-code-linux.sha256`. +digest. It first looks for a companion checksum file next to the asset, for +example `embed-code-linux.sha256`. For GitHub releases without that file, the +digest is read from the GitHub Releases API. API requests are unauthenticated +unless a token provider is configured explicitly: -If a custom mirror provides neither GitHub-compatible release metadata nor a -companion checksum file, configure the release asset digest explicitly: +```kotlin +embedCode { + githubToken.set(providers.environmentVariable("EMBED_CODE_GITHUB_TOKEN")) +} +``` + +If a custom mirror provides no companion checksum file, configure the release +asset digest explicitly: ```kotlin embedCode { @@ -119,7 +125,7 @@ embedCode { 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 still matches that metadata. +its current digest, release source, and platform asset still match that metadata. Check that documentation is up to date: 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 e99bcb6..73adb48 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 @@ -151,6 +152,62 @@ internal class EmbedCodePluginSpec { } } + @Test + fun `redownload an explicit version when the release mirror changes`() { + val firstMirror = projectDirectory.resolve("first-releases") + val secondMirror = projectDirectory.resolve("second-releases") + createFakeRelease(firstMirror, marker = "first-mirror") + createFakeRelease(secondMirror, marker = "second-mirror") + writeBuildFile( + version = TEST_RELEASE_VERSION, + downloadBaseUrl = firstMirror.toUri().toString().trimEnd('/'), + ) + runner(":installEmbedCode").build() + + writeBuildFile( + version = TEST_RELEASE_VERSION, + downloadBaseUrl = secondMirror.toUri().toString().trimEnd('/'), + ) + val result = runner(":installEmbedCode").build() + val executableName = EmbedCodePlatform.installedExecutableName( + System.getProperty("os.name"), + ) + val installedExecutable = projectDirectory.resolve( + "build/embed-code/$TEST_RELEASE_VERSION/$executableName", + ) + + result.output shouldContain "Downloading Embed Code $TEST_RELEASE_VERSION" + Files.readString(installedExecutable) shouldContain "release-marker: second-mirror" + } + + @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" @@ -673,6 +730,7 @@ internal class EmbedCodePluginSpec { root: Path, version: String = TEST_RELEASE_VERSION, tag: String = "v$version", + marker: String = version, ) { val platform = EmbedCodePlatform.detect( System.getProperty("os.name"), @@ -687,6 +745,7 @@ internal class EmbedCodePluginSpec { executable, """ #!/bin/sh + # release-marker: $marker : > arguments.txt for argument in "${'$'}@"; do printf '%s\n' "${'$'}argument" >> arguments.txt 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 index f027364..2fad07f 100644 --- a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/Checksum.kt +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/Checksum.kt @@ -54,6 +54,21 @@ internal fun sha256(file: Path): String { 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]. */ @@ -117,3 +132,45 @@ internal fun parseGitHubAssetSha256(json: String, assetName: String): String { ) return normalizeSha256(digest) } + +/** + * Resolves the trusted digest for a downloaded release asset. + * + * Companion checksum files are preferred because they do not consume GitHub + * REST API quota. GitHub release metadata is used as a fallback. + */ +internal fun resolveExpectedAssetSha256( + configuredSha256: String?, + releaseBaseUrl: String, + releaseTag: String?, + assetName: String, + assetSource: URI, + readMetadata: (URI) -> String, +): String { + if (configuredSha256 != null) { + return configuredSha256 + } + val checksumSource = URI.create("$assetSource.sha256") + val checksumFailure = try { + return parseSha256File(readMetadata(checksumSource)) + } catch (exception: GradleException) { + exception + } + val githubApi = releaseTag?.let { githubReleaseApi(releaseBaseUrl, it) } + if (githubApi != null) { + try { + return parseGitHubAssetSha256(readMetadata(githubApi), assetName) + } catch (exception: GradleException) { + throw GradleException( + "Could not resolve a SHA-256 digest for Embed Code asset `$assetName` " + + "from `$checksumSource` or `$githubApi`.", + exception, + ) + } + } + throw GradleException( + "Could not resolve a SHA-256 digest for Embed Code asset `$assetName`. " + + "Publish `$checksumSource` or configure `embedCode.sha256`.", + checksumFailure, + ) +} 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 94081d8..278d0db 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 @@ -57,6 +57,15 @@ public abstract class EmbedCodeExtension { */ 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 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 c327769..11719dd 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 @@ -68,6 +68,7 @@ public class EmbedCodePlugin : Plugin { 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) @@ -96,6 +97,13 @@ public class EmbedCodePlugin : Plugin { }.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"), + ), + ) // Always verify cached executable contents before allowing execution. task.outputs.upToDateWhen { false } } 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 2ebccf9..5528f51 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 @@ -70,6 +71,10 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { @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 @@ -102,6 +107,10 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { @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. */ @@ -126,11 +135,15 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { val versionFile = resolvedVersionFile.get().asFile.toPath() 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 @@ -145,6 +158,8 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { destination, assetChecksum, executableChecksum, + sourceIdentity, + expectedSourceIdentity, configuredSha256, ) ) { @@ -173,6 +188,8 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { destination, assetChecksum, executableChecksum, + sourceIdentity, + expectedSourceIdentity, configuredSha256, ) ) { @@ -203,7 +220,16 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { selectedReleaseTag, asset, source, - ) + ) { 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( @@ -226,6 +252,7 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { moveAtomically(preparedExecutable, destination) writeResolvedVersion(assetChecksum, expectedAssetSha256) writeResolvedVersion(executableChecksum, preparedExecutableSha256) + writeResolvedVersion(sourceIdentity, expectedSourceIdentity) if (resolvedVersion != null) { writeResolvedVersion(versionFile, resolvedVersion) } @@ -246,6 +273,8 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { destination: Path, assetChecksum: Path, executableChecksum: Path, + sourceIdentity: Path, + expectedSourceIdentity: String, configuredSha256: String?, ) { if (!Files.isRegularFile(destination)) { @@ -259,6 +288,8 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { destination, assetChecksum, executableChecksum, + sourceIdentity, + expectedSourceIdentity, configuredSha256, ) ) { @@ -282,37 +313,6 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { fun selectedVersionName(requestedVersion: String?, resolvedVersion: String?): String = requestedVersion ?: resolvedVersion ?: "latest release" - /** - * Resolves the trusted digest for a downloaded release asset. - */ - fun resolveExpectedAssetSha256( - configuredSha256: String?, - baseUrl: String, - releaseTag: String?, - asset: String, - source: URI, - ): String { - if (configuredSha256 != null) { - return configuredSha256 - } - if (releaseTag != null) { - val githubApi = githubReleaseApi(baseUrl, releaseTag) - if (githubApi != null) { - return parseGitHubAssetSha256(readText(githubApi), asset) - } - } - val checksumSource = URI.create("$source.sha256") - return try { - parseSha256File(readText(checksumSource)) - } catch (exception: GradleException) { - throw GradleException( - "Could not resolve a SHA-256 digest for Embed Code asset `$asset`. " + - "Publish `$checksumSource` or configure `embedCode.sha256`.", - exception, - ) - } - } - /** * Checks both the trusted release-asset digest and cached executable contents. */ @@ -320,6 +320,8 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { destination: Path, assetChecksumFile: Path, executableChecksumFile: Path, + sourceIdentityFile: Path, + expectedSourceIdentity: String, configuredSha256: String?, ): Boolean { if (!Files.isRegularFile(destination)) { @@ -327,6 +329,10 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { } 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 } @@ -452,7 +458,7 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { /** * Reads UTF-8 text from [source], reporting HTTP failures clearly. */ - fun readText(source: URI): String { + fun readText(source: URI, githubToken: String? = null): String { var connection: URLConnection? = null try { connection = source.toURL().openConnection() @@ -460,6 +466,9 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { 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 = true val status = connection.responseCode 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 index fcd8001..5b53846 100644 --- a/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/ChecksumSpec.kt +++ b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/ChecksumSpec.kt @@ -28,10 +28,12 @@ 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 { @@ -50,6 +52,20 @@ internal class ChecksumSpec { assertEquals(digest, parseSha256File("$digest embed-code-linux\n")) } + @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( @@ -76,6 +92,57 @@ internal class ChecksumSpec { assertEquals(digest, parseGitHubAssetSha256(json, "embed-code-linux")) } + @Test + fun `prefer a companion checksum over GitHub release metadata`() { + val digest = "3".repeat(64) + val assetSource = URI.create( + "https://github.com/SpineEventEngine/embed-code-go/" + + "releases/download/v1.2.4/embed-code-linux", + ) + val requests = mutableListOf() + + val resolved = resolveExpectedAssetSha256( + null, + "https://github.com/SpineEventEngine/embed-code-go/releases", + "v1.2.4", + "embed-code-linux", + assetSource, + ) { source -> + requests.add(source) + "$digest embed-code-linux\n" + } + + assertEquals(digest, resolved) + assertEquals(listOf(URI.create("$assetSource.sha256")), requests) + } + + @Test + fun `fall back to GitHub release metadata when a companion checksum is absent`() { + val digest = "4".repeat(64) + val baseUrl = "https://github.com/SpineEventEngine/embed-code-go/releases" + val assetSource = URI.create("$baseUrl/download/v1.2.4/embed-code-linux") + val githubApi = githubReleaseApi(baseUrl, "v1.2.4")!! + val requests = mutableListOf() + + val resolved = resolveExpectedAssetSha256( + null, + baseUrl, + "v1.2.4", + "embed-code-linux", + assetSource, + ) { source -> + requests.add(source) + if (source == githubApi) { + """{"assets":[{"name":"embed-code-linux","digest":"sha256:$digest"}]}""" + } else { + throw GradleException("Checksum asset is absent.") + } + } + + assertEquals(digest, resolved) + assertEquals(listOf(URI.create("$assetSource.sha256"), githubApi), requests) + } + @Test fun `reject a missing GitHub asset digest`() { val error = assertThrows(GradleException::class.java) { From bbe54b1af600f042e69740591de4075928cb0f4f Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Tue, 21 Jul 2026 14:57:53 +0200 Subject: [PATCH 3/4] Remove non-GitHub releases feature. --- README.md | 17 +--- .../embedcode/gradle/EmbedCodePluginSpec.kt | 97 +++++++------------ .../io/spine/embedcode/gradle/Checksum.kt | 47 +++------ .../embedcode/gradle/EmbedCodeExtension.kt | 8 +- .../spine/embedcode/gradle/EmbedCodePlugin.kt | 2 +- .../embedcode/gradle/InstallEmbedCodeTask.kt | 7 +- .../io/spine/embedcode/gradle/ChecksumSpec.kt | 81 ++++++++++------ 7 files changed, 108 insertions(+), 151 deletions(-) diff --git a/README.md b/README.md index 108e84f..bdc19db 100644 --- a/README.md +++ b/README.md @@ -100,10 +100,8 @@ embedCode { ``` Before installing an executable, the plugin verifies the release asset's SHA-256 -digest. It first looks for a companion checksum file next to the asset, for -example `embed-code-linux.sha256`. For GitHub releases without that file, the -digest is read from the GitHub Releases API. API requests are unauthenticated -unless a token provider is configured explicitly: +digest from the GitHub Releases API. API requests are unauthenticated unless a +token provider is configured explicitly: ```kotlin embedCode { @@ -111,17 +109,6 @@ embedCode { } ``` -If a custom mirror provides no companion checksum file, configure the release -asset digest explicitly: - -```kotlin -embedCode { - version.set("1.2.4") - downloadBaseUrl.set("https://releases.example.com/embed-code") - sha256.set("5ee7f23ece8dfd4de293e0fcbd45a8b08a709aaa9a952f4e2d47ed21f1122a6b") -} -``` - 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 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 73adb48..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 @@ -152,34 +152,6 @@ internal class EmbedCodePluginSpec { } } - @Test - fun `redownload an explicit version when the release mirror changes`() { - val firstMirror = projectDirectory.resolve("first-releases") - val secondMirror = projectDirectory.resolve("second-releases") - createFakeRelease(firstMirror, marker = "first-mirror") - createFakeRelease(secondMirror, marker = "second-mirror") - writeBuildFile( - version = TEST_RELEASE_VERSION, - downloadBaseUrl = firstMirror.toUri().toString().trimEnd('/'), - ) - runner(":installEmbedCode").build() - - writeBuildFile( - version = TEST_RELEASE_VERSION, - downloadBaseUrl = secondMirror.toUri().toString().trimEnd('/'), - ) - val result = runner(":installEmbedCode").build() - val executableName = EmbedCodePlatform.installedExecutableName( - System.getProperty("os.name"), - ) - val installedExecutable = projectDirectory.resolve( - "build/embed-code/$TEST_RELEASE_VERSION/$executableName", - ) - - result.output shouldContain "Downloading Embed Code $TEST_RELEASE_VERSION" - Files.readString(installedExecutable) shouldContain "release-marker: second-mirror" - } - @Test fun `ignore an ambient GitHub token unless explicitly configured`() { Files.writeString( @@ -241,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 @@ -333,14 +312,7 @@ internal class EmbedCodePluginSpec { @Test fun `reject a release asset whose SHA-256 digest does not match`() { - val platform = EmbedCodePlatform.detect( - System.getProperty("os.name"), - System.getProperty("os.arch"), - ) - Files.writeString( - releaseDirectory.resolve("latest/download/${platform.assetName}.sha256"), - "${"0".repeat(64)} ${platform.assetName}\n", - ) + writeBuildFile(sha256 = "0".repeat(64)) val result = runner(":installEmbedCode").buildAndFail() @@ -364,18 +336,10 @@ internal class EmbedCodePluginSpec { } @Test - fun `accept a user-provided checksum for a custom mirror`() { - val platform = EmbedCodePlatform.detect( - System.getProperty("os.name"), - System.getProperty("os.arch"), - ) - val asset = releaseDirectory.resolve( - "download/$TEST_RELEASE_TAG/${platform.assetName}", - ) - Files.delete(asset.resolveSibling("${asset.fileName}.sha256")) + fun `accept an explicitly pinned release asset`() { writeBuildFile( version = TEST_RELEASE_VERSION, - sha256 = sha256(asset), + sha256 = releaseAssetSha256(version = TEST_RELEASE_VERSION), ) val result = runner(":installEmbedCode").build() @@ -661,7 +625,7 @@ internal class EmbedCodePluginSpec { sha256: String? = null, ) { val versionConfiguration = version?.let { "version.set(\"$it\")" }.orEmpty() - val checksumConfiguration = sha256?.let { "sha256.set(\"$it\")" }.orEmpty() + val configuredSha256 = sha256 ?: releaseAssetSha256(version = version) Files.writeString( projectDirectory.resolve("build.gradle.kts"), """ @@ -671,7 +635,7 @@ internal class EmbedCodePluginSpec { embedCode { $versionConfiguration - $checksumConfiguration + sha256.set("$configuredSha256") downloadBaseUrl.set("$downloadBaseUrl") codePath.set(layout.projectDirectory.dir("code")) docsPath.set(layout.projectDirectory.dir("docs")) @@ -714,6 +678,7 @@ internal class EmbedCodePluginSpec { embedCode { downloadBaseUrl.set("$baseUrl") + sha256.set("${releaseAssetSha256()}") $directSource namedSource("$firstSourceName", layout.projectDirectory.dir("company-site")) $secondSource @@ -730,7 +695,6 @@ internal class EmbedCodePluginSpec { root: Path, version: String = TEST_RELEASE_VERSION, tag: String = "v$version", - marker: String = version, ) { val platform = EmbedCodePlatform.detect( System.getProperty("os.name"), @@ -745,7 +709,7 @@ internal class EmbedCodePluginSpec { executable, """ #!/bin/sh - # release-marker: $marker + # release-marker: $version : > arguments.txt for argument in "${'$'}@"; do printf '%s\n' "${'$'}argument" >> arguments.txt @@ -768,24 +732,37 @@ internal class EmbedCodePluginSpec { } else { Files.copy(executable, asset) } - writeChecksumFile(asset) val latestAsset = latestDirectory.resolve(platform.assetName) Files.copy( asset, latestAsset, StandardCopyOption.REPLACE_EXISTING, ) - writeChecksumFile(latestAsset) } /** - * Writes a companion SHA-256 file for [asset]. + * Returns the digest of a fake release asset. */ - private fun writeChecksumFile(asset: Path) { - Files.writeString( - asset.resolveSibling("${asset.fileName}.sha256"), - "${sha256(asset)} ${asset.fileName}\n", + 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) } /** @@ -812,9 +789,7 @@ internal class EmbedCodePluginSpec { } server.createContext("/releases/download/") { exchange -> val relativePath = exchange.requestURI.path.removePrefix("/releases/download/") - if (!relativePath.endsWith(".sha256")) { - downloads.incrementAndGet() - } + 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 index 2fad07f..ca3420d 100644 --- a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/Checksum.kt +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/Checksum.kt @@ -87,15 +87,6 @@ internal fun normalizeSha256(value: String): String { return digest.lowercase() } -/** - * Reads the digest from common checksum-file formats. - */ -internal fun parseSha256File(contents: String): String { - val firstLine = contents.lineSequence().firstOrNull { it.isNotBlank() } - ?: throw GradleException("The SHA-256 checksum file is empty.") - return normalizeSha256(firstLine.trim().split(Regex("\\s+"), limit = 2).first()) -} - /** * Returns the GitHub release API URI represented by [releaseBaseUrl]. */ @@ -136,41 +127,31 @@ internal fun parseGitHubAssetSha256(json: String, assetName: String): String { /** * Resolves the trusted digest for a downloaded release asset. * - * Companion checksum files are preferred because they do not consume GitHub - * REST API quota. GitHub release metadata is used as a fallback. + * 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, - assetSource: URI, readMetadata: (URI) -> String, ): String { if (configuredSha256 != null) { return configuredSha256 } - val checksumSource = URI.create("$assetSource.sha256") - val checksumFailure = try { - return parseSha256File(readMetadata(checksumSource)) - } catch (exception: GradleException) { - exception - } val githubApi = releaseTag?.let { githubReleaseApi(releaseBaseUrl, it) } - if (githubApi != null) { - try { - return parseGitHubAssetSha256(readMetadata(githubApi), assetName) - } catch (exception: GradleException) { - throw GradleException( - "Could not resolve a SHA-256 digest for Embed Code asset `$assetName` " + - "from `$checksumSource` or `$githubApi`.", - exception, - ) - } + ?: 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`.", + exception, + ) } - throw GradleException( - "Could not resolve a SHA-256 digest for Embed Code asset `$assetName`. " + - "Publish `$checksumSource` or configure `embedCode.sha256`.", - checksumFailure, - ) } 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 278d0db..fa25030 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 @@ -51,9 +51,8 @@ public abstract class EmbedCodeExtension { /** * An optional SHA-256 digest of the configured release asset. * - * The plugin resolves this digest automatically for GitHub releases and - * mirrors that publish a companion `.sha256` file. Configure this property - * only when a custom release mirror does not expose either mechanism. + * The plugin resolves this digest automatically for releases hosted on + * `github.com`. Configure this property to pin a digest explicitly. */ public abstract val sha256: Property @@ -125,8 +124,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 11719dd..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 @@ -104,7 +104,7 @@ public class EmbedCodePlugin : Plugin { }.orElse("embed-code/latest/source.sha256"), ), ) - // Always verify cached executable contents before allowing execution. + // Intentionally rerun and re-hash the cached executable before every execution. task.outputs.upToDateWhen { false } } 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 5528f51..9cfe4b0 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 @@ -182,7 +182,8 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { if ( ( requestedVersion != null || - resolvedVersion != null && readResolvedVersion(versionFile) == resolvedVersion + resolvedVersion != null && + readResolvedVersion(versionFile) == resolvedVersion ) && isTrustedCachedInstallation( destination, @@ -219,7 +220,6 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { baseUrl, selectedReleaseTag, asset, - source, ) { metadataSource -> val token = if ( metadataSource.host.equals("api.github.com", ignoreCase = true) @@ -358,8 +358,7 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { /** * 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") 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 index 5b53846..8879800 100644 --- a/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/ChecksumSpec.kt +++ b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/ChecksumSpec.kt @@ -45,13 +45,6 @@ internal class ChecksumSpec { assertEquals("a".repeat(64), normalizeSha256("SHA256:$digest")) } - @Test - fun `parse a conventional checksum file`() { - val digest = "1".repeat(64) - - assertEquals(digest, parseSha256File("$digest embed-code-linux\n")) - } - @Test fun `distinguish release assets in cache metadata`() { val baseUrl = "https://github.com/SpineEventEngine/embed-code-go/releases" @@ -93,54 +86,78 @@ internal class ChecksumSpec { } @Test - fun `prefer a companion checksum over GitHub release metadata`() { + fun `resolve an asset digest from GitHub release metadata`() { val digest = "3".repeat(64) - val assetSource = URI.create( - "https://github.com/SpineEventEngine/embed-code-go/" + - "releases/download/v1.2.4/embed-code-linux", - ) + val baseUrl = "https://github.com/SpineEventEngine/embed-code-go/releases" + val githubApi = githubReleaseApi(baseUrl, "v1.2.4")!! val requests = mutableListOf() val resolved = resolveExpectedAssetSha256( null, - "https://github.com/SpineEventEngine/embed-code-go/releases", + baseUrl, "v1.2.4", "embed-code-linux", - assetSource, ) { source -> requests.add(source) - "$digest embed-code-linux\n" + """{"assets":[{"name":"embed-code-linux","digest":"sha256:$digest"}]}""" } assertEquals(digest, resolved) - assertEquals(listOf(URI.create("$assetSource.sha256")), requests) + assertEquals(listOf(githubApi), requests) } @Test - fun `fall back to GitHub release metadata when a companion checksum is absent`() { + fun `use a configured checksum without reading metadata`() { val digest = "4".repeat(64) - val baseUrl = "https://github.com/SpineEventEngine/embed-code-go/releases" - val assetSource = URI.create("$baseUrl/download/v1.2.4/embed-code-linux") - val githubApi = githubReleaseApi(baseUrl, "v1.2.4")!! - val requests = mutableListOf() val resolved = resolveExpectedAssetSha256( + digest, + "file:///tmp/embed-code/releases", null, - baseUrl, - "v1.2.4", "embed-code-linux", - assetSource, - ) { source -> - requests.add(source) - if (source == githubApi) { - """{"assets":[{"name":"embed-code-linux","digest":"sha256:$digest"}]}""" - } else { - throw GradleException("Checksum asset is absent.") - } + ) { + throw AssertionError("Metadata must not be read for a configured checksum.") } assertEquals(digest, resolved) - assertEquals(listOf(URI.create("$assetSource.sha256"), githubApi), requests) + } + + @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("GitHub metadata is unavailable.", error.cause?.message) } @Test From 29dfaac7190be81fdbbc65eb16b4456a5108f108 Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Tue, 21 Jul 2026 17:48:36 +0200 Subject: [PATCH 4/4] Address integrity review comments --- README.md | 9 ++++++-- .../io/spine/embedcode/gradle/Checksum.kt | 13 +++++++---- .../embedcode/gradle/EmbedCodeExtension.kt | 3 ++- .../embedcode/gradle/InstallEmbedCodeTask.kt | 2 +- .../io/spine/embedcode/gradle/ChecksumSpec.kt | 22 ++++++++++++++++++- 5 files changed, 40 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index bdc19db..cc13208 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,9 @@ embedCode { ``` Before installing an executable, the plugin verifies the release asset's SHA-256 -digest from the GitHub Releases API. API requests are unauthenticated unless a -token provider is configured explicitly: +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 { @@ -109,6 +110,10 @@ embedCode { } ``` +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 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 index ca3420d..1be298d 100644 --- a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/Checksum.kt +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/Checksum.kt @@ -80,7 +80,7 @@ internal fun normalizeSha256(value: String): String { trimmed } val isInvalid = digest.length != SHA256_LENGTH || - digest.any { !it.isDigit() && it.lowercaseChar() !in 'a'..'f' } + digest.any { it !in '0'..'9' && it.lowercaseChar() !in 'a'..'f' } if (isInvalid) { throw GradleException("Invalid SHA-256 digest `$value`.") } @@ -92,7 +92,10 @@ internal fun normalizeSha256(value: String): String { */ internal fun githubReleaseApi(releaseBaseUrl: String, releaseTag: String): URI? { val base = URI.create(releaseBaseUrl) - if (base.scheme != "https" || !base.host.equals("github.com", ignoreCase = true)) { + if ( + base.scheme != "https" || + base.host?.equals("github.com", ignoreCase = true) != true + ) { return null } val segments = base.path.trim('/').split('/') @@ -119,7 +122,8 @@ internal fun parseGitHubAssetSha256(json: String, assetName: String): String { ?: 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`.", + "GitHub does not provide a SHA-256 digest for release asset `$assetName`. " + + "Configure `embedCode.sha256` explicitly.", ) return normalizeSha256(digest) } @@ -150,7 +154,8 @@ internal fun resolveExpectedAssetSha256( } catch (exception: GradleException) { throw GradleException( "Could not resolve a SHA-256 digest for Embed Code asset `$assetName` " + - "from `$githubApi`.", + "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 fa25030..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 @@ -52,7 +52,8 @@ public abstract class EmbedCodeExtension { * 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 to pin a digest explicitly. + * `github.com`. Configure this property together with [version] to pin a + * release asset explicitly. */ public abstract val sha256: Property 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 9cfe4b0..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 @@ -469,7 +469,7 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { connection.setRequestProperty("Authorization", "Bearer $githubToken") } if (connection is HttpURLConnection) { - connection.instanceFollowRedirects = true + connection.instanceFollowRedirects = false val status = connection.responseCode if (status < 200 || status > 299) { throw GradleException( 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 index 8879800..c31ae5f 100644 --- a/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/ChecksumSpec.kt +++ b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/ChecksumSpec.kt @@ -45,6 +45,17 @@ internal class ChecksumSpec { 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" @@ -74,6 +85,7 @@ internal class ChecksumSpec { @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 @@ -157,6 +169,13 @@ internal class ChecksumSpec { } } + 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) } @@ -170,7 +189,8 @@ internal class ChecksumSpec { } assertEquals( - "GitHub does not provide a SHA-256 digest for release asset `embed-code-linux`.", + "GitHub does not provide a SHA-256 digest for release asset `embed-code-linux`. " + + "Configure `embedCode.sha256` explicitly.", error.message, ) }