From dc77850a3834b668077122fc595a9c58e0aa135e Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Wed, 22 Jul 2026 15:35:16 +0200 Subject: [PATCH 1/7] Harden cached release asset handling. --- README.md | 26 +- .../embedcode/gradle/EmbedCodePluginSpec.kt | 296 +++++++++- .../io/spine/embedcode/gradle/Checksum.kt | 19 +- .../embedcode/gradle/EmbedCodeExtension.kt | 3 +- .../spine/embedcode/gradle/EmbedCodePlugin.kt | 18 +- .../embedcode/gradle/EmbedCodeVersion.kt | 9 +- .../embedcode/gradle/InstallEmbedCodeTask.kt | 544 ++++++++++++++---- .../io/spine/embedcode/gradle/ChecksumSpec.kt | 16 +- .../embedcode/gradle/EmbedCodeVersionSpec.kt | 19 +- 9 files changed, 782 insertions(+), 168 deletions(-) diff --git a/README.md b/README.md index c31bbc3..028b9a3 100644 --- a/README.md +++ b/README.md @@ -87,12 +87,12 @@ Embedding instructions refer to these roots with `$model/` and By default, the plugin checks the latest Embed Code release before running a task. It reuses the executable in `build/embed-code/latest` while the release -tag remains unchanged and downloads a new executable only after a new -release is published. When the release check fails, for example without -network access, the plugin reuses the previously installed executable. +tag remains unchanged and downloads a new release asset only after a new +release is published. If the latest-release check fails, the plugin reuses the +cached asset only when `sha256` supplies an independent trust anchor. -To use a specific Embed Code application release, set `version` to its exact release tag. -The plugin uses this value verbatim and does not add a `v` prefix. +To use a specific Embed Code application release, add its exact release tag to +the extension: ```kotlin embedCode { @@ -100,9 +100,7 @@ embedCode { } ``` -Omit `version`, or set it to an empty string, to use the latest release. -Setting `version` to `"latest"` targets a release tag literally named `latest`; -it is not an alias for the latest release. +The tag is used verbatim. In particular, the plugin does not add a `v` prefix. 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 @@ -120,9 +118,15 @@ or pin both `version` and `sha256`. Pairing the digest with a fixed version keep 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. +ZIP archive rather than the extracted executable. The verified release asset is +retained under `build/embed-code`, and the executable is recreated from it on +every reuse. Local checksum sidecars are diagnostic only and are not trusted to +authorize executable contents. + +Offline reuse requires `sha256` because a local cache cannot authenticate its +own metadata. Explicit release tags use SHA-256-derived cache directory names, +which preserve case-sensitive tag identity on Windows and case-insensitive file +systems. 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 d40b0de..92b8025 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 @@ -276,6 +276,34 @@ internal class EmbedCodePluginSpec { } } + @Test + fun `require a configured digest when the latest release check fails`() { + val latestStatus = AtomicInteger(HTTP_MOVED_TEMP) + val downloads = AtomicInteger() + val server = startReleaseServer( + downloads = downloads, + latestStatus = latestStatus, + ) + try { + writeBuildFile(downloadBaseUrl = server.releaseBaseUrl) + runner(":installEmbedCode").build() + + writeBuildFile( + downloadBaseUrl = server.releaseBaseUrl, + configureSha256 = false, + ) + latestStatus.set(HTTP_UNAVAILABLE) + val result = runner(":installEmbedCode").buildAndFail() + + result.output shouldContain + "The cached asset cannot be authenticated without a configured digest." + result.output shouldContain "Configure `embedCode.sha256`" + downloads.get() shouldBe 1 + } finally { + server.stop(0) + } + } + @Test fun `report a failed latest release check without a cached executable`() { val server = startReleaseServer( @@ -297,9 +325,7 @@ internal class EmbedCodePluginSpec { fun `report a missing latest executable in offline mode`() { val result = runner(":installEmbedCode", "--offline").buildAndFail() - result.output shouldContain - "Cannot install Embed Code in offline mode because " + - "no cached executable exists" + result.output shouldContain "Cannot reuse cached Embed Code asset" } @Test @@ -322,19 +348,128 @@ internal class EmbedCodePluginSpec { } @Test - fun `reject a modified cached executable in offline mode`() { + fun `restore a modified cached executable without trusting sidecar digests`() { runner(":installEmbedCode").build() val executableName = EmbedCodePlatform.installedExecutableName( System.getProperty("os.name"), ) - Files.writeString( - projectDirectory.resolve("build/embed-code/latest/$executableName"), - "modified after verification", + val installation = projectDirectory.resolve("build/embed-code/latest") + val executable = installation.resolve(executableName) + val verifiedExecutable = Files.readAllBytes(executable) + Files.writeString(executable, "modified after verification") + Files.writeString(installation.resolve("asset.sha256"), "1".repeat(64)) + Files.writeString(installation.resolve("executable.sha256"), "2".repeat(64)) + Files.writeString(installation.resolve("source.sha256"), "3".repeat(64)) + + val result = runner(":installEmbedCode", "--offline").build() + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + Files.readAllBytes(executable).contentEquals(verifiedExecutable) shouldBe true + result.output shouldContain "Reusing verified cached Embed Code executable" + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `restore execute permission from the verified cached asset`() { + runner(":installEmbedCode").build() + val executable = installedExecutable() + executable.toFile().setExecutable(false, false) shouldBe true + + val result = runner(":installEmbedCode", "--offline").build() + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + Files.isExecutable(executable) shouldBe true + } + + @Test + fun `remove staged release assets after cache reuse`() { + runner(":installEmbedCode").build() + runner(":installEmbedCode").build() + val taskTemporaryDirectory = projectDirectory.resolve("build/tmp/installEmbedCode") + + val stagedFiles = if (Files.isDirectory(taskTemporaryDirectory)) { + Files.list(taskTemporaryDirectory).use { paths -> + paths.map { path -> path.fileName.toString() } + .filter { name -> + name.startsWith("downloaded-asset-") || + name.startsWith("cached-asset-") || + name.startsWith("prepared-executable-") + } + .toList() + } + } else { + emptyList() + } + + stagedFiles shouldBe emptyList() + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `do not follow the former predictable download path`() { + val outsideFile = projectDirectory.resolve("outside-download") + Files.writeString(outsideFile, "unchanged") + val platform = EmbedCodePlatform.detect( + System.getProperty("os.name"), + System.getProperty("os.arch"), + ) + val taskTemporaryDirectory = projectDirectory.resolve("build/tmp/installEmbedCode") + Files.createDirectories(taskTemporaryDirectory) + Files.createSymbolicLink( + taskTemporaryDirectory.resolve(platform.assetName), + outsideFile, ) + val result = runner(":installEmbedCode").build() + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + Files.readString(outsideFile) shouldBe "unchanged" + } + + @Test + fun `reject a tampered cached asset offline even when its sidecar is rewritten`() { + runner(":installEmbedCode").build() + val installation = installationDirectory() + val cachedAsset = installation.resolve("release-asset") + Files.writeString(cachedAsset, "untrusted replacement") + Files.writeString(installation.resolve("asset.sha256"), sha256(cachedAsset)) + + val result = runner(":installEmbedCode", "--offline").buildAndFail() + + result.output shouldContain "does not match `embedCode.sha256`" + } + + @Test + fun `redownload a tampered cached asset while online`() { + val downloads = AtomicInteger() + val server = startReleaseServer(downloads = downloads) + try { + writeBuildFile(downloadBaseUrl = server.releaseBaseUrl) + runner(":installEmbedCode").build() + Files.writeString( + installationDirectory().resolve("release-asset"), + "untrusted replacement", + ) + + val result = runner(":installEmbedCode").build() + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + downloads.get() shouldBe 2 + } finally { + server.stop(0) + } + } + + @Test + fun `require a configured digest for offline cache reuse`() { + runner(":installEmbedCode").build() + writeBuildFile(configureSha256 = false) + val result = runner(":installEmbedCode", "--offline").buildAndFail() - result.output shouldContain "SHA-256 integrity metadata is missing or does not match" + result.output shouldContain + "Cannot securely reuse Embed Code in offline mode without a trusted digest." + result.output shouldContain "Configure `embedCode.sha256`" } @Test @@ -364,7 +499,7 @@ internal class EmbedCodePluginSpec { ) Files.exists( projectDirectory.resolve( - "build/embed-code/versions/$overrideTag/$executableName", + "build/embed-code/versions/${releaseTagCacheKey(overrideTag)}/$executableName", ), ) shouldBe true } @@ -384,6 +519,44 @@ internal class EmbedCodePluginSpec { ) shouldBe true } + @Test + fun `store exact release tags under portable cache keys`() { + writeBuildFile(version = TEST_RELEASE_TAG) + + val result = runner(":installEmbedCode").build() + val cacheKey = releaseTagCacheKey(TEST_RELEASE_TAG) + val installation = installationDirectory(TEST_RELEASE_TAG) + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + cacheKey.length shouldBe 64 + cacheKey.all { it in '0'..'9' || it in 'a'..'f' } shouldBe true + Files.readString(installation.resolve("version.txt")).trim() shouldBe TEST_RELEASE_TAG + Files.exists(installedExecutable(TEST_RELEASE_TAG)) shouldBe true + } + + @Test + fun `keep case-distinct release tags in separate cache directories`() { + val lowerTag = "vcase-test" + val upperTag = "VCASE-test" + createFakeRelease(releaseDirectory, version = "lower", tag = lowerTag) + createFakeRelease(releaseDirectory, version = "upper", tag = upperTag) + + writeBuildFile(version = lowerTag) + runner(":installEmbedCode").build() + // Change the file length as well as its case so Gradle cannot reuse a timestamp/size + // file-system snapshot for the rewritten build script. + writeBuildFile(version = " $upperTag ") + runner(":installEmbedCode").build() + + (releaseTagCacheKey(lowerTag) == releaseTagCacheKey(upperTag)) shouldBe false + Files.readString( + installationDirectory(lowerTag).resolve("version.txt"), + ).trim() shouldBe lowerTag + Files.readString( + installationDirectory(upperTag).resolve("version.txt"), + ).trim() shouldBe upperTag + } + @Test fun `keep an explicit latest tag separate from the rolling latest cache`() { runner(":installEmbedCode").build() @@ -396,7 +569,7 @@ internal class EmbedCodePluginSpec { ) val rollingLatest = projectDirectory.resolve("build/embed-code/latest/$executableName") val pinnedLatest = projectDirectory.resolve( - "build/embed-code/versions/latest/$executableName", + "build/embed-code/versions/${releaseTagCacheKey("latest")}/$executableName", ) result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS @@ -459,7 +632,7 @@ internal class EmbedCodePluginSpec { result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS Files.exists( projectDirectory.resolve( - "build/embed-code/versions/$TEST_RELEASE_TAG/$executableName", + "build/embed-code/versions/${releaseTagCacheKey(TEST_RELEASE_TAG)}/$executableName", ), ) shouldBe true } @@ -504,6 +677,75 @@ internal class EmbedCodePluginSpec { Files.exists(projectDirectory.resolve("escaped/asset.sha256")) shouldBe false } + @Test + fun `reject a cached asset path outside the installation directory`() { + Files.writeString( + projectDirectory.resolve("build.gradle.kts"), + """ + + tasks.named("installEmbedCode") { + cachedAssetFile.set(layout.projectDirectory.file("escaped/release-asset")) + } + """.trimIndent(), + StandardOpenOption.APPEND, + ) + + val result = runner(":installEmbedCode").buildAndFail() + + result.output shouldContain "must remain inside" + Files.exists(projectDirectory.resolve("escaped/release-asset")) shouldBe false + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `reject a symbolic-link installation root`() { + val outside = projectDirectory.resolve("outside-cache") + Files.createDirectories(outside) + val sentinel = outside.resolve("sentinel.txt") + Files.writeString(sentinel, "unchanged") + Files.createDirectories(projectDirectory.resolve("build")) + Files.createSymbolicLink(projectDirectory.resolve("build/embed-code"), outside) + + val result = runner(":installEmbedCode").buildAndFail() + + result.output shouldContain "must be a real directory, not a symbolic link" + Files.readString(sentinel) shouldBe "unchanged" + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `reject a symbolic-link explicit cache directory`() { + val outside = projectDirectory.resolve("outside-cache") + Files.createDirectories(outside) + val sentinel = outside.resolve("sentinel.txt") + Files.writeString(sentinel, "unchanged") + val versions = projectDirectory.resolve("build/embed-code/versions") + Files.createDirectories(versions) + Files.createSymbolicLink(versions.resolve(releaseTagCacheKey(TEST_RELEASE_TAG)), outside) + writeBuildFile(version = TEST_RELEASE_TAG) + + val result = runner(":installEmbedCode").buildAndFail() + + result.output shouldContain "must not be a symbolic link" + Files.readString(sentinel) shouldBe "unchanged" + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + fun `reject a symbolic-link cached asset`() { + runner(":installEmbedCode").build() + val outsideAsset = projectDirectory.resolve("outside-asset") + Files.writeString(outsideAsset, "unchanged") + val cachedAsset = installationDirectory().resolve("release-asset") + Files.delete(cachedAsset) + Files.createSymbolicLink(cachedAsset, outsideAsset) + + val result = runner(":installEmbedCode", "--offline").buildAndFail() + + result.output shouldContain "must not be a symbolic link" + Files.readString(outsideAsset) shouldBe "unchanged" + } + @Test fun `defer unsupported platform failure until installation`() { Files.writeString( @@ -769,9 +1011,15 @@ internal class EmbedCodePluginSpec { version: String? = null, downloadBaseUrl: String = releaseDirectory.toUri().toString().trimEnd('/'), sha256: String? = null, + configureSha256: Boolean = true, ) { val versionConfiguration = version?.let { "version.set(\"$it\")" }.orEmpty() - val configuredSha256 = sha256 ?: releaseAssetSha256(tag = version) + val checksumConfiguration = if (configureSha256) { + val configuredSha256 = sha256 ?: releaseAssetSha256(tag = version) + "sha256.set(\"$configuredSha256\")" + } else { + "" + } Files.writeString( projectDirectory.resolve("build.gradle.kts"), """ @@ -781,7 +1029,7 @@ internal class EmbedCodePluginSpec { embedCode { $versionConfiguration - sha256.set("$configuredSha256") + $checksumConfiguration downloadBaseUrl.set("$downloadBaseUrl") codePath.set(layout.projectDirectory.dir("code")) docsPath.set(layout.projectDirectory.dir("docs")) @@ -795,6 +1043,28 @@ internal class EmbedCodePluginSpec { ) } + /** + * Returns the cache directory for the rolling latest release or an exact [releaseTag]. + */ + private fun installationDirectory(releaseTag: String? = null): Path { + val relativePath = if (releaseTag == null) { + "build/embed-code/latest" + } else { + "build/embed-code/versions/${releaseTagCacheKey(releaseTag)}" + } + return projectDirectory.resolve(relativePath) + } + + /** + * Returns the installed executable for the rolling latest release or [releaseTag]. + */ + private fun installedExecutable(releaseTag: String? = null): Path { + val executableName = EmbedCodePlatform.installedExecutableName( + System.getProperty("os.name"), + ) + return installationDirectory(releaseTag).resolve(executableName) + } + /** * Writes a consuming build with two named source roots and no YAML file. */ 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 1be298d..a7e766f 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 @@ -30,9 +30,12 @@ import groovy.json.JsonSlurper import org.gradle.api.GradleException import java.net.URI import java.net.URLEncoder +import java.nio.channels.Channels import java.nio.charset.StandardCharsets import java.nio.file.Files +import java.nio.file.LinkOption import java.nio.file.Path +import java.nio.file.StandardOpenOption import java.security.MessageDigest import java.util.HexFormat @@ -43,7 +46,12 @@ private const val SHA256_LENGTH = 64 */ internal fun sha256(file: Path): String { val digest = MessageDigest.getInstance("SHA-256") - Files.newInputStream(file).use { input -> + Files.newByteChannel( + file, + StandardOpenOption.READ, + LinkOption.NOFOLLOW_LINKS, + ).use { channel -> + val input = Channels.newInputStream(channel) val buffer = ByteArray(8_192) var count = input.read(buffer) while (count >= 0) { @@ -64,10 +72,13 @@ internal fun sha256(value: String): String { } /** - * Returns the cache identity for [releaseBaseUrl] and [assetName]. + * Returns the cache identity for [releaseBaseUrl], [releaseTag], and [assetName]. */ -internal fun releaseAssetIdentity(releaseBaseUrl: String, assetName: String): String = - sha256("$releaseBaseUrl\u0000$assetName") +internal fun releaseAssetIdentity( + releaseBaseUrl: String, + releaseTag: String?, + assetName: String, +): String = sha256("$releaseBaseUrl\u0000${releaseTag.orEmpty()}\u0000$assetName") /** * Validates and normalizes a SHA-256 [value]. 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 2527c1e..b8000b8 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 @@ -49,8 +49,7 @@ public abstract class EmbedCodeExtension { * An optional release tag used verbatim; absent or empty selects the latest release. * * Tags must start with an ASCII letter or digit and may then contain only letters, - * digits, dots, hyphens, underscores, plus signs, or tildes. Hierarchical tags are - * unsupported because `/` is excluded to keep cache paths contained. + * digits, dots, hyphens, underscores, plus signs, or tildes. */ public abstract val version: 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 4e5ecb5..19ce05f 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 @@ -77,9 +77,12 @@ public class EmbedCodePlugin : Plugin { task.installationDirectory.disallowChanges() val requestedTag = task.version.map(::validateVersion) val installationSubdirectory = requestedTag.map { tag -> - if (tag.isEmpty()) "embed-code/latest" else "embed-code/versions/$tag" + if (tag.isEmpty()) { + "embed-code/latest" + } else { + "embed-code/versions/${releaseTagCacheKey(tag)}" + } }.orElse("embed-code/latest") - // Keep explicit tags separate so one named `latest` cannot alias the rolling cache. task.executableFile.set( project.layout.buildDirectory.file( installationSubdirectory.map { directory -> @@ -88,7 +91,14 @@ public class EmbedCodePlugin : Plugin { ), ) task.resolvedVersionFile.set( - project.layout.buildDirectory.file("embed-code/latest/version.txt"), + project.layout.buildDirectory.file( + installationSubdirectory.map { directory -> "$directory/version.txt" }, + ), + ) + task.cachedAssetFile.set( + project.layout.buildDirectory.file( + installationSubdirectory.map { directory -> "$directory/release-asset" }, + ), ) task.assetChecksumFile.set( project.layout.buildDirectory.file( @@ -107,7 +117,7 @@ public class EmbedCodePlugin : Plugin { installationSubdirectory.map { directory -> "$directory/source.sha256" }, ), ) - // Intentionally rerun and re-hash the cached executable before every execution. + // Intentionally rerun and revalidate the cached release asset before execution. task.outputs.upToDateWhen { false } } diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeVersion.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeVersion.kt index ff33a79..59dd4e6 100644 --- a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeVersion.kt +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeVersion.kt @@ -33,8 +33,7 @@ private val validReleaseTag = Regex("[A-Za-z0-9][A-Za-z0-9._+~-]*") /** * Trims and validates a user-configured Embed Code release tag. * - * The tag becomes both a release URL segment and a cache-directory name, - * so only characters that are safe in both locations are accepted. + * The tag becomes a release URL segment, so only URL-safe characters are accepted. * An empty tag selects the latest release. */ internal fun validateVersion(value: String): String { @@ -48,3 +47,9 @@ internal fun validateVersion(value: String): String { } return version } + +/** + * Returns a case-sensitive, filesystem-portable cache key for [releaseTag]. + */ +internal fun releaseTagCacheKey(releaseTag: String): String = + sha256("embed-code-release-tag\u0000$releaseTag") 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 41ce257..7dedd94 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 @@ -44,20 +44,22 @@ import java.io.OutputStream import java.net.HttpURLConnection import java.net.URI import java.net.URLConnection +import java.nio.channels.Channels import java.nio.charset.StandardCharsets import java.nio.file.AtomicMoveNotSupportedException import java.nio.file.Files +import java.nio.file.LinkOption import java.nio.file.Path import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption import java.util.zip.ZipInputStream /** * Downloads and prepares the Embed Code executable selected for the host. * - * Cached executables are reused only after their SHA-256 integrity metadata is - * checked. For the latest release, the remote tag is checked before an - * existing executable is reused. When that check fails, a previously verified - * executable remains available. + * Cached release assets are reused only after their bytes match a trusted + * SHA-256 digest. The executable is recreated from the verified asset on every + * reuse so local cache metadata cannot authorize modified code. */ @DisableCachingByDefault(because = "Release assets come from external URLs that may change") public abstract class InstallEmbedCodeTask : DefaultTask() { @@ -100,19 +102,23 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { @get:OutputFile public abstract val executableFile: RegularFileProperty - /** Stores the release tag represented by the latest executable. */ + /** Stores the exact release tag represented by this installation. */ @get:LocalState public abstract val resolvedVersionFile: RegularFileProperty - /** Stores the verified digest of the downloaded release asset. */ + /** Stores the downloaded release asset used to recreate the executable. */ + @get:LocalState + public abstract val cachedAssetFile: RegularFileProperty + + /** Records the verified digest of the downloaded release asset for diagnostics. */ @get:LocalState public abstract val assetChecksumFile: RegularFileProperty - /** Stores the digest of the prepared executable used from the local cache. */ + /** Records the digest of the prepared executable for diagnostics. */ @get:LocalState public abstract val executableChecksumFile: RegularFileProperty - /** Stores the selected release source and asset identity. */ + /** Records the selected release source and asset identity for diagnostics. */ @get:LocalState public abstract val sourceIdentityFile: RegularFileProperty @@ -135,10 +141,16 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { val asset = platform.assetName val baseUrl = trimTrailingSlashes(downloadBaseUrl.get()) val installationRoot = installationDirectory.get().asFile.toPath() + .toAbsolutePath() + .normalize() val destination = requireInsideInstallationDirectory( executableFile.get().asFile.toPath(), installationRoot, ) + val cachedAsset = requireInsideInstallationDirectory( + cachedAssetFile.get().asFile.toPath(), + installationRoot, + ) val versionFile = requireInsideInstallationDirectory( resolvedVersionFile.get().asFile.toPath(), installationRoot, @@ -155,15 +167,30 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { sourceIdentityFile.get().asFile.toPath(), installationRoot, ) - val expectedSourceIdentity = releaseAssetIdentity(baseUrl, asset) + prepareInstallationDirectory(installationRoot) + listOf( + destination, + cachedAsset, + versionFile, + assetChecksum, + executableChecksum, + sourceIdentity, + ).forEach { path -> requireNoSymbolicLinks(path, installationRoot) } + if (offline.get()) { reuseOfflineInstallation( + platform, destination, + cachedAsset, + versionFile, assetChecksum, executableChecksum, sourceIdentity, - expectedSourceIdentity, + requestedTag, + baseUrl, + asset, configuredSha256, + installationRoot, ) return } @@ -172,16 +199,28 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { try { resolveLatestVersion(baseUrl) } catch (exception: GradleException) { - if ( - !isTrustedCachedInstallation( - destination, - assetChecksum, - executableChecksum, - sourceIdentity, - expectedSourceIdentity, - configuredSha256, - ) - ) { + val expectedAssetSha256 = configuredSha256 ?: throw GradleException( + "${exception.message} The cached asset cannot be authenticated " + + "without a configured digest. Configure `embedCode.sha256` " + + "to allow safe fallback reuse.", + exception, + ) + val cachedTag = readStoredValue(versionFile, installationRoot) + val expectedSourceIdentity = releaseAssetIdentity(baseUrl, cachedTag, asset) + val reused = restoreCachedInstallation( + platform, + destination, + cachedAsset, + versionFile, + assetChecksum, + executableChecksum, + sourceIdentity, + cachedTag, + expectedSourceIdentity, + expectedAssetSha256, + installationRoot, + ) + if (!reused) { throw exception } logger.warn( @@ -198,19 +237,26 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { if (resolvedTag != null) { logger.info("Resolved the latest Embed Code release as {}.", resolvedTag) } - if ( - ( - requestedTag != null || - resolvedTag != null && - readResolvedVersion(versionFile) == resolvedTag - ) && - isTrustedCachedInstallation( + val selectedReleaseTag = requestedTag ?: resolvedTag + val expectedAssetSha256 = resolveTrustedAssetSha256( + configuredSha256, + baseUrl, + selectedReleaseTag, + asset, + ) + val expectedSourceIdentity = releaseAssetIdentity(baseUrl, selectedReleaseTag, asset) + if (restoreCachedInstallation( + platform, destination, + cachedAsset, + versionFile, assetChecksum, executableChecksum, sourceIdentity, + selectedReleaseTag, expectedSourceIdentity, - configuredSha256, + expectedAssetSha256, + installationRoot, ) ) { logger.lifecycle( @@ -220,13 +266,11 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { ) return } - val selectedReleaseTag = requestedTag ?: resolvedTag val source = releaseAsset(baseUrl, selectedReleaseTag, asset) - val download = temporaryDir.toPath().resolve(asset) - val preparedExecutable = temporaryDir.toPath().resolve(platform.executableName) + val download = Files.createTempFile(temporaryDir.toPath(), "downloaded-asset-", ".tmp") try { - Files.createDirectories(destination.parent) + createDirectoriesSafely(destination.parent, installationRoot) val release = requestedTag ?: "latest release" logger.lifecycle("Downloading Embed Code {} from {}", release, source) try { @@ -234,21 +278,6 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { } catch (exception: GradleException) { throw addReleaseTagMigrationHint(exception, requestedTag) } - 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( @@ -257,23 +286,29 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { ) } logger.info("Verified SHA-256 digest `{}` for {}.", downloadedSha256, asset) - - if (asset.endsWith(".zip")) { - extractExecutable(download, platform.executableName, preparedExecutable) + moveSafely(download, cachedAsset, installationRoot) + if (selectedReleaseTag == null) { + deleteSafely(versionFile, installationRoot) } else { - Files.move(download, preparedExecutable, StandardCopyOption.REPLACE_EXISTING) - } - - if (!preparedExecutable.toFile().setExecutable(true, false)) { - throw GradleException("Could not make `$preparedExecutable` executable.") + writeStoredValue(versionFile, selectedReleaseTag, installationRoot) } - val preparedExecutableSha256 = sha256(preparedExecutable) - moveAtomically(preparedExecutable, destination) - writeResolvedVersion(assetChecksum, expectedAssetSha256) - writeResolvedVersion(executableChecksum, preparedExecutableSha256) - writeResolvedVersion(sourceIdentity, expectedSourceIdentity) - if (resolvedTag != null) { - writeResolvedVersion(versionFile, resolvedTag) + val installed = restoreCachedInstallation( + platform, + destination, + cachedAsset, + versionFile, + assetChecksum, + executableChecksum, + sourceIdentity, + selectedReleaseTag, + expectedSourceIdentity, + expectedAssetSha256, + installationRoot, + ) + if (!installed) { + throw GradleException( + "The verified Embed Code asset could not be restored from `$cachedAsset`.", + ) } logger.info( "Installed Embed Code {} at {}.", @@ -282,6 +317,8 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { ) } catch (exception: IOException) { throw GradleException("Could not install Embed Code from $source.", exception) + } finally { + Files.deleteIfExists(download) } } @@ -289,32 +326,49 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { * Reuses an installed executable while Gradle is offline. */ private fun reuseOfflineInstallation( + platform: EmbedCodePlatform, destination: Path, + cachedAsset: Path, + versionFile: Path, assetChecksum: Path, executableChecksum: Path, sourceIdentity: Path, - expectedSourceIdentity: String, + requestedTag: String?, + baseUrl: String, + asset: String, configuredSha256: String?, + installationRoot: Path, ) { - if (!Files.isRegularFile(destination)) { + val expectedAssetSha256 = configuredSha256 ?: throw GradleException( + "Cannot securely reuse Embed Code in offline mode without a trusted digest. " + + "Configure `embedCode.sha256` for the selected release asset.", + ) + val cachedTag = readStoredValue(versionFile, installationRoot) + val selectedTag = requestedTag ?: cachedTag + if (requestedTag != null && cachedTag != requestedTag) { throw GradleException( "Cannot install Embed Code in offline mode because " + - "no cached executable exists at `$destination`.", + "no cached asset exists for release tag `$requestedTag`.", ) } - if ( - !isTrustedCachedInstallation( + val expectedSourceIdentity = releaseAssetIdentity(baseUrl, selectedTag, asset) + if (!restoreCachedInstallation( + platform, destination, + cachedAsset, + versionFile, assetChecksum, executableChecksum, sourceIdentity, + selectedTag, expectedSourceIdentity, - configuredSha256, + expectedAssetSha256, + installationRoot, ) ) { throw GradleException( - "Cannot reuse cached Embed Code executable `$destination` in offline mode " + - "because its SHA-256 integrity metadata is missing or does not match.", + "Cannot reuse cached Embed Code asset `$cachedAsset` in offline mode " + + "because it is missing or does not match `embedCode.sha256`.", ) } logger.lifecycle( @@ -323,17 +377,102 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { ) } + /** + * Resolves a digest from trusted configuration or current release metadata. + */ + private fun resolveTrustedAssetSha256( + configuredSha256: String?, + baseUrl: String, + releaseTag: String?, + asset: String, + ): String = resolveExpectedAssetSha256( + configuredSha256, + baseUrl, + releaseTag, + asset, + ) { metadataSource -> + val token = if (metadataSource.host.equals("api.github.com", ignoreCase = true)) { + githubToken.orNull?.trim()?.ifEmpty { null } + } else { + null + } + readText(metadataSource, token) + } + + /** + * Authenticates the cached release asset and recreates the installed executable from it. + */ + private fun restoreCachedInstallation( + platform: EmbedCodePlatform, + destination: Path, + cachedAsset: Path, + versionFile: Path, + assetChecksum: Path, + executableChecksum: Path, + sourceIdentity: Path, + releaseTag: String?, + expectedSourceIdentity: String, + expectedAssetSha256: String, + installationRoot: Path, + ): Boolean { + requireNoSymbolicLinks(cachedAsset, installationRoot) + if (!Files.isRegularFile(cachedAsset, LinkOption.NOFOLLOW_LINKS)) { + return false + } + if (readStoredValue(versionFile, installationRoot) != releaseTag) { + return false + } + val stagedAsset = Files.createTempFile(temporaryDir.toPath(), "cached-asset-", ".tmp") + var preparedExecutable: Path? = null + try { + copyNoFollow(cachedAsset, stagedAsset) + if (sha256(stagedAsset) != expectedAssetSha256) { + return false + } + val prepared = Files.createTempFile( + temporaryDir.toPath(), + "prepared-executable-", + ".tmp", + ) + preparedExecutable = prepared + if (platform.assetName.endsWith(".zip")) { + extractExecutable(stagedAsset, platform.executableName, prepared) + } else { + Files.copy(stagedAsset, prepared, StandardCopyOption.REPLACE_EXISTING) + } + if (!prepared.toFile().setExecutable(true, false)) { + throw GradleException("Could not make `$prepared` executable.") + } + val preparedExecutableSha256 = sha256(prepared) + moveSafely(prepared, destination, installationRoot) + writeStoredValue(assetChecksum, expectedAssetSha256, installationRoot) + writeStoredValue(executableChecksum, preparedExecutableSha256, installationRoot) + writeStoredValue(sourceIdentity, expectedSourceIdentity, installationRoot) + if (releaseTag == null) { + deleteSafely(versionFile, installationRoot) + } else { + writeStoredValue(versionFile, releaseTag, installationRoot) + } + return true + } catch (_: IOException) { + return false + } finally { + Files.deleteIfExists(stagedAsset) + preparedExecutable?.let(Files::deleteIfExists) + } + } + private companion object { const val CONNECT_TIMEOUT_MILLIS = 30_000 const val READ_TIMEOUT_MILLIS = 120_000 const val BUFFER_SIZE = 8_192 + fun selectedVersionName(requestedTag: String?, resolvedTag: String?): String = + requestedTag ?: resolvedTag ?: "latest release" + /** * Normalizes [path] and verifies that it is below [installationDirectory]. - * - * This is a lexical check because target files may not exist yet. Existing symlinks - * below the installation directory are not resolved. */ fun requireInsideInstallationDirectory( path: Path, @@ -349,66 +488,141 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { return normalizedPath } - fun selectedVersionName(requestedTag: String?, resolvedTag: String?): String = - requestedTag ?: resolvedTag ?: "latest release" + /** + * Creates the installation root and rejects a symlink or non-directory root. + */ + fun prepareInstallationDirectory(installationDirectory: Path) { + try { + if (!Files.exists(installationDirectory, LinkOption.NOFOLLOW_LINKS)) { + Files.createDirectories(installationDirectory) + } + } catch (exception: IOException) { + throw GradleException( + "Could not create Embed Code installation directory `$installationDirectory`.", + exception, + ) + } + if ( + isRedirectingFileSystemEntry(installationDirectory) || + !Files.isDirectory(installationDirectory, LinkOption.NOFOLLOW_LINKS) + ) { + throw GradleException( + "Embed Code installation directory `$installationDirectory` " + + "must be a real directory, not a symbolic link or redirecting entry.", + ) + } + } /** - * Adds an upgrade hint when an exact tag may be missing its former automatic prefix. + * Detects symbolic links and directory redirects such as Windows junctions. */ - fun addReleaseTagMigrationHint( - exception: GradleException, - requestedTag: String?, - ): GradleException { - if (requestedTag == null || requestedTag.startsWith('v')) { - return exception + fun isRedirectingFileSystemEntry(path: Path): Boolean { + return try { + if (Files.isSymbolicLink(path)) { + true + } else { + val parent = path.parent ?: return false + val expectedRealPath = parent.toRealPath().resolve(path.fileName).normalize() + path.toRealPath() != expectedRealPath + } + } catch (exception: IOException) { + throw GradleException( + "Could not inspect Embed Code installation path `$path`.", + exception, + ) } - return GradleException( - "${exception.message} A release tag `v$requestedTag` may exist; " + - "previous plugin versions added this prefix automatically.", - exception, - ) } /** - * Checks both the trusted release-asset digest and cached executable contents. + * Rejects symbolic links in every existing component from the installation root. */ - fun isTrustedCachedInstallation( - destination: Path, - assetChecksumFile: Path, - executableChecksumFile: Path, - sourceIdentityFile: Path, - expectedSourceIdentity: String, - configuredSha256: String?, - ): Boolean { - if (!Files.isRegularFile(destination)) { - return false + fun requireNoSymbolicLinks(path: Path, installationDirectory: Path) { + val root = installationDirectory.toAbsolutePath().normalize() + val normalizedPath = requireInsideInstallationDirectory(path, root) + prepareInstallationDirectory(root) + var current = root + root.relativize(normalizedPath).forEach { component -> + current = current.resolve(component) + if (Files.exists(current, LinkOption.NOFOLLOW_LINKS)) { + if (isRedirectingFileSystemEntry(current)) { + throw GradleException( + "Embed Code installation path `$current` must not be a " + + "symbolic link or redirecting filesystem entry.", + ) + } + if ( + current != normalizedPath && + !Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS) + ) { + throw GradleException( + "Embed Code installation path component `$current` " + + "must be a directory.", + ) + } + } } - val storedAssetSha256 = readStoredSha256(assetChecksumFile) ?: return false - val storedExecutableSha256 = readStoredSha256(executableChecksumFile) ?: return false - val storedSourceIdentity = readStoredSha256(sourceIdentityFile) ?: return false - if (storedSourceIdentity != expectedSourceIdentity) { - return false + } + + /** + * Creates [directory] component by component without following symbolic links. + */ + fun createDirectoriesSafely(directory: Path, installationDirectory: Path) { + val root = installationDirectory.toAbsolutePath().normalize() + val normalizedDirectory = directory.toAbsolutePath().normalize() + if (normalizedDirectory != root) { + requireInsideInstallationDirectory(normalizedDirectory, root) } - if (configuredSha256 != null && configuredSha256 != storedAssetSha256) { - return false + prepareInstallationDirectory(root) + var current = root + root.relativize(normalizedDirectory).forEach { component -> + current = current.resolve(component) + if (Files.exists(current, LinkOption.NOFOLLOW_LINKS)) { + if ( + isRedirectingFileSystemEntry(current) || + !Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS) + ) { + throw GradleException( + "Embed Code installation directory `$current` " + + "must be a real directory, not a symbolic link " + + "or redirecting entry.", + ) + } + } else { + try { + Files.createDirectory(current) + } catch (exception: IOException) { + throw GradleException( + "Could not create Embed Code installation directory `$current`.", + exception, + ) + } + } } - return try { - sha256(destination) == storedExecutableSha256 - } catch (_: IOException) { - false + val realRoot = root.toRealPath() + val realDirectory = normalizedDirectory.toRealPath() + if (!realDirectory.startsWith(realRoot)) { + throw GradleException( + "Embed Code installation directory `$realDirectory` " + + "must remain inside `$realRoot`.", + ) } } /** - * Reads a locally stored SHA-256 digest, ignoring invalid state. + * Adds an upgrade hint when an exact tag may be missing its former automatic prefix. */ - fun readStoredSha256(file: Path): String? { - val value = readResolvedVersion(file) ?: return null - return try { - normalizeSha256(value) - } catch (_: GradleException) { - null + fun addReleaseTagMigrationHint( + exception: GradleException, + requestedTag: String?, + ): GradleException { + if (requestedTag == null || requestedTag.startsWith('v')) { + return exception } + return GradleException( + "${exception.message} A release tag `v$requestedTag` may exist; " + + "previous plugin versions added this prefix automatically.", + exception, + ) } /** @@ -490,8 +704,15 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { } connection.getInputStream().use { input -> - Files.newOutputStream(destination).use { output -> - copy(input, output) + Files.newByteChannel( + destination, + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING, + LinkOption.NOFOLLOW_LINKS, + ).use { outputChannel -> + Channels.newOutputStream(outputChannel).use { output -> + copy(input, output) + } } } } catch (exception: IOException) { @@ -539,25 +760,94 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { } /** - * Returns the recorded latest release tag, if available. + * Reads a UTF-8 cache metadata value without following a symbolic link. */ - fun readResolvedVersion(versionFile: Path): String? { + fun readStoredValue(file: Path, installationDirectory: Path): String? { + requireNoSymbolicLinks(file, installationDirectory) + if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) { + return null + } return try { - Files.readString(versionFile).trim().ifEmpty { null } + Files.newByteChannel( + file, + StandardOpenOption.READ, + LinkOption.NOFOLLOW_LINKS, + ).use { channel -> + Channels.newInputStream(channel) + .bufferedReader(StandardCharsets.UTF_8) + .use { reader -> reader.readText().trim().ifEmpty { null } } + } } catch (_: IOException) { null } } /** - * Records [version] after its executable has been installed. + * Writes a UTF-8 cache metadata value through a fresh, unpredictable file. + */ + @Throws(IOException::class) + fun writeStoredValue( + file: Path, + value: String, + installationDirectory: Path, + ) { + createDirectoriesSafely(file.parent, installationDirectory) + requireNoSymbolicLinks(file, installationDirectory) + val temporaryFile = Files.createTempFile( + file.parent, + ".${file.fileName}-", + ".tmp", + ) + try { + Files.writeString( + temporaryFile, + "$value\n", + StandardCharsets.UTF_8, + StandardOpenOption.TRUNCATE_EXISTING, + ) + moveSafely(temporaryFile, file, installationDirectory) + } finally { + Files.deleteIfExists(temporaryFile) + } + } + + /** + * Removes [file] without following symbolic links. + */ + @Throws(IOException::class) + fun deleteSafely(file: Path, installationDirectory: Path) { + requireNoSymbolicLinks(file, installationDirectory) + Files.deleteIfExists(file) + } + + /** + * Copies [source] without following it when it is a symbolic link. + */ + @Throws(IOException::class) + fun copyNoFollow(source: Path, destination: Path) { + Files.newByteChannel( + source, + StandardOpenOption.READ, + LinkOption.NOFOLLOW_LINKS, + ).use { inputChannel -> + Channels.newInputStream(inputChannel).use { input -> + Files.newOutputStream( + destination, + StandardOpenOption.TRUNCATE_EXISTING, + ).use { output -> copy(input, output) } + } + } + } + + /** + * Moves [source] to a checked installation path. */ @Throws(IOException::class) - fun writeResolvedVersion(versionFile: Path, version: String) { - Files.createDirectories(versionFile.parent) - val temporaryFile = versionFile.resolveSibling("${versionFile.fileName}.tmp") - Files.writeString(temporaryFile, "$version\n") - moveAtomically(temporaryFile, versionFile) + fun moveSafely(source: Path, destination: Path, installationDirectory: Path) { + createDirectoriesSafely(destination.parent, installationDirectory) + requireNoSymbolicLinks(destination, installationDirectory) + moveAtomically(source, destination) + requireNoSymbolicLinks(destination, installationDirectory) } /** 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 c31ae5f..878485c 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 @@ -61,12 +61,20 @@ internal class ChecksumSpec { 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"), + releaseAssetIdentity(baseUrl, "v1", "embed-code-macos-x64.zip"), + releaseAssetIdentity(baseUrl, "v1", "embed-code-macos-arm64.zip"), ) assertNotEquals( - releaseAssetIdentity(baseUrl, "embed-code-linux"), - releaseAssetIdentity("https://releases.example.com/embed-code", "embed-code-linux"), + releaseAssetIdentity(baseUrl, "v1", "embed-code-linux"), + releaseAssetIdentity( + "https://releases.example.com/embed-code", + "v1", + "embed-code-linux", + ), + ) + assertNotEquals( + releaseAssetIdentity(baseUrl, "v1", "embed-code-linux"), + releaseAssetIdentity(baseUrl, "V1", "embed-code-linux"), ) } diff --git a/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodeVersionSpec.kt b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodeVersionSpec.kt index 4a1ab03..022328d 100644 --- a/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodeVersionSpec.kt +++ b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodeVersionSpec.kt @@ -28,11 +28,13 @@ package io.spine.embedcode.gradle import org.gradle.api.InvalidUserDataException import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test -@DisplayName("`validateVersion` should") +@DisplayName("Embed Code release-tag support should") internal class EmbedCodeVersionSpec { @Test @@ -42,6 +44,8 @@ internal class EmbedCodeVersionSpec { "1.0.0-beta+build", "2.0_rc1", "latest", + "CON", + "release.", ) tags.forEach { tag -> @@ -64,4 +68,17 @@ internal class EmbedCodeVersionSpec { fun `treat an empty release tag as latest`() { assertEquals("", validateVersion(" ")) } + + @Test + fun `derive distinct portable cache keys from exact tags`() { + val lowercaseKey = releaseTagCacheKey("v1") + val uppercaseKey = releaseTagCacheKey("V1") + + assertNotEquals(lowercaseKey, uppercaseKey) + listOf(lowercaseKey, uppercaseKey, releaseTagCacheKey("CON"), releaseTagCacheKey("v1.")).forEach { + key -> + assertEquals(64, key.length) + assertTrue(key.all { character -> character in '0'..'9' || character in 'a'..'f' }) + } + } } From 6031df142202de640c68459c1b183c9e87a1414b Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Wed, 22 Jul 2026 16:43:22 +0200 Subject: [PATCH 2/7] Make HTTP failure tests deterministic. --- .../embedcode/gradle/EmbedCodePluginSpec.kt | 55 ++++++++++++++++++- 1 file changed, 53 insertions(+), 2 deletions(-) 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 92b8025..13c4e76 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 @@ -257,7 +257,11 @@ internal class EmbedCodePluginSpec { @Test fun `reuse cached executable when the latest release check fails`() { val latestStatus = AtomicInteger(HTTP_MOVED_TEMP) + val versionChecks = AtomicInteger() + val downloads = AtomicInteger() val server = startReleaseServer( + versionChecks = versionChecks, + downloads = downloads, latestStatus = latestStatus, ) writeBuildFile(downloadBaseUrl = server.releaseBaseUrl) @@ -268,8 +272,10 @@ internal class EmbedCodePluginSpec { val result = runner(":installEmbedCode").build() result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + versionChecks.get() shouldBe 2 + downloads.get() shouldBe 1 result.output shouldContain "Could not check the latest Embed Code release" - result.output shouldContain "HTTP 503" + result.output shouldContain "HTTP 503 from ${server.releaseBaseUrl}/latest" result.output shouldContain "Reusing the cached executable" } finally { server.stop(0) @@ -306,7 +312,11 @@ internal class EmbedCodePluginSpec { @Test fun `report a failed latest release check without a cached executable`() { + val versionChecks = AtomicInteger() + val downloads = AtomicInteger() val server = startReleaseServer( + versionChecks = versionChecks, + downloads = downloads, latestStatus = AtomicInteger(HTTP_UNAVAILABLE), ) try { @@ -314,8 +324,13 @@ internal class EmbedCodePluginSpec { val result = runner(":installEmbedCode").buildAndFail() + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.FAILED + versionChecks.get() shouldBe 1 + downloads.get() shouldBe 0 result.output shouldContain - "Could not resolve the latest Embed Code release: HTTP 503" + "Could not resolve the latest Embed Code release: " + + "HTTP 503 from ${server.releaseBaseUrl}/latest." + result.output shouldNotContain "Reusing the cached executable" } finally { server.stop(0) } @@ -810,6 +825,42 @@ internal class EmbedCodePluginSpec { } } + @Test + fun `report an HTTP status returned for a release asset`() { + val downloads = AtomicInteger() + val server = HttpServer.create( + InetSocketAddress("127.0.0.1", 0), + 0, + ) + server.createContext("/releases/download/") { exchange -> + downloads.incrementAndGet() + exchange.sendResponseHeaders(503, -1) + exchange.close() + } + server.start() + try { + val baseUrl = "http://127.0.0.1:${server.address.port}/releases" + writeBuildFile( + version = TEST_RELEASE_TAG, + downloadBaseUrl = baseUrl, + ) + + val result = runner(":installEmbedCode").buildAndFail() + + val platform = EmbedCodePlatform.detect( + System.getProperty("os.name"), + System.getProperty("os.arch"), + ) + val source = "$baseUrl/download/$TEST_RELEASE_TAG/${platform.assetName}" + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.FAILED + downloads.get() shouldBe 1 + result.output shouldContain + "Could not download Embed Code: HTTP 503 from $source." + } finally { + server.stop(0) + } + } + @Test @EnabledOnOs(OS.LINUX, OS.MAC) fun `run check mode with Gradle 8_14_4`() { From 5aa6d827eb108b627913ddc33ec298d224b5fe81 Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Wed, 22 Jul 2026 17:01:51 +0200 Subject: [PATCH 3/7] Fix tests under windows. --- .../spine/embedcode/gradle/EmbedCodePluginSpec.kt | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) 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 13c4e76..81f5cfa 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 @@ -554,10 +554,10 @@ internal class EmbedCodePluginSpec { val lowerTag = "vcase-test" val upperTag = "VCASE-test" createFakeRelease(releaseDirectory, version = "lower", tag = lowerTag) - createFakeRelease(releaseDirectory, version = "upper", tag = upperTag) - writeBuildFile(version = lowerTag) runner(":installEmbedCode").build() + + createFakeRelease(releaseDirectory, version = "upper", tag = upperTag) // Change the file length as well as its case so Gradle cannot reuse a timestamp/size // file-system snapshot for the rewritten build script. writeBuildFile(version = " $upperTag ") @@ -567,9 +567,13 @@ internal class EmbedCodePluginSpec { Files.readString( installationDirectory(lowerTag).resolve("version.txt"), ).trim() shouldBe lowerTag + Files.readString(installedExecutable(lowerTag)) shouldContain + "# release-marker: lower" Files.readString( installationDirectory(upperTag).resolve("version.txt"), ).trim() shouldBe upperTag + Files.readString(installedExecutable(upperTag)) shouldContain + "# release-marker: upper" } @Test @@ -1197,7 +1201,11 @@ internal class EmbedCodePluginSpec { zip.closeEntry() } } else { - Files.copy(executable, asset) + Files.copy( + executable, + asset, + StandardCopyOption.REPLACE_EXISTING, + ) } val latestAsset = latestDirectory.resolve(platform.assetName) Files.copy( From 69b74645115369186d243139c23862db333f3864 Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Wed, 22 Jul 2026 19:00:14 +0200 Subject: [PATCH 4/7] Improve offline behavior. --- README.md | 43 +++--- .../embedcode/gradle/EmbedCodePluginSpec.kt | 116 ++++++++++---- .../embedcode/gradle/InstallEmbedCodeTask.kt | 141 +++++++++++++++--- .../embedcode/gradle/EmbedCodeVersionSpec.kt | 8 +- 4 files changed, 236 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index 028b9a3..f42389c 100644 --- a/README.md +++ b/README.md @@ -85,11 +85,15 @@ embedCode { Embedding instructions refer to these roots with `$model/` and `$database/`. `codePath` and `namedSource(...)` are mutually exclusive. -By default, the plugin checks the latest Embed Code release before running a task. -It reuses the executable in `build/embed-code/latest` while the release -tag remains unchanged and downloads a new release asset only after a new -release is published. If the latest-release check fails, the plugin reuses the -cached asset only when `sha256` supplies an independent trust anchor. +By default, the plugin resolves and verifies the latest Embed Code release on +the first installation. It then reuses the executable in +`build/embed-code/latest` without another release or checksum-metadata request. +Run `clean` or remove that directory to check for a newer release. Changing the +configured version, release source, platform, or `sha256` to a different digest +also invalidates the matching cached installation. If a latest-release check +fails after invalidation, the plugin reuses an existing installed executable. If +that executable is missing, the plugin can restore the retained release asset +only when `sha256` supplies a trust anchor. To use a specific Embed Code application release, add its exact release tag to the extension: @@ -100,12 +104,7 @@ embedCode { } ``` -The tag is used verbatim. In particular, the plugin does not add a `v` prefix. - -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: +API requests are unauthenticated unless a token provider is configured explicitly: ```kotlin embedCode { @@ -113,18 +112,22 @@ 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. +For CI, configure `githubToken` to avoid GitHub's unauthenticated API rate limit +during the initial resolution. Without `sha256`, the first online installation +resolves the asset digest from GitHub release metadata. Pin both `version` and +`sha256` to keep that initial installation tied to an immutable release. The digest applies to the downloaded release asset. For macOS, this means the ZIP archive rather than the extracted executable. The verified release asset is -retained under `build/embed-code`, and the executable is recreated from it on -every reuse. Local checksum sidecars are diagnostic only and are not trusted to -authorize executable contents. - -Offline reuse requires `sha256` because a local cache cannot authenticate its -own metadata. Explicit release tags use SHA-256-derived cache directory names, +retained under `build/embed-code`. Local metadata records that the installed +executable came from a verified asset. Later builds trust this local state and +do not rehash the executable or retained asset. If local cache contents may have +been changed, remove the installation directory to force verification again. + +Offline mode reuses an existing regular executable from the selected cache +directory without remote verification and without requiring `sha256`. If the +executable is missing, `sha256` is required to authenticate and restore the +retained asset. Explicit release tags use SHA-256-derived cache directory names, which preserve case-sensitive tag identity on Windows and case-insensitive file systems. 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 81f5cfa..a889620 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 @@ -134,7 +134,7 @@ internal class EmbedCodePluginSpec { } @Test - fun `reuse latest executable when the release tag is unchanged`() { + fun `reuse a verified latest executable without another release request`() { val latestTag = AtomicReference(TEST_RELEASE_TAG) val versionChecks = AtomicInteger() val downloads = AtomicInteger() @@ -143,12 +143,17 @@ internal class EmbedCodePluginSpec { writeBuildFile(downloadBaseUrl = server.releaseBaseUrl) runner(":installEmbedCode").build() + writeBuildFile( + downloadBaseUrl = server.releaseBaseUrl, + configureSha256 = false, + ) val result = runner(":installEmbedCode").build() result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS - versionChecks.get() shouldBe 2 + versionChecks.get() shouldBe 1 downloads.get() shouldBe 1 - result.output shouldContain "Reusing verified Embed Code v$TEST_RELEASE_VERSION" + result.output shouldContain + "Reusing previously verified Embed Code v$TEST_RELEASE_VERSION" } finally { server.stop(0) } @@ -207,7 +212,7 @@ internal class EmbedCodePluginSpec { } @Test - fun `download latest executable when the release tag changes`() { + fun `download latest executable when the configured digest changes`() { val nextVersion = "1.2.5-test" createFakeRelease(releaseDirectory, nextVersion) val latestTag = AtomicReference(TEST_RELEASE_TAG) @@ -247,11 +252,29 @@ internal class EmbedCodePluginSpec { } finally { server.stop(0) } + writeBuildFile( + downloadBaseUrl = server.releaseBaseUrl, + configureSha256 = false, + ) val result = runner(":installEmbedCode", "--offline").build() result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS - result.output shouldContain "Reusing verified cached Embed Code executable" + result.output shouldContain "Reusing installed Embed Code executable" + } + + @Test + fun `reuse a manually installed executable offline without cache metadata`() { + writeBuildFile(configureSha256 = false) + val executable = installedExecutable() + Files.createDirectories(executable.parent) + Files.writeString(executable, "user-provided executable") + + val result = runner(":installEmbedCode", "--offline").build() + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + Files.readString(executable) shouldBe "user-provided executable" + result.output shouldContain "Reusing installed Embed Code executable" } @Test @@ -267,6 +290,11 @@ internal class EmbedCodePluginSpec { writeBuildFile(downloadBaseUrl = server.releaseBaseUrl) try { runner(":installEmbedCode").build() + writeBuildFile( + downloadBaseUrl = server.releaseBaseUrl, + configureSha256 = false, + ) + Files.delete(installationDirectory().resolve("source.sha256")) latestStatus.set(HTTP_UNAVAILABLE) val result = runner(":installEmbedCode").build() @@ -276,14 +304,14 @@ internal class EmbedCodePluginSpec { downloads.get() shouldBe 1 result.output shouldContain "Could not check the latest Embed Code release" result.output shouldContain "HTTP 503 from ${server.releaseBaseUrl}/latest" - result.output shouldContain "Reusing the cached executable" + result.output shouldContain "Reusing the installed executable" } finally { server.stop(0) } } @Test - fun `require a configured digest when the latest release check fails`() { + fun `require a configured digest to restore a cached asset after latest check failure`() { val latestStatus = AtomicInteger(HTTP_MOVED_TEMP) val downloads = AtomicInteger() val server = startReleaseServer( @@ -293,6 +321,7 @@ internal class EmbedCodePluginSpec { try { writeBuildFile(downloadBaseUrl = server.releaseBaseUrl) runner(":installEmbedCode").build() + Files.delete(installedExecutable()) writeBuildFile( downloadBaseUrl = server.releaseBaseUrl, @@ -302,7 +331,8 @@ internal class EmbedCodePluginSpec { val result = runner(":installEmbedCode").buildAndFail() result.output shouldContain - "The cached asset cannot be authenticated without a configured digest." + "No installed executable is available for reuse, and the cached asset " + + "cannot be authenticated without a configured digest." result.output shouldContain "Configure `embedCode.sha256`" downloads.get() shouldBe 1 } finally { @@ -310,6 +340,31 @@ internal class EmbedCodePluginSpec { } } + @Test + fun `restore a verified cached asset after latest release check failure`() { + val latestStatus = AtomicInteger(HTTP_MOVED_TEMP) + val downloads = AtomicInteger() + val server = startReleaseServer( + downloads = downloads, + latestStatus = latestStatus, + ) + try { + writeBuildFile(downloadBaseUrl = server.releaseBaseUrl) + runner(":installEmbedCode").build() + Files.delete(installedExecutable()) + latestStatus.set(HTTP_UNAVAILABLE) + + val result = runner(":installEmbedCode").build() + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + Files.exists(installedExecutable()) shouldBe true + downloads.get() shouldBe 1 + result.output shouldContain "Reusing the cached executable" + } finally { + server.stop(0) + } + } + @Test fun `report a failed latest release check without a cached executable`() { val versionChecks = AtomicInteger() @@ -363,34 +418,27 @@ internal class EmbedCodePluginSpec { } @Test - fun `restore a modified cached executable without trusting sidecar digests`() { + fun `trust a local executable after its verified installation`() { runner(":installEmbedCode").build() - val executableName = EmbedCodePlatform.installedExecutableName( - System.getProperty("os.name"), - ) - val installation = projectDirectory.resolve("build/embed-code/latest") - val executable = installation.resolve(executableName) - val verifiedExecutable = Files.readAllBytes(executable) + val executable = installedExecutable() Files.writeString(executable, "modified after verification") - Files.writeString(installation.resolve("asset.sha256"), "1".repeat(64)) - Files.writeString(installation.resolve("executable.sha256"), "2".repeat(64)) - Files.writeString(installation.resolve("source.sha256"), "3".repeat(64)) - val result = runner(":installEmbedCode", "--offline").build() + val result = runner(":installEmbedCode").build() result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS - Files.readAllBytes(executable).contentEquals(verifiedExecutable) shouldBe true - result.output shouldContain "Reusing verified cached Embed Code executable" + Files.readString(executable) shouldBe "modified after verification" + result.output shouldContain "Reusing previously verified Embed Code" } @Test @EnabledOnOs(OS.LINUX, OS.MAC) - fun `restore execute permission from the verified cached asset`() { + fun `restore execute permission when verified cache metadata is missing`() { runner(":installEmbedCode").build() val executable = installedExecutable() executable.toFile().setExecutable(false, false) shouldBe true + Files.delete(installationDirectory().resolve("source.sha256")) - val result = runner(":installEmbedCode", "--offline").build() + val result = runner(":installEmbedCode").build() result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS Files.isExecutable(executable) shouldBe true @@ -448,6 +496,7 @@ internal class EmbedCodePluginSpec { val cachedAsset = installation.resolve("release-asset") Files.writeString(cachedAsset, "untrusted replacement") Files.writeString(installation.resolve("asset.sha256"), sha256(cachedAsset)) + Files.delete(installedExecutable()) val result = runner(":installEmbedCode", "--offline").buildAndFail() @@ -465,6 +514,7 @@ internal class EmbedCodePluginSpec { installationDirectory().resolve("release-asset"), "untrusted replacement", ) + Files.delete(installedExecutable()) val result = runner(":installEmbedCode").build() @@ -476,17 +526,30 @@ internal class EmbedCodePluginSpec { } @Test - fun `require a configured digest for offline cache reuse`() { + fun `require a configured digest to restore a missing executable offline`() { runner(":installEmbedCode").build() + Files.delete(installedExecutable()) writeBuildFile(configureSha256 = false) val result = runner(":installEmbedCode", "--offline").buildAndFail() result.output shouldContain - "Cannot securely reuse Embed Code in offline mode without a trusted digest." + "no executable exists at" result.output shouldContain "Configure `embedCode.sha256`" } + @Test + fun `restore a missing executable from a verified cached asset offline`() { + runner(":installEmbedCode").build() + Files.delete(installedExecutable()) + + val result = runner(":installEmbedCode", "--offline").build() + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + Files.exists(installedExecutable()) shouldBe true + result.output shouldContain "Reusing verified cached Embed Code executable" + } + @Test fun `accept an explicitly pinned release asset`() { writeBuildFile( @@ -753,6 +816,7 @@ internal class EmbedCodePluginSpec { @EnabledOnOs(OS.LINUX, OS.MAC) fun `reject a symbolic-link cached asset`() { runner(":installEmbedCode").build() + Files.delete(installedExecutable()) val outsideAsset = projectDirectory.resolve("outside-asset") Files.writeString(outsideAsset, "unchanged") val cachedAsset = installationDirectory().resolve("release-asset") @@ -887,7 +951,7 @@ internal class EmbedCodePluginSpec { val result = runner(":embedCode").build() result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS - result.output shouldContain "Reusing verified Embed Code $TEST_RELEASE_TAG" + result.output shouldContain "Reusing previously verified Embed Code $TEST_RELEASE_TAG" result.task(":embedCode")?.outcome shouldBe TaskOutcome.SUCCESS Files.readString(projectDirectory.resolve("mode.txt")).trim() shouldBe "embed" } 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 7dedd94..051286b 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 @@ -57,9 +57,10 @@ import java.util.zip.ZipInputStream /** * Downloads and prepares the Embed Code executable selected for the host. * - * Cached release assets are reused only after their bytes match a trusted - * SHA-256 digest. The executable is recreated from the verified asset on every - * reuse so local cache metadata cannot authorize modified code. + * Release assets are authenticated before their first installation. A previously + * verified local installation is reused without another network request or digest + * calculation. If that installation is missing or its metadata no longer matches + * the configured release source, the retained asset is authenticated again before use. */ @DisableCachingByDefault(because = "Release assets come from external URLs that may change") public abstract class InstallEmbedCodeTask : DefaultTask() { @@ -110,15 +111,15 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { @get:LocalState public abstract val cachedAssetFile: RegularFileProperty - /** Records the verified digest of the downloaded release asset for diagnostics. */ + /** Records the verified digest of the downloaded release asset. */ @get:LocalState public abstract val assetChecksumFile: RegularFileProperty - /** Records the digest of the prepared executable for diagnostics. */ + /** Records that the prepared executable came from a verified release asset. */ @get:LocalState public abstract val executableChecksumFile: RegularFileProperty - /** Records the selected release source and asset identity for diagnostics. */ + /** Records the selected release source and asset identity. */ @get:LocalState public abstract val sourceIdentityFile: RegularFileProperty @@ -168,14 +169,7 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { installationRoot, ) prepareInstallationDirectory(installationRoot) - listOf( - destination, - cachedAsset, - versionFile, - assetChecksum, - executableChecksum, - sourceIdentity, - ).forEach { path -> requireNoSymbolicLinks(path, installationRoot) } + requireNoSymbolicLinks(destination, installationRoot) if (offline.get()) { reuseOfflineInstallation( @@ -194,20 +188,50 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { ) return } + val cachedTag = readStoredValue(versionFile, installationRoot) + if (isPreviouslyVerifiedInstallation( + destination, + versionFile, + assetChecksum, + executableChecksum, + sourceIdentity, + requestedTag, + baseUrl, + asset, + configuredSha256, + installationRoot, + ) + ) { + logger.lifecycle( + "Reusing previously verified Embed Code {} from {}", + selectedVersionName(requestedTag, cachedTag), + destination, + ) + return + } val resolvedTag = if (requestedTag == null) { logger.info("Resolving the latest Embed Code release from {}.", baseUrl) try { resolveLatestVersion(baseUrl) } catch (exception: GradleException) { + if (isReusableInstalledExecutable(destination, installationRoot)) { + logger.warn( + "Could not check the latest Embed Code release ({}). " + + "Reusing the installed executable from `{}`.", + exception.message, + destination, + ) + return + } val expectedAssetSha256 = configuredSha256 ?: throw GradleException( - "${exception.message} The cached asset cannot be authenticated " + - "without a configured digest. Configure `embedCode.sha256` " + - "to allow safe fallback reuse.", + "${exception.message} No installed executable is available for reuse, " + + "and the cached asset cannot be authenticated without a configured " + + "digest. Configure `embedCode.sha256` to restore it safely.", exception, ) val cachedTag = readStoredValue(versionFile, installationRoot) val expectedSourceIdentity = releaseAssetIdentity(baseUrl, cachedTag, asset) - val reused = restoreCachedInstallation( + val reused = installFromVerifiedAsset( platform, destination, cachedAsset, @@ -245,7 +269,7 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { asset, ) val expectedSourceIdentity = releaseAssetIdentity(baseUrl, selectedReleaseTag, asset) - if (restoreCachedInstallation( + if (installFromVerifiedAsset( platform, destination, cachedAsset, @@ -292,7 +316,7 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { } else { writeStoredValue(versionFile, selectedReleaseTag, installationRoot) } - val installed = restoreCachedInstallation( + val installed = installFromVerifiedAsset( platform, destination, cachedAsset, @@ -339,9 +363,17 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { configuredSha256: String?, installationRoot: Path, ) { + if (isReusableInstalledExecutable(destination, installationRoot)) { + logger.lifecycle( + "Reusing installed Embed Code executable from {} in offline mode", + destination, + ) + return + } val expectedAssetSha256 = configuredSha256 ?: throw GradleException( - "Cannot securely reuse Embed Code in offline mode without a trusted digest. " + - "Configure `embedCode.sha256` for the selected release asset.", + "Cannot install Embed Code in offline mode because no executable exists at " + + "`$destination`, and the cached asset cannot be authenticated without a " + + "trusted digest. Configure `embedCode.sha256` to restore it safely.", ) val cachedTag = readStoredValue(versionFile, installationRoot) val selectedTag = requestedTag ?: cachedTag @@ -352,7 +384,7 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { ) } val expectedSourceIdentity = releaseAssetIdentity(baseUrl, selectedTag, asset) - if (!restoreCachedInstallation( + if (!installFromVerifiedAsset( platform, destination, cachedAsset, @@ -377,6 +409,66 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { ) } + /** + * Returns whether an installed executable can be reused without remote verification. + * + * Offline operation deliberately trusts a regular file placed at the configured output + * path. The path itself remains subject to the installation-root and link checks. + */ + private fun isReusableInstalledExecutable( + destination: Path, + installationRoot: Path, + ): Boolean { + requireNoSymbolicLinks(destination, installationRoot) + return Files.isRegularFile(destination, LinkOption.NOFOLLOW_LINKS) + } + + /** + * Returns whether the installed executable was produced by a successful verified install. + * + * This deliberately treats the local installation and its metadata as trusted after the + * initial asset verification. It validates cache identity, but does not rehash local files. + */ + private fun isPreviouslyVerifiedInstallation( + destination: Path, + versionFile: Path, + assetChecksum: Path, + executableChecksum: Path, + sourceIdentity: Path, + requestedTag: String?, + baseUrl: String, + asset: String, + configuredSha256: String?, + installationRoot: Path, + ): Boolean { + if (!isReusableInstalledExecutable(destination, installationRoot)) { + return false + } + val cachedTag = readStoredValue(versionFile, installationRoot) + if (requestedTag != null && cachedTag != requestedTag) { + return false + } + val selectedTag = requestedTag ?: cachedTag + val expectedSourceIdentity = releaseAssetIdentity(baseUrl, selectedTag, asset) + if (readStoredValue(sourceIdentity, installationRoot) != expectedSourceIdentity) { + return false + } + val storedAssetSha256 = readStoredSha256(assetChecksum, installationRoot) + ?: return false + if (configuredSha256 != null && storedAssetSha256 != configuredSha256) { + return false + } + return readStoredSha256(executableChecksum, installationRoot) != null + } + + /** + * Reads a valid SHA-256 cache marker, or returns `null` when it is absent or malformed. + */ + private fun readStoredSha256(file: Path, installationRoot: Path): String? { + val value = readStoredValue(file, installationRoot) ?: return null + return runCatching { normalizeSha256(value) }.getOrNull() + } + /** * Resolves a digest from trusted configuration or current release metadata. */ @@ -402,7 +494,7 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { /** * Authenticates the cached release asset and recreates the installed executable from it. */ - private fun restoreCachedInstallation( + private fun installFromVerifiedAsset( platform: EmbedCodePlatform, destination: Path, cachedAsset: Path, @@ -834,6 +926,7 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { Files.newOutputStream( destination, StandardOpenOption.TRUNCATE_EXISTING, + LinkOption.NOFOLLOW_LINKS, ).use { output -> copy(input, output) } } } diff --git a/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodeVersionSpec.kt b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodeVersionSpec.kt index 022328d..e007dc5 100644 --- a/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodeVersionSpec.kt +++ b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodeVersionSpec.kt @@ -75,8 +75,12 @@ internal class EmbedCodeVersionSpec { val uppercaseKey = releaseTagCacheKey("V1") assertNotEquals(lowercaseKey, uppercaseKey) - listOf(lowercaseKey, uppercaseKey, releaseTagCacheKey("CON"), releaseTagCacheKey("v1.")).forEach { - key -> + listOf( + lowercaseKey, + uppercaseKey, + releaseTagCacheKey("CON"), + releaseTagCacheKey("v1."), + ).forEach { key -> assertEquals(64, key.length) assertTrue(key.all { character -> character in '0'..'9' || character in 'a'..'f' }) } From 536d868c37a6d544580c05dbcb433daef3e31708 Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Wed, 22 Jul 2026 19:14:04 +0200 Subject: [PATCH 5/7] Improve readme. --- README.md | 67 ++++++++++++++++++++++--------------------------------- 1 file changed, 27 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index f42389c..27646f2 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ The plugin is written in Kotlin, but uses the Kotlin runtime supplied by Gradle. This section describes how to use the plugin. For information about the Embed Code application itself, see its [documentation][embed-code]. +### Configuration + Add the following configuration to the project's `build.gradle.kts`: ```kotlin @@ -81,22 +83,36 @@ embedCode { docsPath.set(layout.projectDirectory) } ``` +`codePath` and `namedSource(...)` are mutually exclusive. + +### Execution + +Check that documentation is up to date: + +```bash +./gradlew :checkEmbedding +``` + +Update documentation: + +```bash +./gradlew :embedCode +``` + +The plugin prefers the `checkEmbedding` and `embedCode` task names. If a name +is already occupied when the plugin is applied, underscores are prepended until +an available name is found, for example `_embedCode` or `__embedCode`. +The fallback cannot account for a conflicting task registered later. -Embedding instructions refer to these roots with `$model/` and -`$database/`. `codePath` and `namedSource(...)` are mutually exclusive. +### Version By default, the plugin resolves and verifies the latest Embed Code release on the first installation. It then reuses the executable in `build/embed-code/latest` without another release or checksum-metadata request. Run `clean` or remove that directory to check for a newer release. Changing the -configured version, release source, platform, or `sha256` to a different digest -also invalidates the matching cached installation. If a latest-release check -fails after invalidation, the plugin reuses an existing installed executable. If -that executable is missing, the plugin can restore the retained release asset -only when `sha256` supplies a trust anchor. +configured version. -To use a specific Embed Code application release, add its exact release tag to -the extension: +To use a specific Embed Code application release, add its exact release tag to the extension: ```kotlin embedCode { @@ -104,6 +120,8 @@ embedCode { } ``` +### GitHub Authorization + API requests are unauthenticated unless a token provider is configured explicitly: ```kotlin @@ -117,37 +135,6 @@ during the initial resolution. Without `sha256`, the first online installation resolves the asset digest from GitHub release metadata. Pin both `version` and `sha256` to keep that initial installation tied to an immutable release. -The digest applies to the downloaded release asset. For macOS, this means the -ZIP archive rather than the extracted executable. The verified release asset is -retained under `build/embed-code`. Local metadata records that the installed -executable came from a verified asset. Later builds trust this local state and -do not rehash the executable or retained asset. If local cache contents may have -been changed, remove the installation directory to force verification again. - -Offline mode reuses an existing regular executable from the selected cache -directory without remote verification and without requiring `sha256`. If the -executable is missing, `sha256` is required to authenticate and restore the -retained asset. Explicit release tags use SHA-256-derived cache directory names, -which preserve case-sensitive tag identity on Windows and case-insensitive file -systems. - -Check that documentation is up to date: - -```bash -./gradlew :checkEmbedding -``` - -Update documentation: - -```bash -./gradlew :embedCode -``` - -The plugin prefers the `checkEmbedding` and `embedCode` task names. If a name -is already occupied when the plugin is applied, underscores are prepended until -an available name is found, for example `_embedCode` or `__embedCode`. -The fallback cannot account for a conflicting task registered later. - ## Development Run compilation, plugin validation, and the complete test suite: From 4e65bde6e72347d4a7496b812429dc94fd339414 Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Thu, 23 Jul 2026 11:04:01 +0200 Subject: [PATCH 6/7] Add local re-verification. --- README.md | 12 ++- .../embedcode/gradle/EmbedCodePluginSpec.kt | 51 +++++++++--- .../embedcode/gradle/InstallEmbedCodeTask.kt | 83 ++++++++++++------- 3 files changed, 103 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 27646f2..4073eb2 100644 --- a/README.md +++ b/README.md @@ -107,10 +107,14 @@ The fallback cannot account for a conflicting task registered later. ### Version By default, the plugin resolves and verifies the latest Embed Code release on -the first installation. It then reuses the executable in -`build/embed-code/latest` without another release or checksum-metadata request. -Run `clean` or remove that directory to check for a newer release. Changing the -configured version. +the first installation. Before reusing the executable in +`build/embed-code/latest`, it verifies the executable against the digest stored +during installation. This local check does not require another release or +checksum-metadata request. If the executable was modified, the plugin restores +it from the authenticated cached release asset, or fails safely when that asset +cannot be authenticated offline. Run `clean` or remove that directory to check +for a newer release. Changing the configured version selects another cache +entry. To use a specific Embed Code application release, add its exact release tag to the extension: 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 a889620..4527235 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 @@ -260,21 +260,21 @@ internal class EmbedCodePluginSpec { val result = runner(":installEmbedCode", "--offline").build() result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS - result.output shouldContain "Reusing installed Embed Code executable" + result.output shouldContain "Reusing locally verified Embed Code executable" } @Test - fun `reuse a manually installed executable offline without cache metadata`() { + fun `reject a manually installed executable offline without integrity metadata`() { writeBuildFile(configureSha256 = false) val executable = installedExecutable() Files.createDirectories(executable.parent) Files.writeString(executable, "user-provided executable") - val result = runner(":installEmbedCode", "--offline").build() + val result = runner(":installEmbedCode", "--offline").buildAndFail() - result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS Files.readString(executable) shouldBe "user-provided executable" - result.output shouldContain "Reusing installed Embed Code executable" + result.output shouldContain "no locally verified executable is available" + result.output shouldNotContain "Reusing locally verified Embed Code executable" } @Test @@ -304,7 +304,7 @@ internal class EmbedCodePluginSpec { downloads.get() shouldBe 1 result.output shouldContain "Could not check the latest Embed Code release" result.output shouldContain "HTTP 503 from ${server.releaseBaseUrl}/latest" - result.output shouldContain "Reusing the installed executable" + result.output shouldContain "Reusing the locally verified installed executable" } finally { server.stop(0) } @@ -331,8 +331,8 @@ internal class EmbedCodePluginSpec { val result = runner(":installEmbedCode").buildAndFail() result.output shouldContain - "No installed executable is available for reuse, and the cached asset " + - "cannot be authenticated without a configured digest." + "No locally verified installed executable is available for reuse, and " + + "the cached asset cannot be authenticated without a configured digest." result.output shouldContain "Configure `embedCode.sha256`" downloads.get() shouldBe 1 } finally { @@ -418,16 +418,45 @@ internal class EmbedCodePluginSpec { } @Test - fun `trust a local executable after its verified installation`() { + fun `restore a modified installed executable from the verified cached asset`() { runner(":installEmbedCode").build() val executable = installedExecutable() + val verifiedExecutable = Files.readAllBytes(executable) Files.writeString(executable, "modified after verification") val result = runner(":installEmbedCode").build() result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + Files.readAllBytes(executable).contentEquals(verifiedExecutable) shouldBe true + result.output shouldContain "Reusing verified Embed Code" + } + + @Test + fun `restore a modified installed executable from the verified cache offline`() { + runner(":installEmbedCode").build() + val executable = installedExecutable() + val verifiedExecutable = Files.readAllBytes(executable) + Files.writeString(executable, "modified after verification") + + val result = runner(":installEmbedCode", "--offline").build() + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + Files.readAllBytes(executable).contentEquals(verifiedExecutable) shouldBe true + result.output shouldContain "Reusing verified cached Embed Code executable" + } + + @Test + fun `reject a modified installed executable offline without a trusted asset digest`() { + runner(":installEmbedCode").build() + val executable = installedExecutable() + Files.writeString(executable, "modified after verification") + writeBuildFile(configureSha256 = false) + + val result = runner(":installEmbedCode", "--offline").buildAndFail() + Files.readString(executable) shouldBe "modified after verification" - result.output shouldContain "Reusing previously verified Embed Code" + result.output shouldContain "no locally verified executable is available" + result.output shouldNotContain "Reusing locally verified Embed Code executable" } @Test @@ -534,7 +563,7 @@ internal class EmbedCodePluginSpec { val result = runner(":installEmbedCode", "--offline").buildAndFail() result.output shouldContain - "no executable exists at" + "no locally verified executable is available at" result.output shouldContain "Configure `embedCode.sha256`" } 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 051286b..aa803c1 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 @@ -57,10 +57,11 @@ import java.util.zip.ZipInputStream /** * Downloads and prepares the Embed Code executable selected for the host. * - * Release assets are authenticated before their first installation. A previously - * verified local installation is reused without another network request or digest - * calculation. If that installation is missing or its metadata no longer matches - * the configured release source, the retained asset is authenticated again before use. + * Release assets are authenticated before their first installation. Before a local + * installation is reused, its digest is calculated and compared with the digest stored + * during that verified installation; this does not require a network request. If the + * installation is missing, modified, or its metadata no longer matches the configured + * release source, the retained asset is authenticated again before use. */ @DisableCachingByDefault(because = "Release assets come from external URLs that may change") public abstract class InstallEmbedCodeTask : DefaultTask() { @@ -115,7 +116,7 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { @get:LocalState public abstract val assetChecksumFile: RegularFileProperty - /** Records that the prepared executable came from a verified release asset. */ + /** Records the digest used to authenticate the prepared executable on reuse. */ @get:LocalState public abstract val executableChecksumFile: RegularFileProperty @@ -214,19 +215,25 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { try { resolveLatestVersion(baseUrl) } catch (exception: GradleException) { - if (isReusableInstalledExecutable(destination, installationRoot)) { + if (isLocallyVerifiedExecutable( + destination, + executableChecksum, + installationRoot, + ) + ) { logger.warn( "Could not check the latest Embed Code release ({}). " + - "Reusing the installed executable from `{}`.", + "Reusing the locally verified installed executable from `{}`.", exception.message, destination, ) return } val expectedAssetSha256 = configuredSha256 ?: throw GradleException( - "${exception.message} No installed executable is available for reuse, " + - "and the cached asset cannot be authenticated without a configured " + - "digest. Configure `embedCode.sha256` to restore it safely.", + "${exception.message} No locally verified installed executable is " + + "available for reuse, and the cached asset cannot be authenticated " + + "without a configured digest. Configure `embedCode.sha256` to " + + "restore it safely.", exception, ) val cachedTag = readStoredValue(versionFile, installationRoot) @@ -363,17 +370,30 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { configuredSha256: String?, installationRoot: Path, ) { - if (isReusableInstalledExecutable(destination, installationRoot)) { + if (isPreviouslyVerifiedInstallation( + destination, + versionFile, + assetChecksum, + executableChecksum, + sourceIdentity, + requestedTag, + baseUrl, + asset, + configuredSha256, + installationRoot, + ) + ) { logger.lifecycle( - "Reusing installed Embed Code executable from {} in offline mode", + "Reusing locally verified Embed Code executable from {} in offline mode", destination, ) return } val expectedAssetSha256 = configuredSha256 ?: throw GradleException( - "Cannot install Embed Code in offline mode because no executable exists at " + - "`$destination`, and the cached asset cannot be authenticated without a " + - "trusted digest. Configure `embedCode.sha256` to restore it safely.", + "Cannot install Embed Code in offline mode because no locally verified " + + "executable is available at `$destination`, and the cached asset cannot " + + "be authenticated without a trusted digest. Configure `embedCode.sha256` " + + "to restore it safely.", ) val cachedTag = readStoredValue(versionFile, installationRoot) val selectedTag = requestedTag ?: cachedTag @@ -410,24 +430,30 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { } /** - * Returns whether an installed executable can be reused without remote verification. - * - * Offline operation deliberately trusts a regular file placed at the configured output - * path. The path itself remains subject to the installation-root and link checks. + * Returns whether an installed executable matches its locally stored verified digest. */ - private fun isReusableInstalledExecutable( + private fun isLocallyVerifiedExecutable( destination: Path, + executableChecksum: Path, installationRoot: Path, ): Boolean { requireNoSymbolicLinks(destination, installationRoot) - return Files.isRegularFile(destination, LinkOption.NOFOLLOW_LINKS) + if (!Files.isRegularFile(destination, LinkOption.NOFOLLOW_LINKS)) { + return false + } + val storedExecutableSha256 = readStoredSha256( + executableChecksum, + installationRoot, + ) ?: return false + return try { + sha256(destination) == storedExecutableSha256 + } catch (_: IOException) { + false + } } /** * Returns whether the installed executable was produced by a successful verified install. - * - * This deliberately treats the local installation and its metadata as trusted after the - * initial asset verification. It validates cache identity, but does not rehash local files. */ private fun isPreviouslyVerifiedInstallation( destination: Path, @@ -441,9 +467,6 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { configuredSha256: String?, installationRoot: Path, ): Boolean { - if (!isReusableInstalledExecutable(destination, installationRoot)) { - return false - } val cachedTag = readStoredValue(versionFile, installationRoot) if (requestedTag != null && cachedTag != requestedTag) { return false @@ -458,7 +481,11 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { if (configuredSha256 != null && storedAssetSha256 != configuredSha256) { return false } - return readStoredSha256(executableChecksum, installationRoot) != null + return isLocallyVerifiedExecutable( + destination, + executableChecksum, + installationRoot, + ) } /** From 9c7d468c7a0ca2fe5d58faa164c1b930aac9c532 Mon Sep 17 00:00:00 2001 From: Vladyslav Kuksiuk Date: Thu, 23 Jul 2026 11:09:20 +0200 Subject: [PATCH 7/7] Clarify version cache behavior --- README.md | 5 +++-- .../kotlin/io/spine/embedcode/gradle/EmbedCodeExtension.kt | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4073eb2..a988b0c 100644 --- a/README.md +++ b/README.md @@ -113,8 +113,9 @@ during installation. This local check does not require another release or checksum-metadata request. If the executable was modified, the plugin restores it from the authenticated cached release asset, or fails safely when that asset cannot be authenticated offline. Run `clean` or remove that directory to check -for a newer release. Changing the configured version selects another cache -entry. +for a newer release. Changing the configured `version` selects a separate cache +entry. If that entry does not already contain a verified installation, the +plugin downloads and verifies the selected release while online. To use a specific Embed Code application release, add its exact release tag to the extension: 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 b8000b8..20f3f99 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 @@ -50,6 +50,7 @@ public abstract class EmbedCodeExtension { * * Tags must start with an ASCII letter or digit and may then contain only letters, * digits, dots, hyphens, underscores, plus signs, or tildes. + * Tags containing `/` are unsupported because the tag is used as a release URL segment. */ public abstract val version: Property