diff --git a/README.md b/README.md index c31bbc3..a988b0c 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,48 +83,9 @@ embedCode { docsPath.set(layout.projectDirectory) } ``` +`codePath` and `namedSource(...)` are mutually exclusive. -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 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, set `version` to its exact release tag. -The plugin uses this value verbatim and does not add a `v` prefix. - -```kotlin -embedCode { - 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. Only these metadata requests use the -optional token; release asset downloads remain unauthenticated. API requests are -unauthenticated unless a token provider is configured explicitly: - -```kotlin -embedCode { - githubToken.set(providers.environmentVariable("EMBED_CODE_GITHUB_TOKEN")) -} -``` - -For CI, configure `githubToken` to avoid GitHub's unauthenticated API rate limit, -or pin both `version` and `sha256`. Pairing the digest with a fixed version keeps -the pin valid when GitHub publishes a newer release. - -The digest applies to the downloaded release asset. For macOS, this means the -ZIP archive rather than the extracted executable. Verified cache metadata is -stored under `build/embed-code`; offline mode reuses the executable only when -its current digest, release source, and platform asset still match that metadata. +### Execution Check that documentation is up to date: @@ -141,6 +104,42 @@ 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. +### Version + +By default, the plugin resolves and verifies the latest Embed Code release on +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 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: + +```kotlin +embedCode { + version.set("v1.2.4") +} +``` + +### GitHub Authorization + +API requests are unauthenticated unless a token provider is configured explicitly: + +```kotlin +embedCode { + githubToken.set(providers.environmentVariable("EMBED_CODE_GITHUB_TOKEN")) +} +``` + +For CI, configure `githubToken` to avoid GitHub's unauthenticated API rate limit +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. + ## Development Run compilation, plugin validation, and the complete test suite: 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..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 @@ -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,29 +252,113 @@ 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 locally verified Embed Code executable" + } + + @Test + 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").buildAndFail() + + Files.readString(executable) shouldBe "user-provided executable" + result.output shouldContain "no locally verified executable is available" + result.output shouldNotContain "Reusing locally verified Embed Code executable" } @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) 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() 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 locally verified installed executable" + } finally { + server.stop(0) + } + } + + @Test + 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( + downloads = downloads, + latestStatus = latestStatus, + ) + try { + writeBuildFile(downloadBaseUrl = server.releaseBaseUrl) + runner(":installEmbedCode").build() + Files.delete(installedExecutable()) + + writeBuildFile( + downloadBaseUrl = server.releaseBaseUrl, + configureSha256 = false, + ) + latestStatus.set(HTTP_UNAVAILABLE) + val result = runner(":installEmbedCode").buildAndFail() + + result.output shouldContain + "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 { + server.stop(0) + } + } + + @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) @@ -278,7 +367,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 { @@ -286,8 +379,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) } @@ -297,9 +395,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 +418,165 @@ internal class EmbedCodePluginSpec { } @Test - fun `reject a modified cached executable in offline mode`() { + fun `restore a modified installed executable from the verified cached asset`() { runner(":installEmbedCode").build() - val executableName = EmbedCodePlatform.installedExecutableName( + 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 "no locally verified executable is available" + result.output shouldNotContain "Reusing locally verified Embed Code executable" + } + + @Test + @EnabledOnOs(OS.LINUX, OS.MAC) + 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").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"), ) - Files.writeString( - projectDirectory.resolve("build/embed-code/latest/$executableName"), - "modified after verification", + 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)) + Files.delete(installedExecutable()) + + 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", + ) + Files.delete(installedExecutable()) + + 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 to restore a missing executable offline`() { + runner(":installEmbedCode").build() + Files.delete(installedExecutable()) + 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 + "no locally verified executable is available 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 @@ -364,7 +606,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 +626,48 @@ 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) + 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 ") + runner(":installEmbedCode").build() + + (releaseTagCacheKey(lowerTag) == releaseTagCacheKey(upperTag)) shouldBe false + 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 fun `keep an explicit latest tag separate from the rolling latest cache`() { runner(":installEmbedCode").build() @@ -396,7 +680,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 +743,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 +788,76 @@ 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() + Files.delete(installedExecutable()) + 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( @@ -568,6 +922,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`() { @@ -590,7 +980,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" } @@ -769,9 +1159,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 +1177,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 +1191,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. */ @@ -876,7 +1294,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( 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..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 @@ -49,8 +49,8 @@ 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. + * Tags containing `/` are unsupported because the tag is used as a release URL segment. */ 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..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 @@ -44,20 +44,24 @@ 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. + * 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() { @@ -100,19 +104,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. */ @get:LocalState public abstract val assetChecksumFile: RegularFileProperty - /** Stores the digest of the prepared executable used from the local cache. */ + /** Records the digest used to authenticate the prepared executable on reuse. */ @get:LocalState public abstract val executableChecksumFile: RegularFileProperty - /** Stores the selected release source and asset identity. */ + /** Records the selected release source and asset identity. */ @get:LocalState public abstract val sourceIdentityFile: RegularFileProperty @@ -135,10 +143,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 +169,44 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { sourceIdentityFile.get().asFile.toPath(), installationRoot, ) - val expectedSourceIdentity = releaseAssetIdentity(baseUrl, asset) + prepareInstallationDirectory(installationRoot) + requireNoSymbolicLinks(destination, installationRoot) + if (offline.get()) { reuseOfflineInstallation( + platform, destination, + cachedAsset, + versionFile, assetChecksum, executableChecksum, sourceIdentity, - expectedSourceIdentity, + requestedTag, + baseUrl, + asset, + configuredSha256, + installationRoot, + ) + 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 } @@ -172,16 +215,43 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { try { resolveLatestVersion(baseUrl) } catch (exception: GradleException) { - if ( - !isTrustedCachedInstallation( + if (isLocallyVerifiedExecutable( destination, - assetChecksum, executableChecksum, - sourceIdentity, - expectedSourceIdentity, - configuredSha256, + installationRoot, ) ) { + logger.warn( + "Could not check the latest Embed Code release ({}). " + + "Reusing the locally verified installed executable from `{}`.", + exception.message, + destination, + ) + return + } + val expectedAssetSha256 = configuredSha256 ?: throw GradleException( + "${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) + val expectedSourceIdentity = releaseAssetIdentity(baseUrl, cachedTag, asset) + val reused = installFromVerifiedAsset( + platform, + destination, + cachedAsset, + versionFile, + assetChecksum, + executableChecksum, + sourceIdentity, + cachedTag, + expectedSourceIdentity, + expectedAssetSha256, + installationRoot, + ) + if (!reused) { throw exception } logger.warn( @@ -198,19 +268,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 (installFromVerifiedAsset( + platform, destination, + cachedAsset, + versionFile, assetChecksum, executableChecksum, sourceIdentity, + selectedReleaseTag, expectedSourceIdentity, - configuredSha256, + expectedAssetSha256, + installationRoot, ) ) { logger.lifecycle( @@ -220,13 +297,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 +309,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 +317,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 = installFromVerifiedAsset( + 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 +348,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 +357,70 @@ 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)) { + if (isPreviouslyVerifiedInstallation( + destination, + versionFile, + assetChecksum, + executableChecksum, + sourceIdentity, + requestedTag, + baseUrl, + asset, + configuredSha256, + installationRoot, + ) + ) { + logger.lifecycle( + "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 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 + 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 (!installFromVerifiedAsset( + 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 +429,169 @@ public abstract class InstallEmbedCodeTask : DefaultTask() { ) } + /** + * Returns whether an installed executable matches its locally stored verified digest. + */ + private fun isLocallyVerifiedExecutable( + destination: Path, + executableChecksum: Path, + installationRoot: Path, + ): Boolean { + requireNoSymbolicLinks(destination, installationRoot) + 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. + */ + private fun isPreviouslyVerifiedInstallation( + destination: Path, + versionFile: Path, + assetChecksum: Path, + executableChecksum: Path, + sourceIdentity: Path, + requestedTag: String?, + baseUrl: String, + asset: String, + configuredSha256: String?, + installationRoot: Path, + ): Boolean { + 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 isLocallyVerifiedExecutable( + destination, + executableChecksum, + installationRoot, + ) + } + + /** + * 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. + */ + 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 installFromVerifiedAsset( + 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 +607,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 +823,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 +879,95 @@ 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, + LinkOption.NOFOLLOW_LINKS, + ).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..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 @@ -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,21 @@ 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' }) + } + } }