diff --git a/README.md b/README.md index bdc19db..366afc9 100644 --- a/README.md +++ b/README.md @@ -87,18 +87,23 @@ 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 -version remains unchanged and downloads a new executable only after a new +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. -To use a specific Embed Code application release, add its version to the extension: +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. ```kotlin embedCode { - version.set("1.2.4") + version.set("v1.2.4") } ``` +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. + Before installing an executable, the plugin verifies the release asset's SHA-256 digest from the GitHub Releases API. API requests are unauthenticated unless a token provider is configured explicitly: 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 c01342e..d40b0de 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 @@ -38,6 +38,8 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.condition.EnabledOnOs import org.junit.jupiter.api.condition.OS import org.junit.jupiter.api.io.TempDir +import java.net.HttpURLConnection.HTTP_MOVED_TEMP +import java.net.HttpURLConnection.HTTP_UNAVAILABLE import java.net.InetSocketAddress import java.nio.file.Files import java.nio.file.Path @@ -132,7 +134,7 @@ internal class EmbedCodePluginSpec { } @Test - fun `reuse latest executable when the release version is unchanged`() { + fun `reuse latest executable when the release tag is unchanged`() { val latestTag = AtomicReference(TEST_RELEASE_TAG) val versionChecks = AtomicInteger() val downloads = AtomicInteger() @@ -205,7 +207,7 @@ internal class EmbedCodePluginSpec { } @Test - fun `download latest executable when the release version changes`() { + fun `download latest executable when the release tag changes`() { val nextVersion = "1.2.5-test" createFakeRelease(releaseDirectory, nextVersion) val latestTag = AtomicReference(TEST_RELEASE_TAG) @@ -215,14 +217,14 @@ internal class EmbedCodePluginSpec { try { writeBuildFile( downloadBaseUrl = server.releaseBaseUrl, - sha256 = releaseAssetSha256(version = TEST_RELEASE_VERSION), + sha256 = releaseAssetSha256(tag = TEST_RELEASE_TAG), ) runner(":installEmbedCode").build() latestTag.set("v$nextVersion") writeBuildFile( downloadBaseUrl = server.releaseBaseUrl, - sha256 = releaseAssetSha256(version = nextVersion), + sha256 = releaseAssetSha256(tag = "v$nextVersion"), ) runner(":installEmbedCode").build() @@ -238,12 +240,7 @@ internal class EmbedCodePluginSpec { @Test fun `reuse latest executable in offline mode`() { - val latestTag = AtomicReference(TEST_RELEASE_TAG) - val server = startReleaseServer( - latestTag, - AtomicInteger(), - AtomicInteger(), - ) + val server = startReleaseServer() writeBuildFile(downloadBaseUrl = server.releaseBaseUrl) try { runner(":installEmbedCode").build() @@ -259,36 +256,41 @@ internal class EmbedCodePluginSpec { @Test fun `reuse cached executable when the latest release check fails`() { - val latestTag = AtomicReference(TEST_RELEASE_TAG) + val latestStatus = AtomicInteger(HTTP_MOVED_TEMP) val server = startReleaseServer( - latestTag, - AtomicInteger(), - AtomicInteger(), + latestStatus = latestStatus, ) writeBuildFile(downloadBaseUrl = server.releaseBaseUrl) try { runner(":installEmbedCode").build() + latestStatus.set(HTTP_UNAVAILABLE) + + val result = runner(":installEmbedCode").build() + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + result.output shouldContain "Could not check the latest Embed Code release" + result.output shouldContain "HTTP 503" + result.output shouldContain "Reusing the cached executable" } finally { server.stop(0) } - - val result = runner(":installEmbedCode").build() - - result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS - result.output shouldContain "Could not check the latest Embed Code release" - result.output shouldContain "Reusing the cached executable" } @Test fun `report a failed latest release check without a cached executable`() { - val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) - val baseUrl = "http://127.0.0.1:${server.address.port}/releases" - server.stop(0) - writeBuildFile(downloadBaseUrl = baseUrl) + val server = startReleaseServer( + latestStatus = AtomicInteger(HTTP_UNAVAILABLE), + ) + try { + writeBuildFile(downloadBaseUrl = server.releaseBaseUrl) - val result = runner(":installEmbedCode").buildAndFail() + val result = runner(":installEmbedCode").buildAndFail() - result.output shouldContain "Could not resolve the latest Embed Code release" + result.output shouldContain + "Could not resolve the latest Embed Code release: HTTP 503" + } finally { + server.stop(0) + } } @Test @@ -302,7 +304,7 @@ internal class EmbedCodePluginSpec { @Test fun `keep explicit versions offline when no verified executable is cached`() { - writeBuildFile(version = TEST_RELEASE_VERSION) + writeBuildFile(version = TEST_RELEASE_TAG) val result = runner(":installEmbedCode", "--offline").buildAndFail() @@ -338,8 +340,8 @@ internal class EmbedCodePluginSpec { @Test fun `accept an explicitly pinned release asset`() { writeBuildFile( - version = TEST_RELEASE_VERSION, - sha256 = releaseAssetSha256(version = TEST_RELEASE_VERSION), + version = TEST_RELEASE_TAG, + sha256 = releaseAssetSha256(tag = TEST_RELEASE_TAG), ) val result = runner(":installEmbedCode").build() @@ -349,10 +351,10 @@ internal class EmbedCodePluginSpec { @Test @EnabledOnOs(OS.LINUX, OS.MAC) - fun `trim an overridden Embed Code version`() { - val overrideVersion = "0.0.0-test" - createFakeRelease(releaseDirectory, overrideVersion) - writeBuildFile(" $overrideVersion ") + fun `trim an overridden Embed Code release tag`() { + val overrideTag = "v0.0.0-test" + createFakeRelease(releaseDirectory, version = "0.0.0-test", tag = overrideTag) + writeBuildFile(" $overrideTag ") val result = runner(":checkEmbedding").build() @@ -361,10 +363,147 @@ internal class EmbedCodePluginSpec { System.getProperty("os.name"), ) Files.exists( - projectDirectory.resolve("build/embed-code/$overrideVersion/$executableName"), + projectDirectory.resolve( + "build/embed-code/versions/$overrideTag/$executableName", + ), + ) shouldBe true + } + + @Test + fun `treat an empty release tag as the rolling latest release`() { + writeBuildFile(version = " ") + + val result = runner(":installEmbedCode").build() + val executableName = EmbedCodePlatform.installedExecutableName( + System.getProperty("os.name"), + ) + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + Files.exists( + projectDirectory.resolve("build/embed-code/latest/$executableName"), + ) shouldBe true + } + + @Test + fun `keep an explicit latest tag separate from the rolling latest cache`() { + runner(":installEmbedCode").build() + createFakeRelease(releaseDirectory, version = "explicit-latest", tag = "latest") + writeBuildFile(version = "latest") + + val result = runner(":installEmbedCode").build() + val executableName = EmbedCodePlatform.installedExecutableName( + System.getProperty("os.name"), + ) + val rollingLatest = projectDirectory.resolve("build/embed-code/latest/$executableName") + val pinnedLatest = projectDirectory.resolve( + "build/embed-code/versions/latest/$executableName", + ) + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + result.output shouldContain "/download/latest/" + Files.readString(rollingLatest) shouldContain "# release-marker: $TEST_RELEASE_VERSION" + Files.readString(pinnedLatest) shouldContain "# release-marker: explicit-latest" + } + + @Test + fun `reject a release tag containing path traversal segments`() { + writeBuildFile( + version = "../../escaped", + sha256 = "0".repeat(64), + ) + + val result = runner(":installEmbedCode").buildAndFail() + + result.output shouldContain "Embed Code release tag `../../escaped` is invalid." + Files.exists(projectDirectory.resolve("escaped")) shouldBe false + } + + @Test + fun `validate a release tag configured directly on the installation task`() { + writeBuildFile(version = TEST_RELEASE_TAG) + Files.writeString( + projectDirectory.resolve("build.gradle.kts"), + """ + + tasks.named("installEmbedCode") { + version.set("../../escaped") + } + """.trimIndent(), + StandardOpenOption.APPEND, + ) + + val result = runner(":installEmbedCode").buildAndFail() + + result.output shouldContain "Embed Code release tag `../../escaped` is invalid." + Files.exists(projectDirectory.resolve("escaped")) shouldBe false + } + + @Test + fun `use a release tag configured directly on the installation task`() { + Files.writeString( + projectDirectory.resolve("build.gradle.kts"), + """ + + tasks.named("installEmbedCode") { + version.set("$TEST_RELEASE_TAG") + } + """.trimIndent(), + StandardOpenOption.APPEND, + ) + + val result = runner(":installEmbedCode").build() + val executableName = EmbedCodePlatform.installedExecutableName( + System.getProperty("os.name"), + ) + + result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS + Files.exists( + projectDirectory.resolve( + "build/embed-code/versions/$TEST_RELEASE_TAG/$executableName", + ), ) shouldBe true } + @Test + fun `reject an executable path outside the installation directory`() { + writeBuildFile(version = TEST_RELEASE_TAG) + Files.writeString( + projectDirectory.resolve("build.gradle.kts"), + """ + + tasks.named("installEmbedCode") { + executableFile.set(layout.projectDirectory.file("escaped/embed-code")) + } + """.trimIndent(), + StandardOpenOption.APPEND, + ) + + val result = runner(":installEmbedCode").buildAndFail() + + result.output shouldContain "must remain inside" + Files.exists(projectDirectory.resolve("escaped/embed-code")) shouldBe false + } + + @Test + fun `reject a checksum path outside the installation directory`() { + writeBuildFile(version = TEST_RELEASE_TAG) + Files.writeString( + projectDirectory.resolve("build.gradle.kts"), + """ + + tasks.named("installEmbedCode") { + assetChecksumFile.set(layout.projectDirectory.file("escaped/asset.sha256")) + } + """.trimIndent(), + StandardOpenOption.APPEND, + ) + + val result = runner(":installEmbedCode").buildAndFail() + + result.output shouldContain "must remain inside" + Files.exists(projectDirectory.resolve("escaped/asset.sha256")) shouldBe false + } + @Test fun `defer unsupported platform failure until installation`() { Files.writeString( @@ -400,7 +539,7 @@ internal class EmbedCodePluginSpec { } @Test - fun `report an HTTP status returned for a release asset`() { + fun `suggest a prefixed tag when a pinned release download fails`() { val server = HttpServer.create( InetSocketAddress("127.0.0.1", 0), 0, @@ -412,11 +551,18 @@ internal class EmbedCodePluginSpec { server.start() try { val baseUrl = "http://127.0.0.1:${server.address.port}/releases" - writeBuildFile(downloadBaseUrl = baseUrl) + writeBuildFile( + version = TEST_RELEASE_VERSION, + downloadBaseUrl = baseUrl, + sha256 = "0".repeat(64), + ) val result = runner(":installEmbedCode").buildAndFail() result.output shouldContain "HTTP 503" + result.output shouldContain + "A release tag `v$TEST_RELEASE_VERSION` may exist; " + + "previous plugin versions added this prefix automatically." } finally { server.stop(0) } @@ -437,14 +583,14 @@ internal class EmbedCodePluginSpec { @Test @EnabledOnOs(OS.LINUX, OS.MAC) fun `reuse installation when running embed mode`() { - writeBuildFile(TEST_RELEASE_VERSION) + writeBuildFile(TEST_RELEASE_TAG) runner(":checkEmbedding").build() releaseDirectory.toFile().deleteRecursively() val result = runner(":embedCode").build() result.task(":installEmbedCode")?.outcome shouldBe TaskOutcome.SUCCESS - result.output shouldContain "Reusing verified Embed Code $TEST_RELEASE_VERSION" + result.output shouldContain "Reusing verified Embed Code $TEST_RELEASE_TAG" result.task(":embedCode")?.outcome shouldBe TaskOutcome.SUCCESS Files.readString(projectDirectory.resolve("mode.txt")).trim() shouldBe "embed" } @@ -625,7 +771,7 @@ internal class EmbedCodePluginSpec { sha256: String? = null, ) { val versionConfiguration = version?.let { "version.set(\"$it\")" }.orEmpty() - val configuredSha256 = sha256 ?: releaseAssetSha256(version = version) + val configuredSha256 = sha256 ?: releaseAssetSha256(tag = version) Files.writeString( projectDirectory.resolve("build.gradle.kts"), """ @@ -745,45 +891,44 @@ internal class EmbedCodePluginSpec { */ private fun releaseAssetSha256( root: Path = releaseDirectory, - version: String? = null, + tag: String? = null, ): String { val platform = EmbedCodePlatform.detect( System.getProperty("os.name"), System.getProperty("os.arch"), ) - val asset = if (version == null) { + val normalizedTag = tag?.trim()?.ifEmpty { null } + val asset = if (normalizedTag == null) { root.resolve("latest/download/${platform.assetName}") } else { - val normalizedVersion = version.trim() - val tag = if (normalizedVersion.startsWith('v')) { - normalizedVersion - } else { - "v$normalizedVersion" - } - root.resolve("download/$tag/${platform.assetName}") + root.resolve("download/$normalizedTag/${platform.assetName}") } return sha256(asset) } /** - * Starts a release server whose latest endpoint redirects to a mutable tag. + * Starts a release server with mutable latest-release status and tag responses. */ private fun startReleaseServer( - latestTag: AtomicReference, - versionChecks: AtomicInteger, - downloads: AtomicInteger, + latestTag: AtomicReference = AtomicReference(TEST_RELEASE_TAG), + versionChecks: AtomicInteger = AtomicInteger(), + downloads: AtomicInteger = AtomicInteger(), + latestStatus: AtomicInteger = AtomicInteger(HTTP_MOVED_TEMP), ): HttpServer { val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) server.createContext("/releases/latest") { exchange -> versionChecks.incrementAndGet() + val responseStatus = latestStatus.get() if (exchange.requestMethod != "HEAD") { exchange.sendResponseHeaders(405, -1) + } else if (responseStatus != HTTP_MOVED_TEMP) { + exchange.sendResponseHeaders(responseStatus, -1) } else { exchange.responseHeaders.add( "Location", "/releases/tag/${latestTag.get()}", ) - exchange.sendResponseHeaders(302, -1) + exchange.sendResponseHeaders(HTTP_MOVED_TEMP, -1) } exchange.close() } diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeExtension.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeExtension.kt index fa25030..5e7df1c 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 @@ -45,7 +45,13 @@ public abstract class EmbedCodeExtension { private val configuredSourceNames = mutableSetOf() - /** An optional release version, with the latest release used when absent. */ + /** + * 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. + */ public abstract val version: Property /** @@ -122,9 +128,9 @@ public abstract class EmbedCodeExtension { /** * The base URL of the Embed Code releases. * - * The plugin appends `/latest/download/` when no version is - * configured, or `/download/v/` for an explicit - * version. This property primarily supports functional testing. + * The plugin appends `/latest/download/` when [version] is + * absent or empty, or `/download//` for an + * explicit tag. This property primarily supports functional testing. */ public abstract val downloadBaseUrl: Property diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodePlugin.kt index a9501c2..4e5ecb5 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 @@ -60,24 +60,31 @@ public class EmbedCodePlugin : Plugin { // The installed file name always tracks the host operating system. // Overriding the task's `operatingSystem` input changes asset selection only. val installedExecutableName = EmbedCodePlatform.installedExecutableName(operatingSystem) - val requestedVersion = extension.version.map { version -> version.trim() } + val installationDirectory = project.layout.buildDirectory.dir("embed-code") val installTask = project.tasks.register( installTaskName, InstallEmbedCodeTask::class.java, ) { task -> task.description = "Installs the requested Embed Code executable" - task.version.set(requestedVersion) + task.version.set(extension.version) task.sha256.set(extension.sha256) task.githubToken.set(extension.githubToken) task.downloadBaseUrl.set(extension.downloadBaseUrl) task.operatingSystem.set(operatingSystem) task.architecture.set(architecture) task.offline.set(project.gradle.startParameter.isOffline) + task.installationDirectory.set(installationDirectory) + 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" + }.orElse("embed-code/latest") + // Keep explicit tags separate so one named `latest` cannot alias the rolling cache. task.executableFile.set( project.layout.buildDirectory.file( - requestedVersion.map { version -> - "embed-code/$version/$installedExecutableName" - }.orElse("embed-code/latest/$installedExecutableName"), + installationSubdirectory.map { directory -> + "$directory/$installedExecutableName" + }, ), ) task.resolvedVersionFile.set( @@ -85,23 +92,19 @@ public class EmbedCodePlugin : Plugin { ) task.assetChecksumFile.set( project.layout.buildDirectory.file( - requestedVersion.map { version -> - "embed-code/$version/asset.sha256" - }.orElse("embed-code/latest/asset.sha256"), + installationSubdirectory.map { directory -> "$directory/asset.sha256" }, ), ) task.executableChecksumFile.set( project.layout.buildDirectory.file( - requestedVersion.map { version -> - "embed-code/$version/executable.sha256" - }.orElse("embed-code/latest/executable.sha256"), + installationSubdirectory.map { directory -> + "$directory/executable.sha256" + }, ), ) task.sourceIdentityFile.set( project.layout.buildDirectory.file( - requestedVersion.map { version -> - "embed-code/$version/source.sha256" - }.orElse("embed-code/latest/source.sha256"), + installationSubdirectory.map { directory -> "$directory/source.sha256" }, ), ) // Intentionally rerun and re-hash the cached executable before every execution. 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 new file mode 100644 index 0000000..ff33a79 --- /dev/null +++ b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/EmbedCodeVersion.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.embedcode.gradle + +import org.gradle.api.InvalidUserDataException + +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. + * An empty tag selects the latest release. + */ +internal fun validateVersion(value: String): String { + val version = value.trim() + if (version.isNotEmpty() && !validReleaseTag.matches(version)) { + throw InvalidUserDataException( + "Embed Code release tag `$value` is invalid. " + + "Use letters, digits, dots, hyphens, underscores, plus signs, or tildes, " + + "and start with a letter or digit.", + ) + } + return version +} diff --git a/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt b/gradle-plugin/src/main/kotlin/io/spine/embedcode/gradle/InstallEmbedCodeTask.kt index 9cfe4b0..71f1900 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 @@ -28,6 +28,7 @@ package io.spine.embedcode.gradle import org.gradle.api.DefaultTask import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.Input @@ -54,14 +55,14 @@ 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 version is checked before an + * 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. */ @DisableCachingByDefault(because = "Release assets come from external URLs that may change") public abstract class InstallEmbedCodeTask : DefaultTask() { - /** An optional Embed Code release version. */ + /** An optional Embed Code release tag used verbatim; absent or empty selects latest. */ @get:Input @get:Optional public abstract val version: Property @@ -91,11 +92,15 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { @get:Input public abstract val offline: Property + /** The directory containing executables and their integrity metadata. */ + @get:Internal + public abstract val installationDirectory: DirectoryProperty + /** The installed executable used by Embed Code execution tasks. */ @get:OutputFile public abstract val executableFile: RegularFileProperty - /** Stores the release version represented by the latest executable. */ + /** Stores the release tag represented by the latest executable. */ @get:LocalState public abstract val resolvedVersionFile: RegularFileProperty @@ -116,10 +121,8 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { */ @TaskAction public fun install() { - val requestedVersion = version.orNull - if (requestedVersion != null && requestedVersion.isEmpty()) { - throw GradleException("Embed Code version must not be empty.") - } + // Validate again because consumers can configure this public task input directly. + val requestedTag = version.orNull?.let(::validateVersion)?.ifEmpty { null } val configuredSha256 = sha256.orNull?.let(::normalizeSha256) val hostOperatingSystem = operatingSystem.get() val hostArchitecture = architecture.get() @@ -131,11 +134,27 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { val platform = EmbedCodePlatform.detect(hostOperatingSystem, hostArchitecture) val asset = platform.assetName val baseUrl = trimTrailingSlashes(downloadBaseUrl.get()) - val destination = executableFile.get().asFile.toPath() - val versionFile = resolvedVersionFile.get().asFile.toPath() - val assetChecksum = assetChecksumFile.get().asFile.toPath() - val executableChecksum = executableChecksumFile.get().asFile.toPath() - val sourceIdentity = sourceIdentityFile.get().asFile.toPath() + val installationRoot = installationDirectory.get().asFile.toPath() + val destination = requireInsideInstallationDirectory( + executableFile.get().asFile.toPath(), + installationRoot, + ) + val versionFile = requireInsideInstallationDirectory( + resolvedVersionFile.get().asFile.toPath(), + installationRoot, + ) + val assetChecksum = requireInsideInstallationDirectory( + assetChecksumFile.get().asFile.toPath(), + installationRoot, + ) + val executableChecksum = requireInsideInstallationDirectory( + executableChecksumFile.get().asFile.toPath(), + installationRoot, + ) + val sourceIdentity = requireInsideInstallationDirectory( + sourceIdentityFile.get().asFile.toPath(), + installationRoot, + ) val expectedSourceIdentity = releaseAssetIdentity(baseUrl, asset) if (offline.get()) { reuseOfflineInstallation( @@ -148,7 +167,7 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { ) return } - val resolvedVersion = if (requestedVersion == null) { + val resolvedTag = if (requestedTag == null) { logger.info("Resolving the latest Embed Code release from {}.", baseUrl) try { resolveLatestVersion(baseUrl) @@ -176,14 +195,14 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { } else { null } - if (resolvedVersion != null) { - logger.info("Resolved the latest Embed Code release as {}.", resolvedVersion) + if (resolvedTag != null) { + logger.info("Resolved the latest Embed Code release as {}.", resolvedTag) } if ( ( - requestedVersion != null || - resolvedVersion != null && - readResolvedVersion(versionFile) == resolvedVersion + requestedTag != null || + resolvedTag != null && + readResolvedVersion(versionFile) == resolvedTag ) && isTrustedCachedInstallation( destination, @@ -196,25 +215,25 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { ) { logger.lifecycle( "Reusing verified Embed Code {} from {}", - selectedVersionName(requestedVersion, resolvedVersion), + selectedVersionName(requestedTag, resolvedTag), destination, ) return } - val selectedReleaseTag = if (requestedVersion != null) { - releaseTagForVersion(requestedVersion) - } else { - resolvedVersion - } + val selectedReleaseTag = requestedTag ?: resolvedTag val source = releaseAsset(baseUrl, selectedReleaseTag, asset) val download = temporaryDir.toPath().resolve(asset) val preparedExecutable = temporaryDir.toPath().resolve(platform.executableName) try { Files.createDirectories(destination.parent) - val release = requestedVersion ?: "latest release" + val release = requestedTag ?: "latest release" logger.lifecycle("Downloading Embed Code {} from {}", release, source) - download(source, download) + try { + download(source, download) + } catch (exception: GradleException) { + throw addReleaseTagMigrationHint(exception, requestedTag) + } val expectedAssetSha256 = resolveExpectedAssetSha256( configuredSha256, baseUrl, @@ -253,8 +272,8 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { writeResolvedVersion(assetChecksum, expectedAssetSha256) writeResolvedVersion(executableChecksum, preparedExecutableSha256) writeResolvedVersion(sourceIdentity, expectedSourceIdentity) - if (resolvedVersion != null) { - writeResolvedVersion(versionFile, resolvedVersion) + if (resolvedTag != null) { + writeResolvedVersion(versionFile, resolvedTag) } logger.info( "Installed Embed Code {} at {}.", @@ -310,8 +329,45 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { const val READ_TIMEOUT_MILLIS = 120_000 const val BUFFER_SIZE = 8_192 - fun selectedVersionName(requestedVersion: String?, resolvedVersion: String?): String = - requestedVersion ?: resolvedVersion ?: "latest release" + /** + * 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, + installationDirectory: Path, + ): Path { + val root = installationDirectory.toAbsolutePath().normalize() + val normalizedPath = path.toAbsolutePath().normalize() + if (normalizedPath == root || !normalizedPath.startsWith(root)) { + throw GradleException( + "Embed Code installation path `$normalizedPath` must remain inside `$root`.", + ) + } + return normalizedPath + } + + fun selectedVersionName(requestedTag: String?, resolvedTag: String?): String = + requestedTag ?: resolvedTag ?: "latest release" + + /** + * Adds an upgrade hint when an exact tag may be missing its former automatic prefix. + */ + 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, + ) + } /** * Checks both the trusted release-asset digest and cached executable contents. @@ -412,13 +468,6 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { return URI.create("$baseUrl/download/$releaseTag/$asset") } - /** - * Returns the release tag corresponding to a user-configured [version]. - */ - fun releaseTagForVersion(version: String): String { - return if (version.startsWith("v")) version else "v$version" - } - /** * Downloads [source] into [destination], reporting HTTP failures clearly. */ @@ -490,7 +539,7 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { } /** - * Returns the recorded latest release version, if available. + * Returns the recorded latest release tag, if available. */ fun readResolvedVersion(versionFile: Path): String? { return try { 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 new file mode 100644 index 0000000..4a1ab03 --- /dev/null +++ b/gradle-plugin/src/test/kotlin/io/spine/embedcode/gradle/EmbedCodeVersionSpec.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package io.spine.embedcode.gradle + +import org.gradle.api.InvalidUserDataException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +@DisplayName("`validateVersion` should") +internal class EmbedCodeVersionSpec { + + @Test + fun `accept and trim safe release tags`() { + val tags = listOf( + "v1.2.3", + "1.0.0-beta+build", + "2.0_rc1", + "latest", + ) + + tags.forEach { tag -> + assertEquals(tag, validateVersion(" $tag ")) + } + } + + @Test + fun `reject unsafe release tags`() { + val tags = listOf("../x", "a/b", "a%2f") + + tags.forEach { tag -> + assertThrows(InvalidUserDataException::class.java) { + validateVersion(tag) + } + } + } + + @Test + fun `treat an empty release tag as latest`() { + assertEquals("", validateVersion(" ")) + } +} diff --git a/version.gradle.kts b/version.gradle.kts index df540b8..2102fb6 100644 --- a/version.gradle.kts +++ b/version.gradle.kts @@ -25,4 +25,4 @@ */ /** Version of the Embed Code Gradle plugin. */ -extra.set("embedCodePluginVersion", "0.1.0") +extra.set("embedCodePluginVersion", "0.1.1")