diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImpl.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImpl.kt index 09df1d092c..23072e7655 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImpl.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImpl.kt @@ -14,12 +14,15 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import java.io.BufferedReader import java.io.File +import java.io.IOException import java.io.InputStreamReader +import java.nio.file.InvalidPathException import java.util.UUID import java.util.concurrent.ConcurrentHashMap @@ -44,24 +47,43 @@ class IdeCommandServiceImpl internal constructor( when (spec) { // Through the tooling server, never ./gradlew: a second daemon doubles Gradle's memory // on the device, and its output would never reach the Build Output pane. - is CommandSpec.GradleTask -> GradleTaskExecution(executionId, spec, gradleTaskRunner, timeoutMs) + is CommandSpec.GradleTask -> { + GradleTaskExecution(executionId, spec, gradleTaskRunner, timeoutMs) + } - is CommandSpec.ShellCommand -> CommandExecutionImpl(executionId, shellProcess(spec), timeoutMs) + is CommandSpec.ShellCommand -> { + val workDir = + try { + resolvePluginWorkingDirectory(pluginId, projectRootProvider(), spec.workingDirectory) + } catch (e: InvalidPathException) { + return RejectedCommandExecution(executionId, "Invalid working directory: ${e.message}") + } catch (e: IOException) { + return RejectedCommandExecution(executionId, "Invalid working directory: ${e.message}") + } + val processBuilder = + try { + shellProcess(spec, workDir) + } catch (e: IllegalArgumentException) { + return RejectedCommandExecution(executionId, "Invalid environment: ${e.message}") + } + CommandExecutionImpl(executionId, processBuilder, timeoutMs) + } } runningCommands[executionId] = execution execution.start { runningCommands.remove(executionId) } return execution } - private fun shellProcess(spec: CommandSpec.ShellCommand): ProcessBuilder { - val workDir = resolvePluginWorkingDirectory(pluginId, projectRootProvider(), spec.workingDirectory) - return ProcessBuilder(listOf(spec.executable) + spec.arguments).apply { + private fun shellProcess( + spec: CommandSpec.ShellCommand, + workDir: File?, + ): ProcessBuilder = + ProcessBuilder(listOf(spec.executable) + spec.arguments).apply { workDir?.let { directory(it) } environment().putAll(spec.environment) redirectErrorStream(false) TermuxProcessEnvironment.applyTo(environment(), appFilesDir) } - } override fun isCommandRunning(executionId: String): Boolean = runningCommands[executionId]?.isRunning() == true @@ -224,3 +246,16 @@ private class CommandExecutionImpl( private const val MAX_OUTPUT_BYTES = 10 * 1024 * 1024 } } + +private class RejectedCommandExecution( + override val executionId: String, + error: String, +) : CommandExecution { + private val result = CommandResult.Failure(-1, "", "", error, 0) + + override val output: Flow = emptyFlow() + + override suspend fun await(): CommandResult = result + + override fun cancel() = Unit +} diff --git a/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImplTest.kt b/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImplTest.kt new file mode 100644 index 0000000000..ecbb1c891d --- /dev/null +++ b/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImplTest.kt @@ -0,0 +1,55 @@ +package com.itsaky.androidide.plugins.manager.services + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.plugins.PluginPermission +import com.itsaky.androidide.plugins.extensions.CommandResult +import com.itsaky.androidide.plugins.extensions.CommandSpec +import kotlinx.coroutines.runBlocking +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class IdeCommandServiceImplTest { + @get:Rule + val tmp = TemporaryFolder() + + private fun resultFor(workingDirectory: String): CommandResult = + resultFor(CommandSpec.ShellCommand("true", workingDirectory = workingDirectory)) + + private fun resultFor(spec: CommandSpec): CommandResult { + val projectRoot = tmp.newFolder("project") + val service = + IdeCommandServiceImpl( + pluginId = "test.plugin", + permissions = setOf(PluginPermission.SYSTEM_COMMANDS), + projectRootProvider = { projectRoot }, + appFilesDir = tmp.newFolder("files"), + ) + val execution = service.executeCommand(spec, 5_000) + return runBlocking { execution.await() } + } + + @Test + fun `a working directory with a NUL character fails the command`() { + val result = resultFor("sub\u0000dir") + + assertThat(result).isInstanceOf(CommandResult.Failure::class.java) + assertThat((result as CommandResult.Failure).error).startsWith("Invalid working directory") + } + + @Test + fun `a working directory too long to resolve fails the command`() { + val result = resultFor("a/".repeat(3000)) + + assertThat(result).isInstanceOf(CommandResult.Failure::class.java) + assertThat((result as CommandResult.Failure).error).startsWith("Invalid working directory") + } + + @Test + fun `an environment variable with a NUL character fails the command`() { + val result = resultFor(CommandSpec.ShellCommand("true", environment = mapOf("KEY" to "a\u0000b"))) + + assertThat(result).isInstanceOf(CommandResult.Failure::class.java) + assertThat((result as CommandResult.Failure).error).startsWith("Invalid environment") + } +}