diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a1b072..69ccd25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - support for matching workspace agent in the URI via the agent name +- support for checking if CLI is signed ### Removed diff --git a/build.gradle.kts b/build.gradle.kts index 1e8c5cc..cdfc5e8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -63,6 +63,7 @@ dependencies { ksp(libs.moshi.codegen) implementation(libs.retrofit) implementation(libs.retrofit.moshi) + implementation(libs.bundles.bouncycastle) testImplementation(kotlin("test")) testImplementation(libs.mokk) testImplementation(libs.bundles.toolbox.plugin.api) diff --git a/gradle.properties b/gradle.properties index efbc54f..9513b30 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -version=0.4.0 +version=0.5.0 group=com.coder.toolbox name=coder-toolbox diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f925bdc..28820b1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,6 +16,7 @@ gettext = "0.7.0" plugin-structure = "3.310" mockk = "1.14.4" detekt = "1.23.8" +bouncycastle = "1.81" [libraries] toolbox-core-api = { module = "com.jetbrains.toolbox:core-api", version.ref = "toolbox-plugin-api" } @@ -34,10 +35,13 @@ retrofit-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.re plugin-structure = { module = "org.jetbrains.intellij.plugins:structure-toolbox", version.ref = "plugin-structure" } mokk = { module = "io.mockk:mockk", version.ref = "mockk" } marketplace-client = { module = "org.jetbrains.intellij:plugin-repository-rest-client", version.ref = "marketplace-client" } +bouncycastle-bcpg = { module = "org.bouncycastle:bcpg-jdk18on", version.ref = "bouncycastle" } +bouncycastle-bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" } [bundles] serialization = ["serialization-core", "serialization-json", "serialization-json-okio"] toolbox-plugin-api = ["toolbox-core-api", "toolbox-ui-api", "toolbox-remote-dev-api"] +bouncycastle = ["bouncycastle-bcpg", "bouncycastle-bcprov"] [plugins] kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index be4c40a..3e3172a 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -35,6 +35,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.selects.onTimeout import kotlinx.coroutines.selects.select import java.net.URI +import java.util.UUID import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource @@ -302,31 +303,51 @@ class CoderRemoteProvider( * Handle incoming links (like from the dashboard). */ override suspend fun handleUri(uri: URI) { - linkHandler.handle( - uri, shouldDoAutoSetup(), - { - coderHeaderPage.isBusyCreatingNewEnvironment.update { - true + try { + linkHandler.handle( + uri, shouldDoAutoSetup(), + { + coderHeaderPage.isBusyCreatingNewEnvironment.update { + true + } + }, + { + coderHeaderPage.isBusyCreatingNewEnvironment.update { + false + } } - }, - { - coderHeaderPage.isBusyCreatingNewEnvironment.update { + ) { restClient, cli -> + // stop polling and de-initialize resources + close() + isInitialized.update { false } + // start initialization with the new settings + this@CoderRemoteProvider.client = restClient + coderHeaderPage.setTitle(context.i18n.pnotr(restClient.url.toString())) + + environments.showLoadingMessage() + pollJob = poll(restClient, cli) + isInitialized.waitForTrue() } - ) { restClient, cli -> - // stop polling and de-initialize resources - close() - isInitialized.update { + } catch (ex: Exception) { + context.logger.error(ex, "") + val textError = if (ex is APIResponseException) { + if (!ex.reason.isNullOrBlank()) { + ex.reason + } else ex.message + } else ex.message + + context.ui.showSnackbar( + UUID.randomUUID().toString(), + context.i18n.ptrl("Error encountered while handling Coder URI"), + context.i18n.pnotr(textError ?: ""), + context.i18n.ptrl("Dismiss") + ) + } finally { + coderHeaderPage.isBusyCreatingNewEnvironment.update { false } - // start initialization with the new settings - this@CoderRemoteProvider.client = restClient - coderHeaderPage.setTitle(context.i18n.pnotr(restClient.url.toString())) - - environments.showLoadingMessage() - pollJob = poll(restClient, cli) - isInitialized.waitForTrue() } } diff --git a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt index e4ef501..177ba81 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt @@ -1,40 +1,43 @@ package com.coder.toolbox.cli import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.cli.downloader.CoderDownloadApi +import com.coder.toolbox.cli.downloader.CoderDownloadService +import com.coder.toolbox.cli.downloader.DownloadResult +import com.coder.toolbox.cli.downloader.DownloadResult.Downloaded import com.coder.toolbox.cli.ex.MissingVersionException -import com.coder.toolbox.cli.ex.ResponseException import com.coder.toolbox.cli.ex.SSHConfigFormatException +import com.coder.toolbox.cli.ex.UnsignedBinaryExecutionDeniedException +import com.coder.toolbox.cli.gpg.GPGVerifier +import com.coder.toolbox.cli.gpg.VerificationResult +import com.coder.toolbox.cli.gpg.VerificationResult.Failed +import com.coder.toolbox.cli.gpg.VerificationResult.Invalid import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceAgent -import com.coder.toolbox.settings.ReadOnlyCoderSettings +import com.coder.toolbox.settings.SignatureFallbackStrategy.ALLOW import com.coder.toolbox.util.CoderHostnameVerifier import com.coder.toolbox.util.InvalidVersionException -import com.coder.toolbox.util.OS import com.coder.toolbox.util.SemVer import com.coder.toolbox.util.coderSocketFactory +import com.coder.toolbox.util.coderTrustManagers import com.coder.toolbox.util.escape import com.coder.toolbox.util.escapeSubcommand -import com.coder.toolbox.util.getHeaders -import com.coder.toolbox.util.getOS import com.coder.toolbox.util.safeHost -import com.coder.toolbox.util.sha1 -import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonDataException import com.squareup.moshi.Moshi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient import org.zeroturnaround.exec.ProcessExecutor +import retrofit2.Retrofit import java.io.EOFException -import java.io.FileInputStream import java.io.FileNotFoundException -import java.net.ConnectException -import java.net.HttpURLConnection import java.net.URL import java.nio.file.Files import java.nio.file.Path -import java.nio.file.StandardOpenOption -import java.util.zip.GZIPInputStream -import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.X509TrustManager /** * Version output from the CLI's version command. @@ -44,7 +47,6 @@ internal data class Version( @Json(name = "version") val version: String, ) -private const val DOWNLOADING_CODER_CLI = "Downloading Coder CLI..." /** * Do as much as possible to get a valid, up-to-date CLI. @@ -58,14 +60,19 @@ private const val DOWNLOADING_CODER_CLI = "Downloading Coder CLI..." * 6. Since the binary directory can be read-only, if downloading fails, start * from step 2 with the data directory. */ -fun ensureCLI( +suspend fun ensureCLI( context: CoderToolboxContext, deploymentURL: URL, buildVersion: String, showTextProgress: (String) -> Unit ): CoderCLIManager { + fun reportProgress(msg: String) { + showTextProgress(msg) + context.logger.info(msg) + } + val settings = context.settingsStore.readOnly() - val cli = CoderCLIManager(deploymentURL, context.logger, settings) + val cli = CoderCLIManager(context, deploymentURL) // Short-circuit if we already have the expected version. This // lets us bypass the 304 which is slower and may not be @@ -74,13 +81,13 @@ fun ensureCLI( // the 304 method. val cliMatches = cli.matchesVersion(buildVersion) if (cliMatches == true) { + reportProgress("Local CLI version matches server version: $buildVersion") return cli } // If downloads are enabled download the new version. if (settings.enableDownloads) { - context.logger.info(DOWNLOADING_CODER_CLI) - showTextProgress(DOWNLOADING_CODER_CLI) + reportProgress("Downloading Coder CLI...") try { cli.download(buildVersion, showTextProgress) return cli @@ -95,15 +102,15 @@ fun ensureCLI( } // Try falling back to the data directory. - val dataCLI = CoderCLIManager(deploymentURL, context.logger, settings, true) + val dataCLI = CoderCLIManager(context, deploymentURL, true) val dataCLIMatches = dataCLI.matchesVersion(buildVersion) if (dataCLIMatches == true) { + reportProgress("Local CLI version from data directory matches server version: $buildVersion") return dataCLI } if (settings.enableDownloads) { - context.logger.info(DOWNLOADING_CODER_CLI) - showTextProgress(DOWNLOADING_CODER_CLI) + reportProgress("Downloading Coder CLI to the data directory...") dataCLI.download(buildVersion, showTextProgress) return dataCLI } @@ -126,122 +133,162 @@ data class Features( * Manage the CLI for a single deployment. */ class CoderCLIManager( + private val context: CoderToolboxContext, // The URL of the deployment this CLI is for. private val deploymentURL: URL, - private val logger: Logger, - // Plugin configuration. - private val settings: ReadOnlyCoderSettings, // If the binary directory is not writable, this can be used to force the // manager to download to the data directory instead. - forceDownloadToData: Boolean = false, + private val forceDownloadToData: Boolean = false, ) { - val remoteBinaryURL: URL = settings.binSource(deploymentURL) - val localBinaryPath: Path = settings.binPath(deploymentURL, forceDownloadToData) - val coderConfigPath: Path = settings.dataDir(deploymentURL).resolve("config") + private val downloader = createDownloadService() + private val gpgVerifier = GPGVerifier(context) + + val remoteBinaryURL: URL = context.settingsStore.binSource(deploymentURL) + val localBinaryPath: Path = context.settingsStore.binPath(deploymentURL, forceDownloadToData) + val coderConfigPath: Path = context.settingsStore.dataDir(deploymentURL).resolve("config") + + private fun createDownloadService(): CoderDownloadService { + val okHttpClient = OkHttpClient.Builder() + .sslSocketFactory( + coderSocketFactory(context.settingsStore.tls), + coderTrustManagers(context.settingsStore.tls.caPath)[0] as X509TrustManager + ) + .hostnameVerifier(CoderHostnameVerifier(context.settingsStore.tls.altHostname)) + .build() + + val retrofit = Retrofit.Builder() + .baseUrl(deploymentURL.toString()) + .client(okHttpClient) + .build() + + val service = retrofit.create(CoderDownloadApi::class.java) + return CoderDownloadService(context, service, deploymentURL, forceDownloadToData) + } /** * Download the CLI from the deployment if necessary. */ - fun download(buildVersion: String, showTextProgress: (String) -> Unit): Boolean { - val eTag = getBinaryETag() - val conn = remoteBinaryURL.openConnection() as HttpURLConnection - if (!settings.headerCommand.isNullOrBlank()) { - val headersFromHeaderCommand = getHeaders(deploymentURL, settings.headerCommand) - for ((key, value) in headersFromHeaderCommand) { - conn.setRequestProperty(key, value) + suspend fun download(buildVersion: String, showTextProgress: (String) -> Unit): Boolean { + try { + val cliResult = withContext(Dispatchers.IO) { + downloader.downloadCli(buildVersion, showTextProgress) + }.let { result -> + when { + result.isSkipped() -> return false + result.isNotFound() -> throw IllegalStateException("Could not find Coder CLI") + result.isFailed() -> throw (result as DownloadResult.Failed).error + else -> result as Downloaded + } } - } - if (eTag != null) { - logger.info("Found existing binary at $localBinaryPath; calculated hash as $eTag") - conn.setRequestProperty("If-None-Match", "\"$eTag\"") - } - conn.setRequestProperty("Accept-Encoding", "gzip") - if (conn is HttpsURLConnection) { - conn.sslSocketFactory = coderSocketFactory(settings.tls) - conn.hostnameVerifier = CoderHostnameVerifier(settings.tls.altHostname) - } - try { - conn.connect() - logger.info("GET ${conn.responseCode} $remoteBinaryURL") - when (conn.responseCode) { - HttpURLConnection.HTTP_OK -> { - logger.info("Downloading binary to $localBinaryPath") - Files.deleteIfExists(localBinaryPath) - Files.createDirectories(localBinaryPath.parent) - val outputStream = Files.newOutputStream( - localBinaryPath, - StandardOpenOption.CREATE, - StandardOpenOption.TRUNCATE_EXISTING - ) - val sourceStream = if (conn.isGzip()) GZIPInputStream(conn.inputStream) else conn.inputStream - - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - var bytesRead: Int - var totalRead = 0L - - sourceStream.use { source -> - outputStream.use { sink -> - while (source.read(buffer).also { bytesRead = it } != -1) { - sink.write(buffer, 0, bytesRead) - totalRead += bytesRead - showTextProgress("${settings.defaultCliBinaryNameByOsAndArch} $buildVersion - ${totalRead.toHumanReadableSize()} downloaded") - } + var signatureResult = withContext(Dispatchers.IO) { + downloader.downloadSignature(showTextProgress) + } + + if (signatureResult.isNotDownloaded()) { + if (context.settingsStore.fallbackOnCoderForSignatures == ALLOW) { + context.logger.info("Trying to download signature file from releases.coder.com") + signatureResult = withContext(Dispatchers.IO) { + downloader.downloadReleasesSignature(buildVersion, showTextProgress) + } + + // if we could still not download it, ask the user if he accepts the risk + if (signatureResult.isNotDownloaded()) { + val acceptsUnsignedBinary = context.ui.showYesNoPopup( + context.i18n.ptrl("Security Warning"), + context.i18n.pnotr("Could not fetch any signatures for ${cliResult.source} from releases.coder.com. Would you like to run it anyway?"), + context.i18n.ptrl("Accept"), + context.i18n.ptrl("Abort"), + ) + + if (acceptsUnsignedBinary) { + downloader.commit() + return true + } else { + throw UnsignedBinaryExecutionDeniedException("Running unsigned CLI from ${cliResult.source} was denied by the user") } } - if (getOS() != OS.WINDOWS) { - localBinaryPath.toFile().setExecutable(true) + } else { + // we are not allowed to fetch signatures from releases.coder.com + // so we will ask the user if he wants to continue + val acceptsUnsignedBinary = context.ui.showYesNoPopup( + context.i18n.ptrl("Security Warning"), + context.i18n.pnotr("No signatures were found for ${cliResult.source} and fallback to releases.coder.com is not allowed. Would you like to run it anyway?"), + context.i18n.ptrl("Accept"), + context.i18n.ptrl("Abort"), + ) + + if (acceptsUnsignedBinary) { + downloader.commit() + return true + } else { + throw UnsignedBinaryExecutionDeniedException("Running unsigned CLI from ${cliResult.source} was denied by the user") } - return true } + } + + // we have the cli, and signature is downloaded, let's verify the signature + signatureResult = signatureResult as Downloaded + gpgVerifier.verifySignature(cliResult.dst, signatureResult.dst).let { result -> + when { + result.isValid() -> { + downloader.commit() + return true + } - HttpURLConnection.HTTP_NOT_MODIFIED -> { - logger.info("Using cached binary at $localBinaryPath") - showTextProgress("Using cached binary") - return false + else -> { + logFailure(result, cliResult, signatureResult) + // prompt the user if he wants to accept the risk + val shouldRunAnyway = context.ui.showYesNoPopup( + context.i18n.ptrl("Security Warning"), + context.i18n.pnotr("Could not verify the authenticity of the ${cliResult.source}, it may be tampered with. Would you like to run it anyway?"), + context.i18n.ptrl("Run anyway"), + context.i18n.ptrl("Abort"), + ) + + if (shouldRunAnyway) { + downloader.commit() + return true + } else { + throw UnsignedBinaryExecutionDeniedException("Running unverified CLI from ${cliResult.source} was denied by the user") + } + } } } - } catch (e: ConnectException) { - // Add the URL so this is more easily debugged. - throw ConnectException("${e.message} to $remoteBinaryURL") } finally { - conn.disconnect() + downloader.cleanup() } - throw ResponseException("Unexpected response from $remoteBinaryURL", conn.responseCode) } - private fun HttpURLConnection.isGzip(): Boolean = this.contentEncoding.equals("gzip", ignoreCase = true) - - fun Long.toHumanReadableSize(): String { - if (this < 1024) return "$this B" - - val kb = this / 1024.0 - if (kb < 1024) return String.format("%.1f KB", kb) - - val mb = kb / 1024.0 - if (mb < 1024) return String.format("%.1f MB", mb) + private fun logFailure( + result: VerificationResult, + cliResult: Downloaded, + signatureResult: Downloaded + ) { + when { + result.isInvalid() -> { + val reason = (result as Invalid).reason + context.logger.error("Signature of ${cliResult.dst} is invalid." + reason?.let { " Reason: $it" } + .orEmpty()) + } - val gb = mb / 1024.0 - return String.format("%.1f GB", gb) - } + result.signatureIsNotFound() -> { + context.logger.error("Can't verify signature of ${cliResult.dst} because ${signatureResult.dst} does not exist") + } - /** - * Return the entity tag for the binary on disk, if any. - */ - private fun getBinaryETag(): String? = try { - sha1(FileInputStream(localBinaryPath.toFile())) - } catch (e: FileNotFoundException) { - null - } catch (e: Exception) { - logger.warn(e, "Unable to calculate hash for $localBinaryPath") - null + else -> { + UnsignedBinaryExecutionDeniedException((result as Failed).error.message) + val failure = result as DownloadResult.Failed + context.logger.error(failure.error, "Failed to verify signature for ${cliResult.dst}") + } + } } /** * Use the provided token to initializeSession the CLI. */ fun login(token: String): String { - logger.info("Storing CLI credentials in $coderConfigPath") + context.logger.info("Storing CLI credentials in $coderConfigPath") return exec( "login", deploymentURL.toString(), @@ -261,7 +308,7 @@ class CoderCLIManager( wsWithAgents: Set>, feats: Features = features, ) { - logger.info("Configuring SSH config at ${settings.sshConfigPath}") + context.logger.info("Configuring SSH config at ${context.settingsStore.sshConfigPath}") writeSSHConfig(modifySSHConfig(readSSHConfig(), wsWithAgents, feats)) } @@ -269,8 +316,8 @@ class CoderCLIManager( * Return the contents of the SSH config or null if it does not exist. */ private fun readSSHConfig(): String? = try { - Path.of(settings.sshConfigPath).toFile().readText() - } catch (e: FileNotFoundException) { + Path.of(context.settingsStore.sshConfigPath).toFile().readText() + } catch (_: FileNotFoundException) { null } @@ -301,21 +348,21 @@ class CoderCLIManager( // always use the correct URL. "--url", escape(deploymentURL.toString()), - if (!settings.headerCommand.isNullOrBlank()) "--header-command" else null, - if (!settings.headerCommand.isNullOrBlank()) escapeSubcommand(settings.headerCommand!!) else null, + if (!context.settingsStore.headerCommand.isNullOrBlank()) "--header-command" else null, + if (!context.settingsStore.headerCommand.isNullOrBlank()) escapeSubcommand(context.settingsStore.headerCommand!!) else null, "ssh", "--stdio", - if (settings.disableAutostart && feats.disableAutostart) "--disable-autostart" else null, - "--network-info-dir ${escape(settings.networkInfoDir)}" + if (context.settingsStore.disableAutostart && feats.disableAutostart) "--disable-autostart" else null, + "--network-info-dir ${escape(context.settingsStore.networkInfoDir)}" ) val proxyArgs = baseArgs + listOfNotNull( - if (!settings.sshLogDirectory.isNullOrBlank()) "--log-dir" else null, - if (!settings.sshLogDirectory.isNullOrBlank()) escape(settings.sshLogDirectory!!) else null, + if (!context.settingsStore.sshLogDirectory.isNullOrBlank()) "--log-dir" else null, + if (!context.settingsStore.sshLogDirectory.isNullOrBlank()) escape(context.settingsStore.sshLogDirectory!!) else null, if (feats.reportWorkspaceUsage) "--usage-app=jetbrains" else null, ) val extraConfig = - if (!settings.sshConfigOptions.isNullOrBlank()) { - "\n" + settings.sshConfigOptions!!.prependIndent(" ") + if (!context.settingsStore.sshConfigOptions.isNullOrBlank()) { + "\n" + context.settingsStore.sshConfigOptions!!.prependIndent(" ") } else { "" } @@ -327,7 +374,7 @@ class CoderCLIManager( SetEnv CODER_SSH_SESSION_TYPE=JetBrains """.trimIndent() - val blockContent = if (settings.isSshWildcardConfigEnabled && feats.wildcardSsh) { + val blockContent = if (context.settingsStore.isSshWildcardConfigEnabled && feats.wildcardSsh) { startBlock + System.lineSeparator() + """ Host ${getHostnamePrefix(deploymentURL)}--* @@ -357,7 +404,7 @@ class CoderCLIManager( } if (contents == null) { - logger.info("No existing SSH config to modify") + context.logger.info("No existing SSH config to modify") return blockContent + System.lineSeparator() } @@ -365,12 +412,12 @@ class CoderCLIManager( val end = "$endBlock(\\s*)".toRegex().find(contents) if (start == null && end == null && isRemoving) { - logger.info("No workspaces and no existing config blocks to remove") + context.logger.info("No workspaces and no existing config blocks to remove") return null } if (start == null && end == null) { - logger.info("Appending config block") + context.logger.info("Appending config block") val toAppend = if (contents.isEmpty()) { blockContent @@ -394,7 +441,7 @@ class CoderCLIManager( } if (isRemoving) { - logger.info("No workspaces; removing config block") + context.logger.info("No workspaces; removing config block") return listOf( contents.substring(0, start.range.first), // Need to keep the trailing newline(s) if we are not at the @@ -405,7 +452,7 @@ class CoderCLIManager( ).joinToString("") } - logger.info("Replacing existing config block") + context.logger.info("Replacing existing config block") return listOf( contents.substring(0, start.range.first), start.groupValues[1], // Leading newline(s). @@ -420,14 +467,14 @@ class CoderCLIManager( */ private fun writeSSHConfig(contents: String?) { if (contents != null) { - if (!settings.sshConfigPath.isNullOrBlank()) { - val sshConfPath = Path.of(settings.sshConfigPath) + if (!context.settingsStore.sshConfigPath.isNullOrBlank()) { + val sshConfPath = Path.of(context.settingsStore.sshConfigPath) sshConfPath.parent.toFile().mkdirs() sshConfPath.toFile().writeText(contents) } // The Coder cli will *not* create the log directory. - if (!settings.sshLogDirectory.isNullOrBlank()) { - Path.of(settings.sshLogDirectory).toFile().mkdirs() + if (!context.settingsStore.sshLogDirectory.isNullOrBlank()) { + Path.of(context.settingsStore.sshLogDirectory).toFile().mkdirs() } } } @@ -460,14 +507,14 @@ class CoderCLIManager( } catch (e: Exception) { when (e) { is InvalidVersionException -> { - logger.info("Got invalid version from $localBinaryPath: ${e.message}") + context.logger.info("Got invalid version from $localBinaryPath: ${e.message}") } else -> { - // An error here most likely means the CLI does not exist or + // An error here most likely means the CLI does not exist, or // it executed successfully but output no version which // suggests it is not the right binary. - logger.info("Unable to determine $localBinaryPath version: ${e.message}") + context.logger.info("Unable to determine $localBinaryPath version: ${e.message}") } } null @@ -480,17 +527,18 @@ class CoderCLIManager( * version could not be parsed. */ fun matchesVersion(rawBuildVersion: String): Boolean? { + if (Files.notExists(localBinaryPath)) return null val cliVersion = tryVersion() ?: return null val buildVersion = try { SemVer.parse(rawBuildVersion) } catch (e: InvalidVersionException) { - logger.info("Got invalid build version: $rawBuildVersion") + context.logger.info("Got invalid build version: $rawBuildVersion") return null } val matches = cliVersion == buildVersion - logger.info("$localBinaryPath version $cliVersion matches $buildVersion: $matches") + context.logger.info("$localBinaryPath version $cliVersion matches $buildVersion: $matches") return matches } @@ -498,13 +546,13 @@ class CoderCLIManager( val stdout = ProcessExecutor() .command(localBinaryPath.toString(), *args) - .environment("CODER_HEADER_COMMAND", settings.headerCommand) + .environment("CODER_HEADER_COMMAND", context.settingsStore.headerCommand) .exitValues(0) .readOutput(true) .execute() .outputUTF8() val redactedArgs = listOf(*args).joinToString(" ").replace(tokenRegex, "--token ") - logger.info("`$localBinaryPath $redactedArgs`: $stdout") + context.logger.info("`$localBinaryPath $redactedArgs`: $stdout") return stdout } @@ -523,7 +571,7 @@ class CoderCLIManager( } fun getHostname(url: URL, ws: Workspace, agent: WorkspaceAgent): String { - return if (settings.isSshWildcardConfigEnabled && features.wildcardSsh) { + return if (context.settingsStore.isSshWildcardConfigEnabled && features.wildcardSsh) { "${getHostnamePrefix(url)}--${ws.ownerName}--${ws.name}.${agent.name}" } else { "coder-jetbrains-toolbox--${ws.ownerName}--${ws.name}.${agent.name}--${url.safeHost()}" diff --git a/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadApi.kt b/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadApi.kt new file mode 100644 index 0000000..4e27569 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadApi.kt @@ -0,0 +1,29 @@ +package com.coder.toolbox.cli.downloader + +import okhttp3.ResponseBody +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.HeaderMap +import retrofit2.http.Streaming +import retrofit2.http.Url + +/** + * Retrofit API for downloading CLI + */ +interface CoderDownloadApi { + @GET + @Streaming + suspend fun downloadCli( + @Url url: String, + @Header("If-None-Match") eTag: String? = null, + @HeaderMap headers: Map = emptyMap(), + @Header("Accept-Encoding") acceptEncoding: String = "gzip", + ): Response + + @GET + suspend fun downloadSignature( + @Url url: String, + @HeaderMap headers: Map = emptyMap() + ): Response +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt b/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt new file mode 100644 index 0000000..03e3a4d --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/cli/downloader/CoderDownloadService.kt @@ -0,0 +1,233 @@ +package com.coder.toolbox.cli.downloader + +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.cli.ex.ResponseException +import com.coder.toolbox.util.OS +import com.coder.toolbox.util.SemVer +import com.coder.toolbox.util.getHeaders +import com.coder.toolbox.util.getOS +import com.coder.toolbox.util.sha1 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.ResponseBody +import retrofit2.Response +import java.io.FileInputStream +import java.net.HttpURLConnection.HTTP_NOT_FOUND +import java.net.HttpURLConnection.HTTP_NOT_MODIFIED +import java.net.HttpURLConnection.HTTP_OK +import java.net.URI +import java.net.URL +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import java.util.zip.GZIPInputStream +import kotlin.io.path.name +import kotlin.io.path.notExists + +/** + * Handles the download steps of Coder CLI + */ +class CoderDownloadService( + private val context: CoderToolboxContext, + private val downloadApi: CoderDownloadApi, + private val deploymentUrl: URL, + forceDownloadToData: Boolean, +) { + private val remoteBinaryURL: URL = context.settingsStore.binSource(deploymentUrl) + private val cliFinalDst: Path = context.settingsStore.binPath(deploymentUrl, forceDownloadToData) + private val cliTempDst: Path = cliFinalDst.resolveSibling("${cliFinalDst.name}.tmp") + + suspend fun downloadCli(buildVersion: String, showTextProgress: (String) -> Unit): DownloadResult { + val eTag = calculateLocalETag() + if (eTag != null) { + context.logger.info("Found existing binary at $cliFinalDst; calculated hash as $eTag") + } + val response = downloadApi.downloadCli( + url = remoteBinaryURL.toString(), + eTag = eTag?.let { "\"$it\"" }, + headers = getRequestHeaders() + ) + + return when (response.code()) { + HTTP_OK -> { + context.logger.info("Downloading binary to temporary $cliTempDst") + response.saveToDisk(cliTempDst, showTextProgress, buildVersion)?.makeExecutable() + DownloadResult.Downloaded(remoteBinaryURL, cliTempDst) + } + + HTTP_NOT_MODIFIED -> { + context.logger.info("Using cached binary at $cliFinalDst") + showTextProgress("Using cached binary") + DownloadResult.Skipped + } + + else -> { + throw ResponseException( + "Unexpected response from $remoteBinaryURL", + response.code() + ) + } + } + } + + /** + * Renames the temporary binary file to its original destination name. + * The implementation will override sibling file that has the original + * destination name. + */ + suspend fun commit(): Path { + return withContext(Dispatchers.IO) { + context.logger.info("Renaming binary from $cliTempDst to $cliFinalDst") + Files.move(cliTempDst, cliFinalDst, StandardCopyOption.REPLACE_EXISTING) + cliFinalDst.makeExecutable() + cliFinalDst + } + } + + /** + * Cleans up the temporary binary file if it exists. + */ + suspend fun cleanup() { + withContext(Dispatchers.IO) { + runCatching { Files.deleteIfExists(cliTempDst) } + .onFailure { ex -> + context.logger.warn(ex, "Failed to delete temporary CLI file: $cliTempDst") + } + } + } + + private fun calculateLocalETag(): String? { + return try { + if (cliFinalDst.notExists()) { + return null + } + sha1(FileInputStream(cliFinalDst.toFile())) + } catch (e: Exception) { + context.logger.warn(e, "Unable to calculate hash for $cliFinalDst") + null + } + } + + private fun getRequestHeaders(): Map { + return if (context.settingsStore.headerCommand.isNullOrBlank()) { + emptyMap() + } else { + getHeaders(deploymentUrl, context.settingsStore.headerCommand) + } + } + + private fun Response.saveToDisk( + localPath: Path, + showTextProgress: (String) -> Unit, + buildVersion: String? = null + ): Path? { + val responseBody = this.body() ?: return null + Files.deleteIfExists(localPath) + Files.createDirectories(localPath.parent) + + val outputStream = Files.newOutputStream( + localPath, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING + ) + val contentEncoding = this.headers()["Content-Encoding"] + val sourceStream = if (contentEncoding?.contains("gzip", ignoreCase = true) == true) { + GZIPInputStream(responseBody.byteStream()) + } else { + responseBody.byteStream() + } + + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytesRead: Int + var totalRead = 0L + // local path is a temporary filename, reporting the progress with the real name + val binaryName = localPath.name.removeSuffix(".tmp") + sourceStream.use { source -> + outputStream.use { sink -> + while (source.read(buffer).also { bytesRead = it } != -1) { + sink.write(buffer, 0, bytesRead) + totalRead += bytesRead + val prettyBuildVersion = buildVersion ?: "" + showTextProgress( + "$binaryName $prettyBuildVersion - ${totalRead.toHumanReadableSize()} downloaded" + ) + } + } + } + return cliFinalDst + } + + + private fun Path.makeExecutable() { + if (getOS() != OS.WINDOWS) { + context.logger.info("Making $this executable...") + this.toFile().setExecutable(true) + } + } + + private fun Long.toHumanReadableSize(): String { + if (this < 1024) return "$this B" + + val kb = this / 1024.0 + if (kb < 1024) return String.format("%.1f KB", kb) + + val mb = kb / 1024.0 + if (mb < 1024) return String.format("%.1f MB", mb) + + val gb = mb / 1024.0 + return String.format("%.1f GB", gb) + } + + suspend fun downloadSignature(showTextProgress: (String) -> Unit): DownloadResult { + return downloadSignature(remoteBinaryURL, showTextProgress, getRequestHeaders()) + } + + private suspend fun downloadSignature( + url: URL, + showTextProgress: (String) -> Unit, + headers: Map = emptyMap() + ): DownloadResult { + val signatureURL = url.toURI().resolve(context.settingsStore.defaultSignatureNameByOsAndArch).toURL() + val localSignaturePath = cliFinalDst.parent.resolve(context.settingsStore.defaultSignatureNameByOsAndArch) + context.logger.info("Downloading signature from $signatureURL") + + val response = downloadApi.downloadSignature( + url = signatureURL.toString(), + headers = headers + ) + + return when (response.code()) { + HTTP_OK -> { + response.saveToDisk(localSignaturePath, showTextProgress) + DownloadResult.Downloaded(signatureURL, localSignaturePath) + } + + HTTP_NOT_FOUND -> { + context.logger.warn("Signature file not found at $signatureURL") + DownloadResult.NotFound + } + + else -> { + DownloadResult.Failed( + ResponseException( + "Failed to download signature from $signatureURL", + response.code() + ) + ) + } + } + + } + + suspend fun downloadReleasesSignature( + buildVersion: String, + showTextProgress: (String) -> Unit + ): DownloadResult { + val semVer = SemVer.parse(buildVersion) + return downloadSignature( + URI.create("https://releases.coder.com/coder-cli/${semVer.major}.${semVer.minor}.${semVer.patch}/").toURL(), + showTextProgress + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/cli/downloader/DownloadResult.kt b/src/main/kotlin/com/coder/toolbox/cli/downloader/DownloadResult.kt new file mode 100644 index 0000000..29d4fda --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/cli/downloader/DownloadResult.kt @@ -0,0 +1,23 @@ +package com.coder.toolbox.cli.downloader + +import java.net.URL +import java.nio.file.Path + + +/** + * Result of a download operation + */ +sealed class DownloadResult { + object Skipped : DownloadResult() + object NotFound : DownloadResult() + data class Downloaded(val source: URL, val dst: Path) : DownloadResult() + data class Failed(val error: Exception) : DownloadResult() + + fun isSkipped(): Boolean = this is Skipped + + fun isNotFound(): Boolean = this is NotFound + + fun isFailed(): Boolean = this is Failed + + fun isNotDownloaded(): Boolean = this !is Downloaded +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/cli/ex/Exceptions.kt b/src/main/kotlin/com/coder/toolbox/cli/ex/Exceptions.kt index d3ca3a4..9fdff54 100644 --- a/src/main/kotlin/com/coder/toolbox/cli/ex/Exceptions.kt +++ b/src/main/kotlin/com/coder/toolbox/cli/ex/Exceptions.kt @@ -5,3 +5,5 @@ class ResponseException(message: String, val code: Int) : Exception(message) class SSHConfigFormatException(message: String) : Exception(message) class MissingVersionException(message: String) : Exception(message) + +class UnsignedBinaryExecutionDeniedException(message: String?) : Exception(message) \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/cli/gpg/GPGVerifier.kt b/src/main/kotlin/com/coder/toolbox/cli/gpg/GPGVerifier.kt new file mode 100644 index 0000000..490b48e --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/cli/gpg/GPGVerifier.kt @@ -0,0 +1,137 @@ +package com.coder.toolbox.cli.gpg + +import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.cli.gpg.VerificationResult.Failed +import com.coder.toolbox.cli.gpg.VerificationResult.Invalid +import com.coder.toolbox.cli.gpg.VerificationResult.SignatureNotFound +import com.coder.toolbox.cli.gpg.VerificationResult.Valid +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.bouncycastle.bcpg.ArmoredInputStream +import org.bouncycastle.openpgp.PGPException +import org.bouncycastle.openpgp.PGPPublicKey +import org.bouncycastle.openpgp.PGPPublicKeyRing +import org.bouncycastle.openpgp.PGPPublicKeyRingCollection +import org.bouncycastle.openpgp.PGPSignatureList +import org.bouncycastle.openpgp.PGPUtil +import org.bouncycastle.openpgp.jcajce.JcaPGPObjectFactory +import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator +import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentVerifierBuilderProvider +import java.io.ByteArrayInputStream +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.inputStream + +class GPGVerifier( + private val context: CoderToolboxContext, +) { + + suspend fun verifySignature( + cli: Path, + signature: Path, + ): VerificationResult { + return try { + if (!Files.exists(signature)) { + context.logger.warn("Signature file not found, skipping verification") + return SignatureNotFound + } + + val (signatureBytes, publicKeyRing) = withContext(Dispatchers.IO) { + val signatureBytes = Files.readAllBytes(signature) + val publicKeyRing = getCoderPublicKeyRings() + + Pair(signatureBytes, publicKeyRing) + } + return verifyDetachedSignature( + cliPath = cli, + signatureBytes = signatureBytes, + publicKeyRings = publicKeyRing + ) + } catch (e: Exception) { + context.logger.error(e, "GPG signature verification failed") + Failed(e) + } + } + + private fun getCoderPublicKeyRings(): List { + try { + val coderPublicKey = javaClass.getResourceAsStream("/META-INF/trusted-keys/pgp-public.key") + ?.readAllBytes() ?: throw IllegalStateException("Trusted public key not found") + return loadPublicKeyRings(coderPublicKey) + } catch (e: Exception) { + throw PGPException("Failed to load Coder public GPG key", e) + } + } + + /** + * Load public key rings from bytes + */ + fun loadPublicKeyRings(publicKeyBytes: ByteArray): List { + return try { + val keyInputStream = ArmoredInputStream(ByteArrayInputStream(publicKeyBytes)) + val keyRingCollection = PGPPublicKeyRingCollection( + PGPUtil.getDecoderStream(keyInputStream), + JcaKeyFingerprintCalculator() + ) + keyRingCollection.keyRings.asSequence().toList() + } catch (e: Exception) { + throw PGPException("Failed to load public key ring", e) + } + } + + /** + * Verify a detached GPG signature + */ + fun verifyDetachedSignature( + cliPath: Path, + signatureBytes: ByteArray, + publicKeyRings: List + ): VerificationResult { + try { + val signatureInputStream = ArmoredInputStream(ByteArrayInputStream(signatureBytes)) + val pgpObjectFactory = JcaPGPObjectFactory(signatureInputStream) + val signatureList = pgpObjectFactory.nextObject() as? PGPSignatureList + ?: throw PGPException("Invalid signature format") + + if (signatureList.isEmpty) { + return Invalid("No signatures found in signature file") + } + + val signature = signatureList[0] + val publicKey = findPublicKey(publicKeyRings, signature.keyID) + ?: throw PGPException("Public key not found for signature") + + signature.init(JcaPGPContentVerifierBuilderProvider(), publicKey) + cliPath.inputStream().use { fileStream -> + val buffer = ByteArray(8192) + var bytesRead: Int + while (fileStream.read(buffer).also { bytesRead = it } != -1) { + signature.update(buffer, 0, bytesRead) + } + } + + val isValid = signature.verify() + context.logger.info("GPG signature verification result: $isValid") + if (isValid) { + return Valid + } + return Invalid() + } catch (e: Exception) { + context.logger.error(e, "GPG signature verification failed") + return Failed(e) + } + } + + /** + * Find a public key across all key rings in the collection + */ + private fun findPublicKey( + keyRings: List, + keyId: Long + ): PGPPublicKey? { + keyRings.forEach { keyRing -> + keyRing.getPublicKey(keyId)?.let { return it } + } + return null + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/cli/gpg/VerificationResult.kt b/src/main/kotlin/com/coder/toolbox/cli/gpg/VerificationResult.kt new file mode 100644 index 0000000..eafafcd --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/cli/gpg/VerificationResult.kt @@ -0,0 +1,15 @@ +package com.coder.toolbox.cli.gpg + +/** + * Result of signature verification + */ +sealed class VerificationResult { + object Valid : VerificationResult() + data class Invalid(val reason: String? = null) : VerificationResult() + data class Failed(val error: Exception) : VerificationResult() + object SignatureNotFound : VerificationResult() + + fun isValid(): Boolean = this == Valid + fun isInvalid(): Boolean = this is Invalid + fun signatureIsNotFound(): Boolean = this == SignatureNotFound +} diff --git a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt index 4d17c09..a6d6143 100644 --- a/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt +++ b/src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt @@ -2,6 +2,7 @@ package com.coder.toolbox.settings import java.net.URL import java.nio.file.Path +import java.util.Locale.getDefault /** * Read-only interface for accessing Coder settings @@ -27,6 +28,11 @@ interface ReadOnlyCoderSettings { */ val binaryDirectory: String? + /** + * Controls whether we fall back release.coder.com + */ + val fallbackOnCoderForSignatures: SignatureFallbackStrategy + /** * Default CLI binary name based on OS and architecture */ @@ -37,6 +43,11 @@ interface ReadOnlyCoderSettings { */ val binaryName: String + /** + * Default CLI signature name based on OS and architecture + */ + val defaultSignatureNameByOsAndArch: String + /** * Where to save plugin data like the Coder binary (if not configured with * binaryDirectory) and the deployment URL and session token. @@ -167,4 +178,32 @@ interface ReadOnlyTLSSettings { * Coder service does not match the hostname in the TLS certificate. */ val altHostname: String? +} + +enum class SignatureFallbackStrategy { + /** + * User has not yet decided whether he wants to fallback on releases.coder.com for signatures + */ + NOT_CONFIGURED, + + /** + * Can fall back on releases.coder.com for signatures. + */ + ALLOW, + + /** + * Can't fall back on releases.coder.com for signatures. + */ + FORBIDDEN; + + fun isAllowed(): Boolean = this == ALLOW + + companion object { + fun fromValue(value: String?): SignatureFallbackStrategy = when (value?.lowercase(getDefault())) { + "not_configured" -> NOT_CONFIGURED + "allow" -> ALLOW + "forbidden" -> FORBIDDEN + else -> NOT_CONFIGURED + } + } } \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt index d08e8d6..a57ff45 100644 --- a/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt +++ b/src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt @@ -3,6 +3,7 @@ package com.coder.toolbox.store import com.coder.toolbox.settings.Environment import com.coder.toolbox.settings.ReadOnlyCoderSettings import com.coder.toolbox.settings.ReadOnlyTLSSettings +import com.coder.toolbox.settings.SignatureFallbackStrategy import com.coder.toolbox.util.Arch import com.coder.toolbox.util.OS import com.coder.toolbox.util.expand @@ -37,8 +38,11 @@ class CoderSettingsStore( override val defaultURL: String get() = store[DEFAULT_URL] ?: "https://dev.coder.com" override val binarySource: String? get() = store[BINARY_SOURCE] override val binaryDirectory: String? get() = store[BINARY_DIRECTORY] + override val fallbackOnCoderForSignatures: SignatureFallbackStrategy + get() = SignatureFallbackStrategy.fromValue(store[FALLBACK_ON_CODER_FOR_SIGNATURES]) override val defaultCliBinaryNameByOsAndArch: String get() = getCoderCLIForOS(getOS(), getArch()) override val binaryName: String get() = store[BINARY_NAME] ?: getCoderCLIForOS(getOS(), getArch()) + override val defaultSignatureNameByOsAndArch: String get() = getCoderSignatureForOS(getOS(), getArch()) override val dataDirectory: String? get() = store[DATA_DIRECTORY] override val globalDataDirectory: String get() = getDefaultGlobalDataDir().normalize().toString() override val globalConfigDir: String get() = getDefaultGlobalConfigDir().normalize().toString() @@ -158,6 +162,13 @@ class CoderSettingsStore( store[ENABLE_DOWNLOADS] = shouldEnableDownloads.toString() } + fun updateSignatureFallbackStrategy(fallback: Boolean) { + store[FALLBACK_ON_CODER_FOR_SIGNATURES] = when (fallback) { + true -> SignatureFallbackStrategy.ALLOW.toString() + else -> SignatureFallbackStrategy.FORBIDDEN.toString() + } + } + fun updateBinaryDirectoryFallback(shouldEnableBinDirFallback: Boolean) { store[ENABLE_BINARY_DIR_FALLBACK] = shouldEnableBinDirFallback.toString() } @@ -237,41 +248,59 @@ class CoderSettingsStore( } /** - * Return the name of the binary (with extension) for the provided OS and - * architecture. + * Return the name of the binary (with extension) for the provided OS and architecture. */ private fun getCoderCLIForOS( os: OS?, arch: Arch?, ): String { - logger.info("Resolving binary for $os $arch") + logger.debug("Resolving binary for $os $arch") + return buildCoderFileName(os, arch) + } + + /** + * Return the name of the signature file (.asc) for the provided OS and architecture. + */ + private fun getCoderSignatureForOS( + os: OS?, + arch: Arch?, + ): String { + logger.debug("Resolving signature for $os $arch") + return buildCoderFileName(os, arch, true) + } + + /** + * Build the coder file name based on OS, architecture, and whether it's a signature file. + */ + private fun buildCoderFileName( + os: OS?, + arch: Arch?, + isSignature: Boolean = false + ): String { if (os == null) { logger.error("Could not resolve client OS and architecture, defaulting to WINDOWS AMD64") - return "coder-windows-amd64.exe" + return if (isSignature) "coder-windows-amd64.asc" else "coder-windows-amd64.exe" } - return when (os) { - OS.WINDOWS -> - when (arch) { - Arch.AMD64 -> "coder-windows-amd64.exe" - Arch.ARM64 -> "coder-windows-arm64.exe" - else -> "coder-windows-amd64.exe" - } - OS.LINUX -> - when (arch) { - Arch.AMD64 -> "coder-linux-amd64" - Arch.ARM64 -> "coder-linux-arm64" - Arch.ARMV7 -> "coder-linux-armv7" - else -> "coder-linux-amd64" - } + val osName = when (os) { + OS.WINDOWS -> "windows" + OS.LINUX -> "linux" + OS.MAC -> "darwin" + } - OS.MAC -> - when (arch) { - Arch.AMD64 -> "coder-darwin-amd64" - Arch.ARM64 -> "coder-darwin-arm64" - else -> "coder-darwin-amd64" - } + val archName = when (arch) { + Arch.AMD64 -> "amd64" + Arch.ARM64 -> "arm64" + Arch.ARMV7 -> "armv7" + else -> "amd64" // default fallback } + + val extension = if (isSignature) ".asc" else when (os) { + OS.WINDOWS -> ".exe" + OS.LINUX, OS.MAC -> "" + } + + return "coder-$osName-$archName$extension" } /** diff --git a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt index e34436f..91e3b74 100644 --- a/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt +++ b/src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt @@ -10,6 +10,8 @@ internal const val BINARY_SOURCE = "binarySource" internal const val BINARY_DIRECTORY = "binaryDirectory" +internal const val FALLBACK_ON_CODER_FOR_SIGNATURES = "signatureFallbackStrategy" + internal const val BINARY_NAME = "binaryName" internal const val DATA_DIRECTORY = "dataDirectory" diff --git a/src/main/kotlin/com/coder/toolbox/util/OS.kt b/src/main/kotlin/com/coder/toolbox/util/OS.kt index 32abd5e..ba39204 100644 --- a/src/main/kotlin/com/coder/toolbox/util/OS.kt +++ b/src/main/kotlin/com/coder/toolbox/util/OS.kt @@ -1,30 +1,19 @@ package com.coder.toolbox.util -import java.util.* +import java.util.Locale fun getOS(): OS? = OS.from(System.getProperty("os.name")) -fun getArch(): Arch? = Arch.from(System.getProperty("os.arch").lowercase(Locale.getDefault())) +fun getArch(): Arch? = Arch.from(System.getProperty("os.arch")?.lowercase(Locale.getDefault())) enum class OS { WINDOWS, LINUX, MAC; - /** - * The name of the current desktop environment. - * For Linux systems it can be GNOME, KDE, XFCE, LXDE, and so on, - * while for macOS it will be Aqua and Windows Shell for Windows. - */ - fun getDesktopEnvironment(): String? = - when (this) { - WINDOWS -> "Windows Shell" - MAC -> "Aqua" - LINUX -> System.getenv("XDG_CURRENT_DESKTOP") - } - companion object { - fun from(os: String): OS? = when { + fun from(os: String?): OS? = when { + os.isNullOrBlank() -> null os.contains("win", true) -> { WINDOWS } @@ -49,7 +38,8 @@ enum class Arch { ; companion object { - fun from(arch: String): Arch? = when { + fun from(arch: String?): Arch? = when { + arch.isNullOrBlank() -> null arch.contains("amd64", true) || arch.contains("x86_64", true) -> AMD64 arch.contains("arm64", true) || arch.contains("aarch64", true) -> ARM64 arch.contains("armv7", true) -> ARMV7 diff --git a/src/main/kotlin/com/coder/toolbox/util/SemVer.kt b/src/main/kotlin/com/coder/toolbox/util/SemVer.kt index 238ce81..a40a9a9 100644 --- a/src/main/kotlin/com/coder/toolbox/util/SemVer.kt +++ b/src/main/kotlin/com/coder/toolbox/util/SemVer.kt @@ -1,6 +1,6 @@ package com.coder.toolbox.util -class SemVer(private val major: Long = 0, private val minor: Long = 0, private val patch: Long = 0) : Comparable { +class SemVer(val major: Long = 0, val minor: Long = 0, val patch: Long = 0) : Comparable { init { require(major >= 0) { "Coder major version must be a positive number" } require(minor >= 0) { "Coder minor version must be a positive number" } diff --git a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt index 61827be..448a20f 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.ClosedSendChannelException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** @@ -32,6 +33,11 @@ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel< TextField(context.i18n.ptrl("Data directory"), settings.dataDirectory ?: "", TextType.General) private val enableDownloadsField = CheckboxField(settings.enableDownloads, context.i18n.ptrl("Enable downloads")) + private val signatureFallbackStrategyField = + CheckboxField( + settings.fallbackOnCoderForSignatures.isAllowed(), + context.i18n.ptrl("Verify binary signature using releases.coder.com when CLI signatures are not available from the deployment") + ) private val enableBinaryDirectoryFallbackField = CheckboxField( settings.enableBinaryDirectoryFallback, @@ -66,6 +72,7 @@ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel< enableDownloadsField, binaryDirectoryField, enableBinaryDirectoryFallbackField, + signatureFallbackStrategyField, dataDirectoryField, headerCommandField, tlsCertPathField, @@ -83,16 +90,17 @@ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel< override val actionButtons: StateFlow> = MutableStateFlow( listOf( Action(context.i18n.ptrl("Save"), closesPage = true) { - context.settingsStore.updateBinarySource(binarySourceField.textState.value) - context.settingsStore.updateBinaryDirectory(binaryDirectoryField.textState.value) - context.settingsStore.updateDataDirectory(dataDirectoryField.textState.value) + context.settingsStore.updateBinarySource(binarySourceField.contentState.value) + context.settingsStore.updateBinaryDirectory(binaryDirectoryField.contentState.value) + context.settingsStore.updateDataDirectory(dataDirectoryField.contentState.value) context.settingsStore.updateEnableDownloads(enableDownloadsField.checkedState.value) + context.settingsStore.updateSignatureFallbackStrategy(signatureFallbackStrategyField.checkedState.value) context.settingsStore.updateBinaryDirectoryFallback(enableBinaryDirectoryFallbackField.checkedState.value) - context.settingsStore.updateHeaderCommand(headerCommandField.textState.value) - context.settingsStore.updateCertPath(tlsCertPathField.textState.value) - context.settingsStore.updateKeyPath(tlsKeyPathField.textState.value) - context.settingsStore.updateCAPath(tlsCAPathField.textState.value) - context.settingsStore.updateAltHostname(tlsAlternateHostnameField.textState.value) + context.settingsStore.updateHeaderCommand(headerCommandField.contentState.value) + context.settingsStore.updateCertPath(tlsCertPathField.contentState.value) + context.settingsStore.updateKeyPath(tlsKeyPathField.contentState.value) + context.settingsStore.updateCAPath(tlsCAPathField.contentState.value) + context.settingsStore.updateAltHostname(tlsAlternateHostnameField.contentState.value) context.settingsStore.updateDisableAutostart(disableAutostartField.checkedState.value) val oldIsSshWildcardConfigEnabled = settings.isSshWildcardConfigEnabled context.settingsStore.updateEnableSshWildcardConfig(enableSshWildCardConfig.checkedState.value) @@ -106,10 +114,73 @@ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel< } } } - context.settingsStore.updateSshLogDir(sshLogDirField.textState.value) - context.settingsStore.updateNetworkInfoDir(networkInfoDirField.textState.value) - context.settingsStore.updateSshConfigOptions(sshExtraArgs.textState.value) + context.settingsStore.updateSshLogDir(sshLogDirField.contentState.value) + context.settingsStore.updateNetworkInfoDir(networkInfoDirField.contentState.value) + context.settingsStore.updateSshConfigOptions(sshExtraArgs.contentState.value) } ) ) + + override fun beforeShow() { + // update the value of all fields + binarySourceField.contentState.update { + settings.binarySource ?: "" + } + binaryDirectoryField.contentState.update { + settings.binaryDirectory ?: "" + } + dataDirectoryField.contentState.update { + settings.dataDirectory ?: "" + } + enableDownloadsField.checkedState.update { + settings.enableDownloads + } + signatureFallbackStrategyField.checkedState.update { + settings.fallbackOnCoderForSignatures.isAllowed() + } + + enableBinaryDirectoryFallbackField.checkedState.update { + settings.enableBinaryDirectoryFallback + } + + headerCommandField.contentState.update { + settings.headerCommand ?: "" + } + + tlsCertPathField.contentState.update { + settings.tls.certPath ?: "" + } + + tlsKeyPathField.contentState.update { + settings.tls.keyPath ?: "" + } + + tlsCAPathField.contentState.update { + settings.tls.caPath ?: "" + } + + tlsAlternateHostnameField.contentState.update { + settings.tls.altHostname ?: "" + } + + disableAutostartField.checkedState.update { + settings.disableAutostart + } + + enableSshWildCardConfig.checkedState.update { + settings.isSshWildcardConfigEnabled + } + + sshExtraArgs.contentState.update { + settings.sshConfigOptions ?: "" + } + + sshLogDirField.contentState.update { + settings.sshLogDirectory ?: "" + } + + networkInfoDirField.contentState.update { + settings.networkInfoDir + } + } } diff --git a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt index aa87b57..2a76864 100644 --- a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -1,9 +1,13 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext +import com.coder.toolbox.settings.SignatureFallbackStrategy import com.coder.toolbox.util.toURL import com.coder.toolbox.views.state.CoderCliSetupContext import com.coder.toolbox.views.state.CoderCliSetupWizardState +import com.jetbrains.toolbox.api.ui.components.CheckboxField +import com.jetbrains.toolbox.api.ui.components.LabelField +import com.jetbrains.toolbox.api.ui.components.LabelStyleType import com.jetbrains.toolbox.api.ui.components.RowGroup import com.jetbrains.toolbox.api.ui.components.TextField import com.jetbrains.toolbox.api.ui.components.TextType @@ -24,13 +28,32 @@ class DeploymentUrlStep( ) : WizardStep { private val urlField = TextField(context.i18n.ptrl("Deployment URL"), "", TextType.General) - private val errorField = ValidationErrorField(context.i18n.pnotr("")) + private val emptyLine = LabelField(context.i18n.pnotr(""), LabelStyleType.Normal) - override val panel: RowGroup = RowGroup( - RowGroup.RowField(urlField), - RowGroup.RowField(errorField) + private val signatureFallbackStrategyField = CheckboxField( + context.settingsStore.fallbackOnCoderForSignatures.isAllowed(), + context.i18n.ptrl("Verify binary signature using releases.coder.com when CLI signatures are not available from the deployment") ) + private val errorField = ValidationErrorField(context.i18n.pnotr("")) + + override val panel: RowGroup + get() { + if (context.settingsStore.fallbackOnCoderForSignatures == SignatureFallbackStrategy.NOT_CONFIGURED) { + return RowGroup( + RowGroup.RowField(urlField), + RowGroup.RowField(emptyLine), + RowGroup.RowField(signatureFallbackStrategyField), + RowGroup.RowField(errorField) + ) + + } + return RowGroup( + RowGroup.RowField(urlField), + RowGroup.RowField(errorField) + ) + } + override fun onVisible() { errorField.textState.update { context.i18n.pnotr("") @@ -38,9 +61,14 @@ class DeploymentUrlStep( urlField.textState.update { context.secrets.lastDeploymentURL } + + signatureFallbackStrategyField.checkedState.update { + context.settingsStore.fallbackOnCoderForSignatures.isAllowed() + } } override fun onNext(): Boolean { + context.settingsStore.updateSignatureFallbackStrategy(signatureFallbackStrategyField.checkedState.value) var url = urlField.textState.value if (url.isBlank()) { errorField.textState.update { context.i18n.ptrl("URL is required") } diff --git a/src/main/resources/META-INF/trusted-keys/pgp-public.key b/src/main/resources/META-INF/trusted-keys/pgp-public.key new file mode 100644 index 0000000..fb5c4c5 --- /dev/null +++ b/src/main/resources/META-INF/trusted-keys/pgp-public.key @@ -0,0 +1,99 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +=dLmT +-----END PGP PUBLIC KEY BLOCK----- \ No newline at end of file diff --git a/src/main/resources/localization/defaultMessages.po b/src/main/resources/localization/defaultMessages.po index 787424c..f176105 100644 --- a/src/main/resources/localization/defaultMessages.po +++ b/src/main/resources/localization/defaultMessages.po @@ -79,6 +79,9 @@ msgstr "" msgid "Enable downloads" msgstr "" +msgid "Verify binary signature using releases.coder.com when CLI signatures are not available from the deployment" +msgstr "" + msgid "Enable binary directory fallback" msgstr "" @@ -149,4 +152,16 @@ msgid "Setting up Coder" msgstr "" msgid "Loading workspaces..." +msgstr "" + +msgid "Security Warning" +msgstr "" + +msgid "Accept" +msgstr "" + +msgid "Abort" +msgstr "" + +msgid "Run anyway" msgstr "" \ No newline at end of file diff --git a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt index 5c37c9e..4ef1235 100644 --- a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt @@ -17,6 +17,7 @@ import com.coder.toolbox.store.DATA_DIRECTORY import com.coder.toolbox.store.DISABLE_AUTOSTART import com.coder.toolbox.store.ENABLE_BINARY_DIR_FALLBACK import com.coder.toolbox.store.ENABLE_DOWNLOADS +import com.coder.toolbox.store.FALLBACK_ON_CODER_FOR_SIGNATURES import com.coder.toolbox.store.HEADER_COMMAND import com.coder.toolbox.store.NETWORK_INFO_DIR import com.coder.toolbox.store.SSH_CONFIG_OPTIONS @@ -41,8 +42,10 @@ import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager import com.jetbrains.toolbox.api.ui.ToolboxUi import com.squareup.moshi.JsonEncodingException import com.sun.net.httpserver.HttpServer +import io.mockk.coEvery import io.mockk.mockk import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.assertDoesNotThrow import org.zeroturnaround.exec.InvalidExitValueException @@ -54,6 +57,7 @@ import java.net.URL import java.nio.file.AccessDeniedException import java.nio.file.Path import java.util.UUID +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals @@ -62,12 +66,13 @@ import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertTrue -private const val VERSION_FOR_PROGRESS_REPORTING = "v2.23.1-devel+de07351b8" +private const val VERSION_FOR_PROGRESS_REPORTING = "v2.13.1-devel+de07351b8" private val noOpTextProgress: (String) -> Unit = { _ -> } internal class CoderCLIManagerTest { + private val ui = mockk(relaxed = true) private val context = CoderToolboxContext( - mockk(), + ui, mockk(), mockk(), mockk(), @@ -75,7 +80,7 @@ internal class CoderCLIManagerTest { mockk(), mockk(), mockk(relaxed = true), - mockk(), + mockk(relaxed = true), CoderSettingsStore( pluginTestSettingsStore(), Environment(), @@ -85,6 +90,11 @@ internal class CoderCLIManagerTest { mockk() ) + @BeforeTest + fun setup() { + coEvery { ui.showYesNoPopup(any(), any(), any(), any()) } returns true + } + /** * Return the contents of a script that contains the string. */ @@ -112,6 +122,9 @@ internal class CoderCLIManagerTest { if (exchange.requestURI.path == "/bin/override") { code = HttpURLConnection.HTTP_OK response = mkbinVersion("0.0.0") + } else if (exchange.requestURI.path.contains(".asc")) { + code = HttpURLConnection.HTTP_NOT_FOUND + response = "not found" } else if (!exchange.requestURI.path.startsWith("/bin/coder-")) { code = HttpURLConnection.HTTP_NOT_FOUND response = "not found" @@ -136,19 +149,14 @@ internal class CoderCLIManagerTest { fun testServerInternalError() { val (srv, url) = mockServer(HttpURLConnection.HTTP_INTERNAL_ERROR) val ccm = CoderCLIManager( - url, - context.logger, - CoderSettingsStore( - pluginTestSettingsStore(), - Environment(), - mockk(relaxed = true) - ).readOnly() + context, + url ) val ex = assertFailsWith( exceptionClass = ResponseException::class, - block = { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }, + block = { runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) } } ) assertEquals(HttpURLConnection.HTTP_INTERNAL_ERROR, ex.code) @@ -164,16 +172,16 @@ internal class CoderCLIManagerTest { ), Environment(), context.logger - ).readOnly() + ) val url = URL("http://localhost") - val ccm1 = CoderCLIManager(url, context.logger, settings) + val ccm1 = CoderCLIManager(context.copy(settingsStore = settings), url) assertEquals(settings.binSource(url), ccm1.remoteBinaryURL) assertEquals(settings.dataDir(url), ccm1.coderConfigPath.parent) assertEquals(settings.binPath(url), ccm1.localBinaryPath) // Can force using data directory. - val ccm2 = CoderCLIManager(url, context.logger, settings, true) + val ccm2 = CoderCLIManager(context.copy(settingsStore = settings), url, true) assertEquals(settings.binSource(url), ccm2.remoteBinaryURL) assertEquals(settings.dataDir(url), ccm2.coderConfigPath.parent) assertEquals(settings.binPath(url, true), ccm2.localBinaryPath) @@ -187,15 +195,16 @@ internal class CoderCLIManagerTest { val (srv, url) = mockServer() val ccm = CoderCLIManager( - url, - context.logger, - CoderSettingsStore( + context.copy( + settingsStore = CoderSettingsStore( pluginTestSettingsStore( DATA_DIRECTORY to tmpdir.resolve("cli-dir-fail-to-write").toString(), ), Environment(), context.logger - ).readOnly(), + ) + ), + url ) ccm.localBinaryPath.parent.toFile().mkdirs() @@ -203,7 +212,7 @@ internal class CoderCLIManagerTest { assertFailsWith( exceptionClass = AccessDeniedException::class, - block = { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }, + block = { runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) } }, ) srv.stop(0) @@ -221,22 +230,24 @@ internal class CoderCLIManagerTest { } val ccm = CoderCLIManager( + context.copy( + settingsStore = CoderSettingsStore( + pluginTestSettingsStore( + DATA_DIRECTORY to tmpdir.resolve("real-cli").toString(), + FALLBACK_ON_CODER_FOR_SIGNATURES to "allow", + ), + Environment(), + context.logger + ) + ), url.toURL(), - context.logger, - CoderSettingsStore( - pluginTestSettingsStore( - DATA_DIRECTORY to tmpdir.resolve("real-cli").toString(), - ), - Environment(), - context.logger - ).readOnly(), ) - assertTrue(ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) + assertTrue(runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) assertDoesNotThrow { ccm.version() } // It should skip the second attempt. - assertFalse(ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) + assertFalse(runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) // Make sure login failures propagate. assertFailsWith( @@ -249,39 +260,43 @@ internal class CoderCLIManagerTest { fun testDownloadMockCLI() { val (srv, url) = mockServer() var ccm = CoderCLIManager( + context.copy( + settingsStore = CoderSettingsStore( + pluginTestSettingsStore( + BINARY_NAME to "coder.bat", + DATA_DIRECTORY to tmpdir.resolve("mock-cli").toString(), + FALLBACK_ON_CODER_FOR_SIGNATURES to "allow", + ), + Environment(), + context.logger, + ) + ), url, - context.logger, - CoderSettingsStore( - pluginTestSettingsStore( - BINARY_NAME to "coder.bat", - DATA_DIRECTORY to tmpdir.resolve("mock-cli").toString(), - ), - Environment(), - context.logger, - ).readOnly(), ) - assertEquals(true, ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) + assertEquals(true, runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) assertEquals(SemVer(url.port.toLong(), 0, 0), ccm.version()) // It should skip the second attempt. - assertEquals(false, ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) + assertEquals(false, runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) // Should use the source override. ccm = CoderCLIManager( + context.copy( + settingsStore = CoderSettingsStore( + pluginTestSettingsStore( + BINARY_SOURCE to "/bin/override", + DATA_DIRECTORY to tmpdir.resolve("mock-cli").toString(), + FALLBACK_ON_CODER_FOR_SIGNATURES to "allow", + ), + Environment(), + context.logger + ) + ), url, - context.logger, - CoderSettingsStore( - pluginTestSettingsStore( - BINARY_SOURCE to "/bin/override", - DATA_DIRECTORY to tmpdir.resolve("mock-cli").toString(), - ), - Environment(), - context.logger - ).readOnly(), ) - assertEquals(true, ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) + assertEquals(true, runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) assertContains(ccm.localBinaryPath.toFile().readText(), "0.0.0") srv.stop(0) @@ -290,15 +305,16 @@ internal class CoderCLIManagerTest { @Test fun testRunNonExistentBinary() { val ccm = CoderCLIManager( - URL("https://foo"), - context.logger, - CoderSettingsStore( + context.copy( + settingsStore = CoderSettingsStore( pluginTestSettingsStore( DATA_DIRECTORY to tmpdir.resolve("does-not-exist").toString(), ), Environment(), context.logger - ).readOnly(), + ) + ), + URL("https://foo") ) assertFailsWith( @@ -311,15 +327,17 @@ internal class CoderCLIManagerTest { fun testOverwritesWrongVersion() { val (srv, url) = mockServer() val ccm = CoderCLIManager( - url, - context.logger, - CoderSettingsStore( + context.copy( + settingsStore = CoderSettingsStore( pluginTestSettingsStore( + FALLBACK_ON_CODER_FOR_SIGNATURES to "allow", DATA_DIRECTORY to tmpdir.resolve("overwrite-cli").toString(), ), Environment(), context.logger - ).readOnly(), + ) + ), + url ) ccm.localBinaryPath.parent.toFile().mkdirs() @@ -329,7 +347,7 @@ internal class CoderCLIManagerTest { assertEquals("cli", ccm.localBinaryPath.toFile().readText()) assertEquals(0, ccm.localBinaryPath.toFile().lastModified()) - assertTrue(ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) + assertTrue(runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) assertNotEquals("cli", ccm.localBinaryPath.toFile().readText()) assertNotEquals(0, ccm.localBinaryPath.toFile().lastModified()) @@ -346,16 +364,17 @@ internal class CoderCLIManagerTest { val settings = CoderSettingsStore( pluginTestSettingsStore( DATA_DIRECTORY to tmpdir.resolve("clobber-cli").toString(), + FALLBACK_ON_CODER_FOR_SIGNATURES to "allow" ), Environment(), context.logger - ).readOnly() + ) - val ccm1 = CoderCLIManager(url1, context.logger, settings) - val ccm2 = CoderCLIManager(url2, context.logger, settings) + val ccm1 = CoderCLIManager(context.copy(settingsStore = settings), url1) + val ccm2 = CoderCLIManager(context.copy(settingsStore = settings), url2) - assertTrue(ccm1.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) - assertTrue(ccm2.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) + assertTrue(runBlocking { ccm1.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) + assertTrue(runBlocking { ccm2.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) srv1.stop(0) srv2.stop(0) @@ -525,10 +544,13 @@ internal class CoderCLIManagerTest { ), env = it.env, context.logger, - ).readOnly() + ) val ccm = - CoderCLIManager(it.url ?: URI.create("https://test.coder.invalid").toURL(), context.logger, settings) + CoderCLIManager( + context.copy(settingsStore = settings), + it.url ?: URI.create("https://test.coder.invalid").toURL() + ) val sshConfigPath = Path.of(settings.sshConfigPath) // Input is the configuration that we start with, if any. @@ -609,7 +631,7 @@ internal class CoderCLIManagerTest { ), Environment(), context.logger - ).readOnly() + ) val sshConfigPath = Path.of(settings.sshConfigPath) sshConfigPath.parent.toFile().mkdirs() Path.of("src/test/resources/fixtures/inputs").resolve("$it.conf").toFile().copyTo( @@ -617,7 +639,7 @@ internal class CoderCLIManagerTest { true, ) - val ccm = CoderCLIManager(URL("https://test.coder.invalid"), context.logger, settings) + val ccm = CoderCLIManager(context.copy(settingsStore = settings), URL("https://test.coder.invalid")) assertFailsWith( exceptionClass = SSHConfigFormatException::class, @@ -644,15 +666,16 @@ internal class CoderCLIManagerTest { tests.forEach { val ccm = CoderCLIManager( + context.copy( + settingsStore = CoderSettingsStore( + pluginTestSettingsStore( + HEADER_COMMAND to it, + ), + Environment(), + context.logger + ) + ), URI.create("https://test.coder.invalid").toURL(), - context.logger, - CoderSettingsStore( - pluginTestSettingsStore( - HEADER_COMMAND to it, - ), - Environment(), - context.logger - ).readOnly(), ) assertFailsWith( @@ -695,16 +718,17 @@ internal class CoderCLIManagerTest { ) val ccm = CoderCLIManager( + context.copy( + settingsStore = CoderSettingsStore( + pluginTestSettingsStore( + BINARY_NAME to "coder.bat", + BINARY_DIRECTORY to tmpdir.resolve("bad-version").toString(), + ), + Environment(), + context.logger, + ) + ), URL("https://test.coder.parse-fail.invalid"), - context.logger, - CoderSettingsStore( - pluginTestSettingsStore( - BINARY_NAME to "coder.bat", - BINARY_DIRECTORY to tmpdir.resolve("bad-version").toString(), - ), - Environment(), - context.logger, - ).readOnly(), ) ccm.localBinaryPath.parent.toFile().mkdirs() @@ -748,16 +772,17 @@ internal class CoderCLIManagerTest { ) val ccm = CoderCLIManager( + context.copy( + settingsStore = CoderSettingsStore( + pluginTestSettingsStore( + BINARY_NAME to "coder.bat", + BINARY_DIRECTORY to tmpdir.resolve("matches-version").toString(), + ), + Environment(), + context.logger, + ) + ), URL("https://test.coder.matches-version.invalid"), - context.logger, - CoderSettingsStore( - pluginTestSettingsStore( - BINARY_NAME to "coder.bat", - BINARY_DIRECTORY to tmpdir.resolve("matches-version").toString(), - ), - Environment(), - context.logger, - ).readOnly(), ) ccm.localBinaryPath.parent.toFile().mkdirs() @@ -852,6 +877,7 @@ internal class CoderCLIManagerTest { ENABLE_BINARY_DIR_FALLBACK to it.enableFallback.toString(), DATA_DIRECTORY to tmpdir.resolve("ensure-data-dir").toString(), BINARY_DIRECTORY to tmpdir.resolve("ensure-bin-dir").toString(), + FALLBACK_ON_CODER_FOR_SIGNATURES to "allow" ), Environment(), context.logger @@ -886,12 +912,12 @@ internal class CoderCLIManagerTest { Result.ERROR -> { assertFailsWith( exceptionClass = AccessDeniedException::class, - block = { ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) }, + block = { runBlocking { ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) } } ) } Result.NONE -> { - val ccm = ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) + val ccm = runBlocking { ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) } assertEquals(settings.binPath(url), ccm.localBinaryPath) assertFailsWith( exceptionClass = ProcessInitException::class, @@ -900,25 +926,25 @@ internal class CoderCLIManagerTest { } Result.DL_BIN -> { - val ccm = ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) + val ccm = runBlocking { ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) } assertEquals(settings.binPath(url), ccm.localBinaryPath) assertEquals(SemVer(url.port.toLong(), 0, 0), ccm.version()) } Result.DL_DATA -> { - val ccm = ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) + val ccm = runBlocking { ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) } assertEquals(settings.binPath(url, true), ccm.localBinaryPath) assertEquals(SemVer(url.port.toLong(), 0, 0), ccm.version()) } Result.USE_BIN -> { - val ccm = ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) + val ccm = runBlocking { ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) } assertEquals(settings.binPath(url), ccm.localBinaryPath) assertEquals(SemVer.parse(it.version ?: ""), ccm.version()) } Result.USE_DATA -> { - val ccm = ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) + val ccm = runBlocking { ensureCLI(localContext, url, it.buildVersion, noOpTextProgress) } assertEquals(settings.binPath(url, true), ccm.localBinaryPath) assertEquals(SemVer.parse(it.fallbackVersion ?: ""), ccm.version()) } @@ -947,18 +973,20 @@ internal class CoderCLIManagerTest { tests.forEach { val (srv, url) = mockServer(version = it.first) val ccm = CoderCLIManager( + context.copy( + settingsStore = CoderSettingsStore( + pluginTestSettingsStore( + BINARY_NAME to "coder.bat", + DATA_DIRECTORY to tmpdir.resolve("features").toString(), + FALLBACK_ON_CODER_FOR_SIGNATURES to "allow" + ), + Environment(), + context.logger, + ) + ), url, - context.logger, - CoderSettingsStore( - pluginTestSettingsStore( - BINARY_NAME to "coder.bat", - DATA_DIRECTORY to tmpdir.resolve("features").toString(), - ), - Environment(), - context.logger, - ).readOnly(), ) - assertEquals(true, ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress)) + assertEquals(true, runBlocking { ccm.download(VERSION_FOR_PROGRESS_REPORTING, noOpTextProgress) }) assertEquals(it.second, ccm.features, "version: ${it.first}") srv.stop(0) diff --git a/src/test/kotlin/com/coder/toolbox/store/CoderSettingsStoreTest.kt b/src/test/kotlin/com/coder/toolbox/store/CoderSettingsStoreTest.kt new file mode 100644 index 0000000..5798524 --- /dev/null +++ b/src/test/kotlin/com/coder/toolbox/store/CoderSettingsStoreTest.kt @@ -0,0 +1,93 @@ +package com.coder.toolbox.store + +import com.coder.toolbox.settings.Environment +import com.coder.toolbox.util.pluginTestSettingsStore +import com.jetbrains.toolbox.api.core.diagnostics.Logger +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test + +class CoderSettingsStoreTest { + private var originalOsName: String? = null + private var originalOsArch: String? = null + + private lateinit var store: CoderSettingsStore + + @BeforeTest + fun setUp() { + originalOsName = System.getProperty("os.name") + originalOsArch = System.getProperty("os.arch") + + store = CoderSettingsStore( + pluginTestSettingsStore(), + Environment(), + mockk(relaxed = true) + ) + } + + @AfterTest + fun tearDown() { + System.setProperty("os.name", originalOsName) + System.setProperty("os.arch", originalOsArch) + } + + @Test + fun `Default CLI and signature for Windows AMD64`() = + assertBinaryAndSignature("Windows 10", "amd64", "coder-windows-amd64.exe", "coder-windows-amd64.asc") + + @Test + fun `Default CLI and signature for Windows ARM64`() = + assertBinaryAndSignature("Windows 10", "aarch64", "coder-windows-arm64.exe", "coder-windows-arm64.asc") + + @Test + fun `Default CLI and signature for Windows ARMV7`() = + assertBinaryAndSignature("Windows 10", "armv7l", "coder-windows-armv7.exe", "coder-windows-armv7.asc") + + @Test + fun `Default CLI and signature for Linux AMD64`() = + assertBinaryAndSignature("Linux", "x86_64", "coder-linux-amd64", "coder-linux-amd64.asc") + + @Test + fun `Default CLI and signature for Linux ARM64`() = + assertBinaryAndSignature("Linux", "aarch64", "coder-linux-arm64", "coder-linux-arm64.asc") + + @Test + fun `Default CLI and signature for Linux ARMV7`() = + assertBinaryAndSignature("Linux", "armv7l", "coder-linux-armv7", "coder-linux-armv7.asc") + + @Test + fun `Default CLI and signature for Mac AMD64`() = + assertBinaryAndSignature("Mac OS X", "x86_64", "coder-darwin-amd64", "coder-darwin-amd64.asc") + + @Test + fun `Default CLI and signature for Mac ARM64`() = + assertBinaryAndSignature("Mac OS X", "aarch64", "coder-darwin-arm64", "coder-darwin-arm64.asc") + + @Test + fun `Default CLI and signature for Mac ARMV7`() = + assertBinaryAndSignature("Mac OS X", "armv7l", "coder-darwin-armv7", "coder-darwin-armv7.asc") + + @Test + fun `Default CLI and signature for unknown OS and Arch`() = + assertBinaryAndSignature(null, null, "coder-windows-amd64.exe", "coder-windows-amd64.asc") + + @Test + fun `Default CLI and signature for unknown Arch fallback on Linux`() = + assertBinaryAndSignature("Linux", "mips64", "coder-linux-amd64", "coder-linux-amd64.asc") + + private fun assertBinaryAndSignature( + osName: String?, + arch: String?, + expectedBinary: String, + expectedSignature: String + ) { + if (osName == null) System.clearProperty("os.name") else System.setProperty("os.name", osName) + if (arch == null) System.clearProperty("os.arch") else System.setProperty("os.arch", arch) + + assertEquals(expectedBinary, store.defaultCliBinaryNameByOsAndArch) + assertEquals(expectedSignature, store.defaultSignatureNameByOsAndArch) + } + +} \ No newline at end of file