From a455fe61a27bd7d03e2d8e04e3a34c1064dbf62c Mon Sep 17 00:00:00 2001 From: Alexander Frolov Date: Wed, 12 Apr 2023 12:53:59 +0300 Subject: [PATCH] Reuse ProcessBuilder from save-cli (#2) * Reuse ProcessBuilder from save-cli ### What's done: * Moved ProcessBuilder from saveourtool/save-cli to saveourtool/process-builder-multiplatform * Downgraded kotest version (5.5.5 -> 5.5.4) as 5.5.5 is not available for macosArm64 --- build.gradle.kts | 2 + .../processbuilder/DetektConfiguration.kt | 25 +-- core/build.gradle.kts | 90 ++++++++ .../processbuilder/ProcessBuilder.kt | 211 ++++++++++++++++++ .../processbuilder/ProcessBuilderInternal.kt | 37 +++ .../exceptions/ProcessExecutionException.kt | 6 + .../exceptions/ProcessTimeoutException.kt | 6 + .../processbuilder/utils/Constants.kt | 7 - .../processbuilder/utils/FileUtils.kt | 42 ++++ .../processbuilder/utils/PlatformUtils.kt | 35 +++ .../processbuilder/ProcessBuilderTest.kt | 112 ++++++++++ .../ProcessBuilderInternal.kt | 96 ++++++++ .../utils/FileUtils.kt | 18 ++ .../utils/PlatformUtils.kt | 12 + .../ProcessBuilderInternalTest.kt | 39 ++++ .../ProcessBuilderInternal.kt | 72 ++++++ .../utils/FileUtils.kt | 25 +++ .../utils/PlatformUtils.kt | 12 + .../ProcessBuilderInternalTest.kt | 39 ++++ gradle/libs.versions.toml | 2 +- 20 files changed, 865 insertions(+), 23 deletions(-) create mode 100644 core/build.gradle.kts create mode 100644 core/src/commonMain/kotlin/com/saveourtool/processbuilder/ProcessBuilder.kt create mode 100644 core/src/commonMain/kotlin/com/saveourtool/processbuilder/ProcessBuilderInternal.kt create mode 100644 core/src/commonMain/kotlin/com/saveourtool/processbuilder/exceptions/ProcessExecutionException.kt create mode 100644 core/src/commonMain/kotlin/com/saveourtool/processbuilder/exceptions/ProcessTimeoutException.kt delete mode 100644 core/src/commonMain/kotlin/com/saveourtool/processbuilder/utils/Constants.kt create mode 100644 core/src/commonMain/kotlin/com/saveourtool/processbuilder/utils/FileUtils.kt create mode 100644 core/src/commonMain/kotlin/com/saveourtool/processbuilder/utils/PlatformUtils.kt create mode 100644 core/src/commonTest/kotlin/com/saveourtool/processbuilder/ProcessBuilderTest.kt create mode 100644 core/src/jvmMain/kotlin/com.saveourtool.processbuilder/ProcessBuilderInternal.kt create mode 100644 core/src/jvmMain/kotlin/com.saveourtool.processbuilder/utils/FileUtils.kt create mode 100644 core/src/jvmMain/kotlin/com.saveourtool.processbuilder/utils/PlatformUtils.kt create mode 100644 core/src/jvmTest/kotlin/com/saveourtool/processbuilder/ProcessBuilderInternalTest.kt create mode 100644 core/src/nativeMain/kotlin/com.saveourtool.processbuilder/ProcessBuilderInternal.kt create mode 100644 core/src/nativeMain/kotlin/com.saveourtool.processbuilder/utils/FileUtils.kt create mode 100644 core/src/nativeMain/kotlin/com.saveourtool.processbuilder/utils/PlatformUtils.kt create mode 100644 core/src/nativeTest/kotlin/com/saveourtool/processbuilder/ProcessBuilderInternalTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 6a71b86..8d14f28 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,5 @@ +import com.saveourtool.processbuilder.configureDetekt import com.saveourtool.processbuilder.configureDiktat import com.saveourtool.processbuilder.createDetektTask @@ -12,3 +13,4 @@ plugins { configureDiktat() createDetektTask() +configureDetekt() diff --git a/buildSrc/src/main/kotlin/com/saveourtool/processbuilder/DetektConfiguration.kt b/buildSrc/src/main/kotlin/com/saveourtool/processbuilder/DetektConfiguration.kt index 33cb5b6..cc3e7c9 100644 --- a/buildSrc/src/main/kotlin/com/saveourtool/processbuilder/DetektConfiguration.kt +++ b/buildSrc/src/main/kotlin/com/saveourtool/processbuilder/DetektConfiguration.kt @@ -22,32 +22,27 @@ fun Project.configureDetekt() { basePath = rootDir.canonicalPath buildUponDefaultConfig = true } + + val reportMerge: TaskProvider = rootProject.tasks.named("mergeDetektReports") { + input.from(tasks.withType().map { it.sarifReportFile }) + shouldRunAfter(tasks.withType()) + } + tasks.withType().configureEach { + reports.sarif.required.set(true) + finalizedBy(reportMerge) + } } /** * Register a unified detekt task */ fun Project.createDetektTask() { - val detektAllTask = tasks.register("detektAll") { + tasks.register("detektAll") { allprojects { this@register.dependsOn(tasks.withType()) } } - tasks.register("mergeDetektReports", ReportMergeTask::class) { - mustRunAfter(detektAllTask) output.set(buildDir.resolve("detekt-sarif-reports/detekt-merged.sarif")) } - - @Suppress("GENERIC_VARIABLE_WRONG_DECLARATION") - val reportMerge: TaskProvider = rootProject.tasks.named("mergeDetektReports") { - input.from( - tasks.withType().map { it.sarifReportFile } - ) - shouldRunAfter(tasks.withType()) - } - tasks.withType().configureEach { - reports.sarif.required.set(true) - finalizedBy(reportMerge) - } } diff --git a/core/build.gradle.kts b/core/build.gradle.kts new file mode 100644 index 0000000..5c18f18 --- /dev/null +++ b/core/build.gradle.kts @@ -0,0 +1,90 @@ +import com.saveourtool.processbuilder.configureDetekt +import com.saveourtool.processbuilder.configureDiktat + +plugins { + kotlin("multiplatform") +} + +kotlin { + jvm { + jvmToolchain(11) + withJava() + testRuns["test"].executionTask.configure { + useJUnitPlatform() + } + } + + macosArm64() + macosX64() + linuxX64() + mingwX64() + + sourceSets { + all { + languageSettings.optIn("kotlin.RequiresOptIn") + } + + val commonMain by getting { + dependencies { + api(libs.okio) + + implementation(libs.kotlin.logging) + implementation(libs.kotlinx.datetime) + implementation(libs.kotlinx.coroutines.core) + } + } + + val commonTest by getting { + dependencies { + implementation(libs.okio.fakefilesystem) + implementation(libs.kotest.assertions.core) + implementation(kotlin("test-common")) + implementation(kotlin("test-annotations-common")) + } + } + + val jvmMain by getting { + dependsOn(commonMain) + dependencies { + implementation(libs.slf4j) + } + } + + val jvmTest by getting { + dependsOn(commonTest) + dependencies { + implementation(kotlin("test-junit5")) + implementation(libs.junit.jupiter.engine) + } + } + + val macosArm64Main by getting + val macosX64Main by getting + val linuxX64Main by getting + val mingwX64Main by getting + + val nativeMain by creating { + dependsOn(commonMain) + macosArm64Main.dependsOn(this) + macosX64Main.dependsOn(this) + linuxX64Main.dependsOn(this) + mingwX64Main.dependsOn(this) + } + + val macosArm64Test by getting + val macosX64Test by getting + val linuxX64Test by getting + val mingwX64Test by getting + + val nativeTest by creating { + dependsOn(commonTest) + macosArm64Test.dependsOn(this) + macosX64Test.dependsOn(this) + linuxX64Test.dependsOn(this) + mingwX64Test.dependsOn(this) + } + } +} + +configureDetekt() +configureDiktat() diff --git a/core/src/commonMain/kotlin/com/saveourtool/processbuilder/ProcessBuilder.kt b/core/src/commonMain/kotlin/com/saveourtool/processbuilder/ProcessBuilder.kt new file mode 100644 index 0000000..5483bdf --- /dev/null +++ b/core/src/commonMain/kotlin/com/saveourtool/processbuilder/ProcessBuilder.kt @@ -0,0 +1,211 @@ +/** + * Utilities to run a process and get its result. + */ + +package com.saveourtool.processbuilder + +import com.saveourtool.processbuilder.exceptions.ProcessExecutionException +import com.saveourtool.processbuilder.exceptions.ProcessTimeoutException +import com.saveourtool.processbuilder.utils.createFile +import com.saveourtool.processbuilder.utils.isCurrentOsWindows +import com.saveourtool.processbuilder.utils.myDeleteRecursively +import com.saveourtool.processbuilder.utils.readLines +import io.github.oshai.KotlinLogging +import okio.FileSystem +import okio.Path +import okio.Path.Companion.toPath + +import kotlinx.datetime.Clock + +internal val logger = KotlinLogging.logger { } + +/** + * Class contains common logic for all platforms + * + * @property useInternalRedirections whether to collect output for future usage, if false, redirectTo will be ignored + * @property fs describes the current file system + */ +class ProcessBuilder(private val useInternalRedirections: Boolean, private val fs: FileSystem) { + /** + * Execute [command] and wait for its completion. + * + * @param command executable command with arguments + * @param directory where to execute provided command, i.e. `cd [directory]` will be performed before [command] execution + * @param redirectTo a file where process output and errors should be redirected. If null, output will be returned as [ExecutionResult.stdout] and [ExecutionResult.stderr]. + * @param timeOutMillis max command execution time + * @return [ExecutionResult] built from process output + * @throws ProcessExecutionException in case of impossibility of command execution + * @throws ProcessTimeoutException if timeout is exceeded + */ + @Suppress( + "TOO_LONG_FUNCTION", + "TooGenericExceptionCaught", + "ReturnCount", + "SwallowedException", + ) + fun exec( + command: String, + directory: String, + redirectTo: Path?, + timeOutMillis: Long, + ): ExecutionResult { + if (command.isBlank()) { + logErrorAndThrowProcessBuilderException("Execution command in ProcessBuilder couldn't be empty!") + } + if (command.contains(">") && useInternalRedirections) { + logger.error { + "Found user provided redirections in `$command`. " + + "SAVE will create own redirections for internal purpose, please refuse redirects or use corresponding argument [redirectTo]" + } + } + + // Temporary directory for stderr and stdout (posix `system()` can't separate streams, so we do it ourselves) + val tmpDir = (FileSystem.SYSTEM_TEMPORARY_DIRECTORY / + ("ProcessBuilder_" + Clock.System.now().toEpochMilliseconds()).toPath()) + logger.trace { "Creating temp directory: $tmpDir" } + // Path to stdout file + val stdoutFile = tmpDir / "stdout.txt" + logger.trace { "Creating stdout file of ProcessBuilder: $stdoutFile" } + // Path to stderr file + val stderrFile = tmpDir / "stderr.txt" + logger.trace { "Creating stderr file of ProcessBuilder: $stderrFile" } + // Instance, containing platform-dependent realization of command execution + val processBuilderInternal = ProcessBuilderInternal(stdoutFile, stderrFile, useInternalRedirections) + fs.createDirectories(tmpDir) + fs.createFile(stdoutFile) + fs.createFile(stderrFile) + logger.trace { "Created temp directory $tmpDir for stderr and stdout of ProcessBuilder" } + + val cmd = modifyCmd(command, directory, processBuilderInternal) + + logger.debug { "Executing: $cmd with timeout $timeOutMillis ms" } + val status = try { + processBuilderInternal.exec(cmd, timeOutMillis) + } catch (ex: ProcessTimeoutException) { + fs.deleteRecursively(tmpDir) + throw ex + } catch (ex: Exception) { + fs.deleteRecursively(tmpDir) + logErrorAndThrowProcessBuilderException(ex.message ?: "Couldn't execute $cmd") + } + + val stdout = fs.readLines(stdoutFile) + val stderr = fs.readLines(stderrFile) + + fs.myDeleteRecursively(tmpDir) + logger.trace { "Removed temp directory $tmpDir" } + if (stderr.isNotEmpty()) { + logger.debug { "stderr of `$command`:\t${stderr.joinToString("\t")}" } + } + redirectTo?.let { + fs.write(redirectTo) { + write(stdout.joinToString("\n").encodeToByteArray()) + write(stderr.joinToString("\n").encodeToByteArray()) + } + } ?: logger.trace { "Execution output:\t$stdout" } + return ExecutionResult(status, stdout, stderr) + } + + private fun modifyCmd( + command: String, + directory: String, + processBuilderInternal: ProcessBuilderInternal, + ): String { + // If we need to step out into some directory before execution + val cdCmd = if (directory.isNotBlank()) { + if (isCurrentOsWindows()) { + "cd /d $directory && " + } else { + "cd $directory && " + } + } else { + "" + } + // Additionally process command for Windows, it it contain `echo` + val commandWithEcho = cdCmd + if (isCurrentOsWindows()) { + processCommandWithEcho(command) + } else { + command + } + logger.trace { "Modified cmd: $commandWithEcho" } + // Finally, make platform dependent adaptations + return processBuilderInternal.prepareCmd(commandWithEcho) + } + + /** + * Log error message and throw exception + * + * @param errMsg error message + * @throws ProcessExecutionException + */ + private fun logErrorAndThrowProcessBuilderException(errMsg: String): Nothing { + logger.error { errMsg } + throw ProcessExecutionException(errMsg) + } + + companion object { + /** + * Check whether there are exists `echo` commands, and process them, since in Windows + * `echo` adds extra whitespaces and newlines. This method will remove them + * + * @param command command to process + * @return unmodified command, if there is no `echo` subcommands, otherwise add parameter `set /p=` to `echo` + */ + @Suppress("ReturnCount") + fun processCommandWithEcho(command: String): String { + if (!command.contains("echo")) { + return command + } + // Command already contains correct signature. + // We also believe not to met complex cases: `echo a; echo | set /p="a && echo b"` + val cmdWithoutWhitespaces = command.replace(" ", "") + if (cmdWithoutWhitespaces.contains("echo|set")) { + return command + } + if (cmdWithoutWhitespaces.contains("echo\"")) { + logger.warn { "You can use echo | set /p\"your command\" to avoid extra whitespaces on Windows" } + return command + } + // If command is complex (have `&&` or `;`), we need to modify only `echo` subcommands + val separator = if (command.contains("&&")) { + "&&" + } else if (command.contains(";")) { + ";" + } else { + "" + } + val listOfCommands = if (separator != "") command.split(separator) as MutableList else mutableListOf(command) + listOfCommands.forEachIndexed { index, cmd -> + if (cmd.contains("echo")) { + var newEchoCommand = cmd.trim(' ').replace("echo ", " echo | set /p dummyName=\"") + // Now we need to add closing `"` in proper place + // Despite the fact, that we don't expect user redirections, for out internal tests we use them, + // so we need to process such cases + // There are three different cases, where we need to insert closing `"`. + // 1) Before stdout redirection + // 2) Before stderr redirection + // 3) At the end of string, if there is no redirections + val indexOfStdoutRedirection = if (newEchoCommand.indexOf(">") != -1) newEchoCommand.indexOf(">") else newEchoCommand.length + val indexOfStderrRedirection = if (newEchoCommand.indexOf("2>") != -1) newEchoCommand.indexOf("2>") else newEchoCommand.length + val insertIndex = minOf(indexOfStdoutRedirection, indexOfStderrRedirection) + newEchoCommand = newEchoCommand.substring(0, insertIndex).trimEnd(' ') + "\" " + newEchoCommand.substring(insertIndex, newEchoCommand.length) + " " + listOfCommands[index] = newEchoCommand + } + } + val modifiedCommand = listOfCommands.joinToString(separator).trim(' ') + logger.trace { "Additionally modify command:`$command` to `$modifiedCommand` because of `echo` on Windows add extra newlines" } + return modifiedCommand + } + } +} + +/** + * @property code exit code + * @property stdout content of stdout + * @property stderr content of stderr + */ +data class ExecutionResult( + val code: Int, + val stdout: List, + val stderr: List, +) diff --git a/core/src/commonMain/kotlin/com/saveourtool/processbuilder/ProcessBuilderInternal.kt b/core/src/commonMain/kotlin/com/saveourtool/processbuilder/ProcessBuilderInternal.kt new file mode 100644 index 0000000..6298511 --- /dev/null +++ b/core/src/commonMain/kotlin/com/saveourtool/processbuilder/ProcessBuilderInternal.kt @@ -0,0 +1,37 @@ +/** + * Utilities to run a process and get its result. + */ + +package com.saveourtool.processbuilder + +import okio.Path + +/** + * A class that is capable of executing processes, specific to different OS and returning their output. + */ +expect class ProcessBuilderInternal( + stdoutFile: Path, + stderrFile: Path, + useInternalRedirections: Boolean, +) { + /** + * Modify execution command according behavior of different OS, + * also stdout and stderr will be redirected to tmp files + * + * @param command raw command + * @return command with redirection of stderr to tmp file + */ + fun prepareCmd(command: String): String + + /** + * Execute [cmd] and wait for its completion. + * + * @param cmd executable command with arguments + * @param timeOutMillis max command execution time + * @return exit status + */ + fun exec( + cmd: String, + timeOutMillis: Long, + ): Int +} diff --git a/core/src/commonMain/kotlin/com/saveourtool/processbuilder/exceptions/ProcessExecutionException.kt b/core/src/commonMain/kotlin/com/saveourtool/processbuilder/exceptions/ProcessExecutionException.kt new file mode 100644 index 0000000..659da70 --- /dev/null +++ b/core/src/commonMain/kotlin/com/saveourtool/processbuilder/exceptions/ProcessExecutionException.kt @@ -0,0 +1,6 @@ +package com.saveourtool.processbuilder.exceptions + +/** + * An [Exception] that can be thrown in case of impossibility of command execution + */ +open class ProcessExecutionException(message: String) : Exception(message) diff --git a/core/src/commonMain/kotlin/com/saveourtool/processbuilder/exceptions/ProcessTimeoutException.kt b/core/src/commonMain/kotlin/com/saveourtool/processbuilder/exceptions/ProcessTimeoutException.kt new file mode 100644 index 0000000..7de4cba --- /dev/null +++ b/core/src/commonMain/kotlin/com/saveourtool/processbuilder/exceptions/ProcessTimeoutException.kt @@ -0,0 +1,6 @@ +package com.saveourtool.processbuilder.exceptions + +/** + * @property timeoutMillis + */ +class ProcessTimeoutException(val timeoutMillis: Long, message: String) : ProcessExecutionException(message) diff --git a/core/src/commonMain/kotlin/com/saveourtool/processbuilder/utils/Constants.kt b/core/src/commonMain/kotlin/com/saveourtool/processbuilder/utils/Constants.kt deleted file mode 100644 index e789b6d..0000000 --- a/core/src/commonMain/kotlin/com/saveourtool/processbuilder/utils/Constants.kt +++ /dev/null @@ -1,7 +0,0 @@ -/** - * File containing constants - */ - -package com.saveourtool.processbuilder.utils - -const val NOT_IMPLEMENTED_IN_JS = "Not implemented in JS" diff --git a/core/src/commonMain/kotlin/com/saveourtool/processbuilder/utils/FileUtils.kt b/core/src/commonMain/kotlin/com/saveourtool/processbuilder/utils/FileUtils.kt new file mode 100644 index 0000000..7515d05 --- /dev/null +++ b/core/src/commonMain/kotlin/com/saveourtool/processbuilder/utils/FileUtils.kt @@ -0,0 +1,42 @@ +/** + * File containing utils for files + */ + +@file:JvmName("FileUtilsJVM") + +package com.saveourtool.processbuilder.utils + +import io.github.oshai.KotlinLogging +import okio.FileSystem +import okio.Path +import kotlin.jvm.JvmName + +expect val fs: FileSystem + +internal val fileUtilsLogger = KotlinLogging.logger { } + +/** + * Delete this directory and all other files and directories in it + * + * @param path a path to a directory + */ +expect fun FileSystem.myDeleteRecursively(path: Path) + +/** + * Create file in [this] [FileSystem], denoted by [Path] [path] + * + * @param path path to a new file + * @return [path] + */ +fun FileSystem.createFile(path: Path): Path { + sink(path).close() + return path +} + +/** + * @param path a path to a file + * @return list of strings from the file + */ +fun FileSystem.readLines(path: Path): List = this.read(path) { + generateSequence { readUtf8Line() }.toList() +} diff --git a/core/src/commonMain/kotlin/com/saveourtool/processbuilder/utils/PlatformUtils.kt b/core/src/commonMain/kotlin/com/saveourtool/processbuilder/utils/PlatformUtils.kt new file mode 100644 index 0000000..de199a1 --- /dev/null +++ b/core/src/commonMain/kotlin/com/saveourtool/processbuilder/utils/PlatformUtils.kt @@ -0,0 +1,35 @@ +/** + * File with platform utils + */ + +@file:Suppress("FILE_NAME_MATCH_CLASS", "MatchingDeclarationName") +@file:JvmName("PlatformUtilsJVM") + +package com.saveourtool.processbuilder.utils + +import kotlin.jvm.JvmName + +/** + * Supported platforms + */ +enum class CurrentOs { + LINUX, + MACOS, + UNDEFINED, + WINDOWS, + ; +} + +/** + * Get type of current OS + * + * @return type of current OS + */ +expect fun getCurrentOs(): CurrentOs + +/** + * Checks if the current OS is windows. + * + * @return true if current OS is Windows + */ +fun isCurrentOsWindows(): Boolean = (getCurrentOs() == CurrentOs.WINDOWS) diff --git a/core/src/commonTest/kotlin/com/saveourtool/processbuilder/ProcessBuilderTest.kt b/core/src/commonTest/kotlin/com/saveourtool/processbuilder/ProcessBuilderTest.kt new file mode 100644 index 0000000..a72ac0a --- /dev/null +++ b/core/src/commonTest/kotlin/com/saveourtool/processbuilder/ProcessBuilderTest.kt @@ -0,0 +1,112 @@ +package com.saveourtool.processbuilder + +import com.saveourtool.processbuilder.ProcessBuilder.Companion.processCommandWithEcho +import com.saveourtool.processbuilder.exceptions.ProcessExecutionException +import com.saveourtool.processbuilder.utils.fs +import com.saveourtool.processbuilder.utils.isCurrentOsWindows +import kotlin.test.Test +import kotlin.test.assertEquals + +class ProcessBuilderTest { + private val processBuilder = ProcessBuilder(useInternalRedirections = true, fs) + + @Test + fun `empty command`() { + try { + processBuilder.exec(" ", "", null, 10_000L) + } catch (ex: ProcessExecutionException) { + assertEquals("Execution command in ProcessBuilder couldn't be empty!", ex.message) + } + } + + @Test + fun `check stdout`() { + val actualResult = processBuilder.exec("echo something", "", null, 10_000L) + val expectedCode = 0 + val expectedStdout = listOf("something") + assertEquals(expectedCode, actualResult.code) + assertEquals(expectedStdout, actualResult.stdout) + assertEquals(emptyList(), actualResult.stderr) + } + + @Test + fun `check stdout with redirection`() { + val actualResult = processBuilder.exec("echo something >/dev/null", "", null, 10_000L) + val (expectedCode, expectedStderr) = when { + isCurrentOsWindows() -> 1 to listOf("The system cannot find the path specified.") + else -> 0 to emptyList() + } + assertEquals(expectedCode, actualResult.code) + assertEquals(emptyList(), actualResult.stdout) + assertEquals(expectedStderr, actualResult.stderr) + } + + @Test + fun `command without echo`() { + val inputCommand = "cd /some/dir; cat /some/file ; ls" + assertEquals(inputCommand, processCommandWithEcho(inputCommand)) + } + + @Test + fun `simple check`() { + val inputCommand = "echo something" + val expectedCommand = "echo | set /p dummyName=\"something\"" + assertEquals(expectedCommand, processCommandWithEcho(inputCommand)) + } + + @Test + fun `simple check with redirection`() { + val inputCommand = "echo something > /dev/null" + val expectedCommand = "echo | set /p dummyName=\"something\" > /dev/null" + assertEquals(expectedCommand, processCommandWithEcho(inputCommand)) + } + + @Test + fun `simple check with redirection without first whitespace`() { + val inputCommand = "echo something> /dev/null" + val expectedCommand = "echo | set /p dummyName=\"something\" > /dev/null" + assertEquals(expectedCommand, processCommandWithEcho(inputCommand)) + } + + @Test + fun `simple check with redirection without whitespaces at all`() { + val inputCommand = "echo something>/dev/null" + val expectedCommand = "echo | set /p dummyName=\"something\" >/dev/null" + assertEquals(expectedCommand, processCommandWithEcho(inputCommand)) + } + + @Test + fun `one long echo`() { + val inputCommand = "echo stub STUB stub foo bar " + val expectedCommand = "echo | set /p dummyName=\"stub STUB stub foo bar\"" + assertEquals(expectedCommand, processCommandWithEcho(inputCommand)) + } + + @Test + fun `change multiple echo commands with redirections`() { + val inputCommand = "echo a > /dev/null && echo b 2>/dev/null && ls" + val expectedCommand = "echo | set /p dummyName=\"a\" > /dev/null && echo | set /p dummyName=\"b\" 2>/dev/null && ls" + assertEquals(expectedCommand, processCommandWithEcho(inputCommand)) + } + + @Test + fun `change multiple echo commands with redirections 2`() { + val inputCommand = "echo a > /dev/null ; echo b 2>/dev/null ; ls" + val expectedCommand = "echo | set /p dummyName=\"a\" > /dev/null ; echo | set /p dummyName=\"b\" 2>/dev/null ; ls" + assertEquals(expectedCommand, processCommandWithEcho(inputCommand)) + } + + @Test + fun `change multiple echo commands with redirections 3`() { + val inputCommand = "echo a > /dev/null; echo b 2>/dev/null; ls" + val expectedCommand = "echo | set /p dummyName=\"a\" > /dev/null ; echo | set /p dummyName=\"b\" 2>/dev/null ; ls" + assertEquals(expectedCommand, processCommandWithEcho(inputCommand)) + } + + @Test + fun `extra whitespaces shouldn't influence to echo`() { + val inputCommand = "echo foo bar ; echo b; ls" + val expectedCommand = "echo | set /p dummyName=\"foo bar\" ; echo | set /p dummyName=\"b\" ; ls" + assertEquals(expectedCommand, processCommandWithEcho(inputCommand)) + } +} diff --git a/core/src/jvmMain/kotlin/com.saveourtool.processbuilder/ProcessBuilderInternal.kt b/core/src/jvmMain/kotlin/com.saveourtool.processbuilder/ProcessBuilderInternal.kt new file mode 100644 index 0000000..97143c3 --- /dev/null +++ b/core/src/jvmMain/kotlin/com.saveourtool.processbuilder/ProcessBuilderInternal.kt @@ -0,0 +1,96 @@ +/** + * Utilities to run a process and get its result. + */ + +package com.saveourtool.processbuilder + +import com.saveourtool.processbuilder.exceptions.ProcessTimeoutException +import com.saveourtool.processbuilder.utils.fs +import com.saveourtool.processbuilder.utils.isCurrentOsWindows + +import okio.Path + +import java.io.BufferedReader +import java.io.InputStreamReader + +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.newFixedThreadPoolContext +import kotlinx.coroutines.runBlocking + +actual class ProcessBuilderInternal actual constructor( + private val stdoutFile: Path, + private val stderrFile: Path, + private val useInternalRedirections: Boolean, +) { + actual fun prepareCmd(command: String): String { + val cmd = buildList { + if (isCurrentOsWindows()) { + add("CMD") + add("/C") + } else { + add("sh") + add("-c") + } + add(command) + } + return cmd.joinToString() + } + + @OptIn(DelicateCoroutinesApi::class) + @Suppress("UnsafeCallOnNullableType") + actual fun exec( + cmd: String, + timeOutMillis: Long, + ): Int { + var status = -1 + runBlocking { + val processContext = newFixedThreadPoolContext(2, "timeOut") + + val runTime = Runtime.getRuntime() + var process: Process? = null + val job = launch(processContext) { + val timeOut = launch { + delay(timeOutMillis) + process?.destroy() + throw ProcessTimeoutException(timeOutMillis, "Timeout is reached: $timeOutMillis") + } + launch { + process = runTime.exec(cmd.split(", ").toTypedArray()) + writeDataFromBufferToFile(process!!, "stdout", stdoutFile) + writeDataFromBufferToFile(process!!, "stderr", stderrFile) + status = process!!.waitFor() + timeOut.cancel() + } + } + job.join() + } + return status + } + + private fun writeDataFromBufferToFile( + process: Process, + stream: String, + file: Path, + ) { + if (!useInternalRedirections) { + return + } + val br = BufferedReader( + InputStreamReader( + if (stream == "stdout") { + process.inputStream + } else { + process.errorStream + } + ) + ) + val data = br.useLines { + it.joinToString("\n") + } + fs.write(file) { + write(data.encodeToByteArray()) + } + } +} diff --git a/core/src/jvmMain/kotlin/com.saveourtool.processbuilder/utils/FileUtils.kt b/core/src/jvmMain/kotlin/com.saveourtool.processbuilder/utils/FileUtils.kt new file mode 100644 index 0000000..cb80401 --- /dev/null +++ b/core/src/jvmMain/kotlin/com.saveourtool.processbuilder/utils/FileUtils.kt @@ -0,0 +1,18 @@ +/** + * File containing utils for files + */ + +package com.saveourtool.processbuilder.utils + +import okio.FileSystem +import okio.Path +import java.nio.file.Files + +actual val fs: FileSystem = FileSystem.SYSTEM + +actual fun FileSystem.myDeleteRecursively(path: Path) { + path.toFile().walkBottomUp().forEach { file -> + fileUtilsLogger.trace { "Attempt to delete file $file" } + Files.delete(file.toPath()) + } +} diff --git a/core/src/jvmMain/kotlin/com.saveourtool.processbuilder/utils/PlatformUtils.kt b/core/src/jvmMain/kotlin/com.saveourtool.processbuilder/utils/PlatformUtils.kt new file mode 100644 index 0000000..6f21739 --- /dev/null +++ b/core/src/jvmMain/kotlin/com.saveourtool.processbuilder/utils/PlatformUtils.kt @@ -0,0 +1,12 @@ +/** + * File with platform utils + */ + +package com.saveourtool.processbuilder.utils + +actual fun getCurrentOs(): CurrentOs = when { + System.getProperty("os.name").startsWith("Linux", ignoreCase = true) -> CurrentOs.LINUX + System.getProperty("os.name").startsWith("Mac", ignoreCase = true) -> CurrentOs.MACOS + System.getProperty("os.name").startsWith("Windows", ignoreCase = true) -> CurrentOs.WINDOWS + else -> CurrentOs.UNDEFINED +} diff --git a/core/src/jvmTest/kotlin/com/saveourtool/processbuilder/ProcessBuilderInternalTest.kt b/core/src/jvmTest/kotlin/com/saveourtool/processbuilder/ProcessBuilderInternalTest.kt new file mode 100644 index 0000000..b8f1105 --- /dev/null +++ b/core/src/jvmTest/kotlin/com/saveourtool/processbuilder/ProcessBuilderInternalTest.kt @@ -0,0 +1,39 @@ +package com.saveourtool.processbuilder + +import com.saveourtool.processbuilder.utils.CurrentOs +import com.saveourtool.processbuilder.utils.getCurrentOs +import okio.FileSystem +import kotlin.test.Test +import kotlin.test.assertEquals + +class ProcessBuilderInternalTest { + private val processBuilder = ProcessBuilder(useInternalRedirections = true, FileSystem.SYSTEM) + + @Test + fun `check stderr`() { + val actualResult = processBuilder.exec("cd non_existent_dir", "", null, 10_000L) + val (expectedCode, expectedStderr) = when (getCurrentOs()) { + CurrentOs.LINUX -> 2 to listOf("sh: 1: cd: can't cd to non_existent_dir") + CurrentOs.MACOS -> 1 to listOf("sh: line 0: cd: non_existent_dir: No such file or directory") + CurrentOs.WINDOWS -> 1 to listOf("The system cannot find the path specified.") + else -> return + } + assertEquals(expectedCode, actualResult.code) + assertEquals(emptyList(), actualResult.stdout) + assertEquals(expectedStderr, actualResult.stderr) + } + + @Test + fun `check stderr with additional warning`() { + val actualResult = processBuilder.exec("cd non_existent_dir 2>/dev/null", "", null, 10_000L) + val (expectedCode, expectedStderr) = when (getCurrentOs()) { + CurrentOs.LINUX -> 2 to emptyList() + CurrentOs.MACOS -> 1 to emptyList() + CurrentOs.WINDOWS -> 1 to listOf("The system cannot find the path specified.") + else -> return + } + assertEquals(expectedCode, actualResult.code) + assertEquals(emptyList(), actualResult.stdout) + assertEquals(expectedStderr, actualResult.stderr) + } +} diff --git a/core/src/nativeMain/kotlin/com.saveourtool.processbuilder/ProcessBuilderInternal.kt b/core/src/nativeMain/kotlin/com.saveourtool.processbuilder/ProcessBuilderInternal.kt new file mode 100644 index 0000000..51026df --- /dev/null +++ b/core/src/nativeMain/kotlin/com.saveourtool.processbuilder/ProcessBuilderInternal.kt @@ -0,0 +1,72 @@ +/** + * Utilities to run a process and get its result. + */ + +package com.saveourtool.processbuilder + +import com.saveourtool.processbuilder.exceptions.ProcessTimeoutException +import com.saveourtool.processbuilder.utils.isCurrentOsWindows + +import okio.Path +import platform.posix.system + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.runBlocking + +@Suppress( + "MISSING_KDOC_TOP_LEVEL", + "MISSING_KDOC_CLASS_ELEMENTS", + "MISSING_KDOC_ON_FUNCTION" +) +actual class ProcessBuilderInternal actual constructor( + private val stdoutFile: Path, + private val stderrFile: Path, + private val useInternalRedirections: Boolean +) { + actual fun prepareCmd(command: String) = if (useInternalRedirections) { + "($command) >$stdoutFile 2>$stderrFile" + } else { + command + } + + @OptIn(ExperimentalCoroutinesApi::class) + actual fun exec( + cmd: String, + timeOutMillis: Long + ): Int { + var status = -1 + + runBlocking { + val timeOut = async(newSingleThreadContext("timeOut")) { + delay(timeOutMillis) + destroy(cmd) + throw ProcessTimeoutException(timeOutMillis, "Timeout is reached: $timeOutMillis") + } + + val command = async { + status = system(cmd) + timeOut.cancel() + } + joinAll(timeOut, command) + } + + if (status == -1) { + error("Couldn't execute $cmd, exit status: $status") + } + return status + } + + private fun destroy(cmd: String) { + val killCmd = if (isCurrentOsWindows()) { + "taskkill /im \"$cmd\" /f" + } else { + "pkill \"$cmd\"" + } + system(killCmd) + logger.trace { "Executed kill command: $killCmd" } + } +} diff --git a/core/src/nativeMain/kotlin/com.saveourtool.processbuilder/utils/FileUtils.kt b/core/src/nativeMain/kotlin/com.saveourtool.processbuilder/utils/FileUtils.kt new file mode 100644 index 0000000..e365f40 --- /dev/null +++ b/core/src/nativeMain/kotlin/com.saveourtool.processbuilder/utils/FileUtils.kt @@ -0,0 +1,25 @@ +/** + * File containing utils for files + */ + +package com.saveourtool.processbuilder.utils + +import okio.FileSystem +import okio.Path +import platform.posix.FTW_DEPTH +import platform.posix.nftw +import platform.posix.remove + +import kotlinx.cinterop.staticCFunction +import kotlinx.cinterop.toKString + +actual val fs: FileSystem = FileSystem.SYSTEM + +@Suppress("MAGIC_NUMBER", "MagicNumber") +actual fun FileSystem.myDeleteRecursively(path: Path) { + nftw(path.toString(), staticCFunction { pathName, _, _, _ -> + val fileName = pathName!!.toKString() + fileUtilsLogger.trace { "Attempt to delete file $fileName" } + remove(fileName) + }, 64, FTW_DEPTH) +} diff --git a/core/src/nativeMain/kotlin/com.saveourtool.processbuilder/utils/PlatformUtils.kt b/core/src/nativeMain/kotlin/com.saveourtool.processbuilder/utils/PlatformUtils.kt new file mode 100644 index 0000000..ca457cf --- /dev/null +++ b/core/src/nativeMain/kotlin/com.saveourtool.processbuilder/utils/PlatformUtils.kt @@ -0,0 +1,12 @@ +/** + * File with platform utils + */ + +package com.saveourtool.processbuilder.utils + +actual fun getCurrentOs() = when (Platform.osFamily) { + OsFamily.LINUX -> CurrentOs.LINUX + OsFamily.MACOSX -> CurrentOs.MACOS + OsFamily.WINDOWS -> CurrentOs.WINDOWS + else -> CurrentOs.UNDEFINED +} diff --git a/core/src/nativeTest/kotlin/com/saveourtool/processbuilder/ProcessBuilderInternalTest.kt b/core/src/nativeTest/kotlin/com/saveourtool/processbuilder/ProcessBuilderInternalTest.kt new file mode 100644 index 0000000..0e3e380 --- /dev/null +++ b/core/src/nativeTest/kotlin/com/saveourtool/processbuilder/ProcessBuilderInternalTest.kt @@ -0,0 +1,39 @@ +package com.saveourtool.processbuilder + +import com.saveourtool.processbuilder.utils.CurrentOs +import com.saveourtool.processbuilder.utils.getCurrentOs +import okio.FileSystem +import kotlin.test.Test +import kotlin.test.assertEquals + +class ProcessBuilderInternalTest { + private val processBuilder = ProcessBuilder(useInternalRedirections = true, FileSystem.SYSTEM) + + @Test + fun `check stderr`() { + val actualResult = processBuilder.exec("cd non_existent_dir", "", null, 10_000L) + val (expectedCode, expectedStderr) = when (getCurrentOs()) { + CurrentOs.LINUX -> 512 to listOf("sh: 1: cd: can't cd to non_existent_dir") + CurrentOs.MACOS -> 256 to listOf("sh: line 0: cd: non_existent_dir: No such file or directory") + CurrentOs.WINDOWS -> 1 to listOf("The system cannot find the path specified.") + else -> return + } + assertEquals(expectedCode, actualResult.code) + assertEquals(emptyList(), actualResult.stdout) + assertEquals(expectedStderr, actualResult.stderr) + } + + @Test + fun `check stderr with additional warning`() { + val actualResult = processBuilder.exec("cd non_existent_dir 2>/dev/null", "", null, 10_000L) + val (expectedCode, expectedStderr) = when (getCurrentOs()) { + CurrentOs.LINUX -> 512 to emptyList() + CurrentOs.MACOS -> 256 to emptyList() + CurrentOs.WINDOWS -> 1 to listOf("The system cannot find the path specified.") + else -> return + } + assertEquals(expectedCode, actualResult.code) + assertEquals(emptyList(), actualResult.stdout) + assertEquals(expectedStderr, actualResult.stderr) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7cd3d92..a2b1822 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,7 +10,7 @@ detekt = "1.22.0" kotlin-logging = "4.0.0-beta-28" slf4j = "2.0.3" # test -kotest = "5.5.5" +kotest = "5.5.4" junit = "5.9.2" [plugins]