diff --git a/README.md b/README.md index 78355f6..e5489fe 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ and it is multithreaded on platforms supporting it (automatically, no code chang - [Language home](https://lynglang.com) - [introduction and tutorial](docs/tutorial.md) - start here please - [Testing and Assertions](docs/Testing.md) +- [Filesystem and Processes (lyngio)](docs/lyngio.md) - [Return Statement](docs/return_statement.md) - [Efficient Iterables in Kotlin Interop](docs/EfficientIterables.md) - [Samples directory](docs/samples) diff --git a/docs/lyng.io.fs.md b/docs/lyng.io.fs.md index 4bb96e5..aa92891 100644 --- a/docs/lyng.io.fs.md +++ b/docs/lyng.io.fs.md @@ -8,7 +8,7 @@ This module provides a uniform, suspend-first filesystem API to Lyng scripts, ba It exposes a Lyng class `Path` with methods for file and directory operations, including streaming readers for large files. -It is a separate library because access to teh filesystem is a security risk we compensate with a separate API that user must explicitly include to the dependency and allow. Together with `FsAceessPolicy` that is required to `createFs()` which actually adds the filesystem to the scope, the security risk is isolated. +It is a separate library because access to the filesystem is a security risk we compensate with a separate API that user must explicitly include to the dependency and allow. Together with `FsAccessPolicy` that is required to `createFs()` which actually adds the filesystem to the scope, the security risk is isolated. Also, it helps keep Lyng core small and focused. @@ -23,7 +23,7 @@ dependencies { implementation("net.sergeych:lyngio:0.0.1-SNAPSHOT") } ``` -Note on maven repository. Lyngio uses ths same maven as Lyng code (`lynglib`) so it is most likely already in your project. If ont, add it to the proper section of your `build.gradle.kts` or settings.gradle.kts: +Note on maven repository. Lyngio uses the same maven as Lyng code (`lynglib`) so it is most likely already in your project. If not, add it to the proper section of your `build.gradle.kts` or settings.gradle.kts: ```kotlin repositories { @@ -43,9 +43,13 @@ This brings in: The filesystem module is not installed automatically. You must explicitly register it in the scope’s `ImportManager` using the installer. You can customize access control via `FsAccessPolicy`. -Kotlin (host) bootstrap example (imports omitted for brevity): +Kotlin (host) bootstrap example: ```kotlin +import net.sergeych.lyng.Scope +import net.sergeych.lyng.io.fs.createFs +import net.sergeych.lyngio.fs.security.PermitAllAccessPolicy + val scope: Scope = Scope.new() val installed: Boolean = createFs(PermitAllAccessPolicy, scope) // installed == true on first registration in this ImportManager, false on repeats diff --git a/docs/lyng.io.process.md b/docs/lyng.io.process.md new file mode 100644 index 0000000..24e395f --- /dev/null +++ b/docs/lyng.io.process.md @@ -0,0 +1,136 @@ +### lyng.io.process — Process execution and control for Lyng scripts + +This module provides a way to run external processes and shell commands from Lyng scripts. It is designed to be multiplatform and uses coroutines for non-blocking execution. + +> **Note:** `lyngio` is a separate library module. It must be explicitly added as a dependency to your host application and initialized in your Lyng scopes. + +--- + +#### Add the library to your project (Gradle) + +If you use this repository as a multi-module project, add a dependency on `:lyngio`: + +```kotlin +dependencies { + implementation("net.sergeych:lyngio:0.0.1-SNAPSHOT") +} +``` + +For external projects, ensure you have the appropriate Maven repository configured (see `lyng.io.fs` documentation). + +--- + +#### Install the module into a Lyng Scope + +The process module is not installed automatically. You must explicitly register it in the scope’s `ImportManager` using `createProcessModule`. You can customize access control via `ProcessAccessPolicy`. + +Kotlin (host) bootstrap example: + +```kotlin +import net.sergeych.lyng.Scope +import net.sergeych.lyng.Script +import net.sergeych.lyng.io.process.createProcessModule +import net.sergeych.lyngio.process.security.PermitAllProcessAccessPolicy + +// ... inside a suspend function or runBlocking +val scope: Scope = Script.newScope() +createProcessModule(PermitAllProcessAccessPolicy, scope) + +// In scripts (or via scope.eval), import the module: +scope.eval("import lyng.io.process") +``` + +--- + +#### Using from Lyng scripts + +```lyng +import lyng.io.process + +// Execute a process with arguments +val p = Process.execute("ls", ["-l", "/tmp"]) +for (line in p.stdout) { + println("OUT: " + line) +} +val exitCode = p.waitFor() +println("Process exited with: " + exitCode) + +// Run a shell command +val sh = Process.shell("echo 'Hello from shell' | wc -w") +for (line in sh.stdout) { + println("Word count: " + line.trim()) +} + +// Platform information +val details = Platform.details() +println("OS: " + details.name + " " + details.version + " (" + details.arch + ")") +if (details.kernelVersion != null) { + println("Kernel: " + details.kernelVersion) +} + +if (Platform.isSupported()) { + println("Processes are supported!") +} +``` + +--- + +#### API Reference + +##### `Process` (static methods) +- `execute(executable: String, args: List): RunningProcess` — Start an external process. +- `shell(command: String): RunningProcess` — Run a command through the system shell (e.g., `/bin/sh` or `cmd.exe`). + +##### `RunningProcess` (instance methods) +- `stdout: Flow` — Standard output stream as a Lyng Flow of lines. +- `stderr: Flow` — Standard error stream as a Lyng Flow of lines. +- `waitFor(): Int` — Wait for the process to exit and return the exit code. +- `signal(name: String)` — Send a signal to the process (e.g., `"SIGINT"`, `"SIGTERM"`, `"SIGKILL"`). +- `destroy()` — Forcefully terminate the process. + +##### `Platform` (static methods) +- `details(): Map` — Get platform details. Returned map keys: `name`, `version`, `arch`, `kernelVersion`. +- `isSupported(): Bool` — True if process execution is supported on the current platform. + +--- + +#### Security Policy + +Process execution is a sensitive operation. `lyngio` uses `ProcessAccessPolicy` to control access to `execute` and `shell` operations. + +- `ProcessAccessPolicy` — Interface for custom policies. +- `PermitAllProcessAccessPolicy` — Allows all operations. +- `ProcessAccessOp` (sealed) — Operations to check: + - `Execute(executable, args)` + - `Shell(command)` + +Example of a restricted policy in Kotlin: + +```kotlin +import net.sergeych.lyngio.fs.security.AccessDecision +import net.sergeych.lyngio.fs.security.Decision +import net.sergeych.lyngio.process.security.ProcessAccessOp +import net.sergeych.lyngio.process.security.ProcessAccessPolicy + +val restrictedPolicy = object : ProcessAccessPolicy { + override suspend fun check(op: ProcessAccessOp, ctx: AccessContext): AccessDecision { + return when (op) { + is ProcessAccessOp.Execute -> { + if (op.executable == "ls") AccessDecision(Decision.Allow) + else AccessDecision(Decision.Deny, "Only 'ls' is allowed") + } + is ProcessAccessOp.Shell -> AccessDecision(Decision.Deny, "Shell is forbidden") + } + } +} +createProcessModule(restrictedPolicy, scope) +``` + +--- + +#### Platform Support + +- **JVM:** Full support using `ProcessBuilder`. +- **Native (Linux/macOS):** Support via POSIX. +- **Windows:** Support planned. +- **Android/JS/iOS/Wasm:** Currently not supported; `isSupported()` returns `false` and attempts to run processes will throw `UnsupportedOperationException`. diff --git a/docs/lyngio.md b/docs/lyngio.md new file mode 100644 index 0000000..c10dea0 --- /dev/null +++ b/docs/lyngio.md @@ -0,0 +1,87 @@ +### lyngio — Extended I/O and System Library for Lyng + +`lyngio` is a separate library that extends the Lyng core (`lynglib`) with powerful, multiplatform, and secure I/O capabilities. + +#### Why a separate module? + +1. **Security:** I/O and process execution are sensitive operations. By keeping them in a separate module, we ensure that the Lyng core remains 100% safe by default. You only enable what you explicitly need. +2. **Footprint:** Not every script needs filesystem or process access. Keeping these as a separate module helps minimize the dependency footprint for small embedded projects. +3. **Control:** `lyngio` provides fine-grained security policies (`FsAccessPolicy`, `ProcessAccessPolicy`) that allow you to control exactly what a script can do. + +#### Included Modules + +- **[lyng.io.fs](lyng.io.fs.md):** Async filesystem access. Provides the `Path` class for file/directory operations, streaming, and globbing. +- **[lyng.io.process](lyng.io.process.md):** External process execution and shell commands. Provides `Process`, `RunningProcess`, and `Platform` information. + +--- + +#### Quick Start: Embedding lyngio + +##### 1. Add Dependencies (Gradle) + +```kotlin +repositories { + maven("https://gitea.sergeych.net/api/packages/SergeychWorks/maven") +} + +dependencies { + // Both are required for full I/O support + implementation("net.sergeych:lynglib:0.0.1-SNAPSHOT") + implementation("net.sergeych:lyngio:0.0.1-SNAPSHOT") +} +``` + +##### 2. Initialize in Kotlin (JVM or Native) + +To use `lyngio` modules in your scripts, you must install them into your Lyng scope and provide a security policy. + +```kotlin +import net.sergeych.lyng.Script +import net.sergeych.lyng.io.fs.createFs +import net.sergeych.lyng.io.process.createProcessModule +import net.sergeych.lyngio.fs.security.PermitAllAccessPolicy +import net.sergeych.lyngio.process.security.PermitAllProcessAccessPolicy + +suspend fun runMyScript() { + val scope = Script.newScope() + + // Install modules with policies + createFs(PermitAllAccessPolicy, scope) + createProcessModule(PermitAllProcessAccessPolicy, scope) + + // Now scripts can import them + scope.eval(""" + import lyng.io.fs + import lyng.io.process + + println("Working dir: " + Path(".").readUtf8()) + println("OS: " + Platform.details().name) + """) +} +``` + +--- + +#### Security Tools + +`lyngio` is built with a "Secure by Default" philosophy. Every I/O or process operation is checked against a policy. + +- **Filesystem Security:** Implement `FsAccessPolicy` to restrict access to specific paths or operations (e.g., read-only access to a sandbox directory). +- **Process Security:** Implement `ProcessAccessPolicy` to restrict which executables can be run or to disable shell execution entirely. + +For more details, see the specific module documentation: +- [Filesystem Security Details](lyng.io.fs.md#access-policy-security) +- [Process Security Details](lyng.io.process.md#security-policy) + +--- + +#### Platform Support Overview + +| Platform | lyng.io.fs | lyng.io.process | +| :--- | :---: | :---: | +| **JVM** | ✅ | ✅ | +| **Native (Linux/macOS)** | ✅ | ✅ | +| **Native (Windows)** | ✅ | 🚧 (Planned) | +| **Android** | ✅ | ❌ | +| **NodeJS** | ✅ | ❌ | +| **Browser / Wasm** | ✅ (In-memory) | ❌ | diff --git a/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/process/PlatformAndroid.kt b/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/process/PlatformAndroid.kt new file mode 100644 index 0000000..3c6a33e --- /dev/null +++ b/lyngio/src/androidMain/kotlin/net/sergeych/lyngio/process/PlatformAndroid.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyngio.process + +actual fun getPlatformDetails(): PlatformDetails { + return PlatformDetails( + name = "Android", + version = android.os.Build.VERSION.RELEASE, + arch = android.os.Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown", + kernelVersion = System.getProperty("os.version") + ) +} + +actual fun isProcessSupported(): Boolean = false + +actual fun getSystemProcessRunner(): LyngProcessRunner { + throw UnsupportedOperationException("Processes are not supported on Android yet") +} diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/process/LyngProcessModule.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/process/LyngProcessModule.kt new file mode 100644 index 0000000..e96dfe0 --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/process/LyngProcessModule.kt @@ -0,0 +1,234 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyng.io.process + +import kotlinx.coroutines.flow.Flow +import net.sergeych.lyng.ModuleScope +import net.sergeych.lyng.Scope +import net.sergeych.lyng.miniast.* +import net.sergeych.lyng.obj.* +import net.sergeych.lyng.pacman.ImportManager +import net.sergeych.lyng.statement +import net.sergeych.lyngio.process.* +import net.sergeych.lyngio.process.security.ProcessAccessDeniedException +import net.sergeych.lyngio.process.security.ProcessAccessPolicy + +/** + * Install Lyng module `lyng.io.process` into the given scope's ImportManager. + */ +fun createProcessModule(policy: ProcessAccessPolicy, scope: Scope): Boolean = + createProcessModule(policy, scope.importManager) + +/** Same as [createProcessModule] but with explicit [ImportManager]. */ +fun createProcessModule(policy: ProcessAccessPolicy, manager: ImportManager): Boolean { + val name = "lyng.io.process" + if (manager.packageNames.contains(name)) return false + + manager.addPackage(name) { module -> + buildProcessModule(module, policy) + } + return true +} + +private suspend fun buildProcessModule(module: ModuleScope, policy: ProcessAccessPolicy) { + val runner = try { + SecuredLyngProcessRunner(getSystemProcessRunner(), policy) + } catch (e: Exception) { + null + } + + val runningProcessType = object : ObjClass("RunningProcess") {} + + runningProcessType.apply { + addFnDoc( + name = "stdout", + doc = "Get standard output stream as a Flow of lines.", + returns = type("lyng.Flow"), + moduleName = module.packageName + ) { + val self = thisAs() + self.process.stdout.toLyngFlow(this) + } + addFnDoc( + name = "stderr", + doc = "Get standard error stream as a Flow of lines.", + returns = type("lyng.Flow"), + moduleName = module.packageName + ) { + val self = thisAs() + self.process.stderr.toLyngFlow(this) + } + addFnDoc( + name = "signal", + doc = "Send a signal to the process (e.g. 'SIGINT', 'SIGTERM', 'SIGKILL').", + params = listOf(ParamDoc("signal", type("lyng.String"))), + moduleName = module.packageName + ) { + processGuard { + val sigStr = requireOnlyArg().value.uppercase() + val sig = try { + ProcessSignal.valueOf(sigStr) + } catch (e: Exception) { + try { + ProcessSignal.valueOf("SIG$sigStr") + } catch (e2: Exception) { + raiseIllegalArgument("Unknown signal: $sigStr") + } + } + thisAs().process.sendSignal(sig) + ObjVoid + } + } + addFnDoc( + name = "waitFor", + doc = "Wait for the process to exit and return its exit code.", + returns = type("lyng.Int"), + moduleName = module.packageName + ) { + processGuard { + thisAs().process.waitFor().toObj() + } + } + addFnDoc( + name = "destroy", + doc = "Forcefully terminate the process.", + moduleName = module.packageName + ) { + thisAs().process.destroy() + ObjVoid + } + } + + val processType = object : ObjClass("Process") {} + + processType.apply { + addClassFnDoc( + name = "execute", + doc = "Execute a process with arguments.", + params = listOf(ParamDoc("executable", type("lyng.String")), ParamDoc("args", type("lyng.List"))), + returns = type("RunningProcess"), + moduleName = module.packageName + ) { + if (runner == null) raiseError("Processes are not supported on this platform") + processGuard { + val executable = requiredArg(0).value + val args = requiredArg(1).list.map { it.toString() } + val lp = runner.execute(executable, args) + ObjRunningProcess(runningProcessType, lp) + } + } + addClassFnDoc( + name = "shell", + doc = "Execute a command via system shell.", + params = listOf(ParamDoc("command", type("lyng.String"))), + returns = type("RunningProcess"), + moduleName = module.packageName + ) { + if (runner == null) raiseError("Processes are not supported on this platform") + processGuard { + val command = requireOnlyArg().value + val lp = runner.shell(command) + ObjRunningProcess(runningProcessType, lp) + } + } + } + + val platformType = object : ObjClass("Platform") {} + + platformType.apply { + addClassFnDoc( + name = "details", + doc = "Get platform core details.", + returns = type("lyng.Map"), + moduleName = module.packageName + ) { + val d = getPlatformDetails() + ObjMap(mutableMapOf( + ObjString("name") to ObjString(d.name), + ObjString("version") to ObjString(d.version), + ObjString("arch") to ObjString(d.arch), + ObjString("kernelVersion") to (d.kernelVersion?.toObj() ?: ObjNull) + )) + } + addClassFnDoc( + name = "isSupported", + doc = "Check if processes are supported on this platform.", + returns = type("lyng.Bool"), + moduleName = module.packageName + ) { + isProcessSupported().toObj() + } + } + + module.addConstDoc( + name = "Process", + value = processType, + doc = "Process execution and control.", + type = type("Process"), + moduleName = module.packageName + ) + module.addConstDoc( + name = "Platform", + value = platformType, + doc = "Platform information.", + type = type("Platform"), + moduleName = module.packageName + ) + module.addConstDoc( + name = "RunningProcess", + value = runningProcessType, + doc = "Handle to a running process.", + type = type("RunningProcess"), + moduleName = module.packageName + ) +} + +class ObjRunningProcess( + override val objClass: ObjClass, + val process: LyngProcess +) : Obj() { + override fun toString(): String = "RunningProcess($process)" +} + +private suspend inline fun Scope.processGuard(crossinline block: suspend () -> Obj): Obj { + return try { + block() + } catch (e: ProcessAccessDeniedException) { + raiseError(ObjIllegalOperationException(this, e.reasonDetail ?: "process access denied")) + } catch (e: Exception) { + raiseError(ObjIllegalOperationException(this, e.message ?: "process error")) + } +} + +private fun Flow.toLyngFlow(flowScope: Scope): ObjFlow { + val producer = statement { + val builder = (this as? net.sergeych.lyng.ClosureScope)?.callScope?.thisObj as? ObjFlowBuilder + ?: this.thisObj as? ObjFlowBuilder + + this@toLyngFlow.collect { + try { + builder?.output?.send(ObjString(it)) + } catch (e: Exception) { + // Channel closed or other error, stop collecting + return@collect + } + } + ObjVoid + } + return ObjFlow(producer, flowScope) +} diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/docs/FsBuiltinDocs.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/docs/FsBuiltinDocs.kt index dbf5a77..b6098eb 100644 --- a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/docs/FsBuiltinDocs.kt +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/docs/FsBuiltinDocs.kt @@ -1,3 +1,20 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + /* * Filesystem module builtin docs registration, located in lyngio so core library * does not depend on external packages. The IDEA plugin (and any other tooling) diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/process/LyngProcess.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/process/LyngProcess.kt new file mode 100644 index 0000000..554d1be --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/process/LyngProcess.kt @@ -0,0 +1,93 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyngio.process + +import kotlinx.coroutines.flow.Flow +import net.sergeych.lyngio.process.security.ProcessAccessOp +import net.sergeych.lyngio.process.security.ProcessAccessPolicy + +/** + * Common signals for process control. + */ +enum class ProcessSignal { + SIGINT, SIGTERM, SIGKILL +} + +/** + * Multiplatform process representation. + */ +interface LyngProcess { + /** + * Standard output stream as a flow of strings (lines). + */ + val stdout: Flow + + /** + * Standard error stream as a flow of strings (lines). + */ + val stderr: Flow + + /** + * Send a signal to the process. + * Throws exception if signals are not supported on the platform or for this process. + */ + suspend fun sendSignal(signal: ProcessSignal) + + /** + * Wait for the process to exit and return the exit code. + */ + suspend fun waitFor(): Int + + /** + * Forcefully terminate the process. + */ + fun destroy() +} + +/** + * Interface for running processes. + */ +interface LyngProcessRunner { + /** + * Execute a process with the given executable and arguments. + */ + suspend fun execute(executable: String, args: List): LyngProcess + + /** + * Execute a command via the platform's default shell. + */ + suspend fun shell(command: String): LyngProcess +} + +/** + * Secured implementation of [LyngProcessRunner] that checks against a [ProcessAccessPolicy]. + */ +class SecuredLyngProcessRunner( + private val runner: LyngProcessRunner, + private val policy: ProcessAccessPolicy +) : LyngProcessRunner { + override suspend fun execute(executable: String, args: List): LyngProcess { + policy.require(ProcessAccessOp.Execute(executable, args)) + return runner.execute(executable, args) + } + + override suspend fun shell(command: String): LyngProcess { + policy.require(ProcessAccessOp.Shell(command)) + return runner.shell(command) + } +} diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/process/Platform.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/process/Platform.kt new file mode 100644 index 0000000..0e22b57 --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/process/Platform.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyngio.process + +/** + * Platform core details. + */ +data class PlatformDetails( + val name: String, + val version: String, + val arch: String, + val kernelVersion: String? = null +) + +/** + * Get the current platform core details. + */ +expect fun getPlatformDetails(): PlatformDetails + +/** + * Check whether the current platform supports processes and shell execution. + */ +expect fun isProcessSupported(): Boolean + +/** + * Get the system default [LyngProcessRunner]. + * Throws [UnsupportedOperationException] if processes are not supported on this platform. + */ +expect fun getSystemProcessRunner(): LyngProcessRunner diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/process/security/ProcessAccessPolicy.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/process/security/ProcessAccessPolicy.kt new file mode 100644 index 0000000..d5c12bd --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/process/security/ProcessAccessPolicy.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyngio.process.security + +import net.sergeych.lyngio.fs.security.AccessContext +import net.sergeych.lyngio.fs.security.AccessDecision +import net.sergeych.lyngio.fs.security.Decision + +/** + * Primitive process operations for access control decisions. + */ +sealed interface ProcessAccessOp { + data class Execute(val executable: String, val args: List) : ProcessAccessOp + data class Shell(val command: String) : ProcessAccessOp +} + +class ProcessAccessDeniedException( + val op: ProcessAccessOp, + val reasonDetail: String? = null, +) : IllegalStateException("Process access denied for $op" + (reasonDetail?.let { ": $it" } ?: "")) + +/** + * Policy interface that decides whether a specific process operation is allowed. + */ +interface ProcessAccessPolicy { + suspend fun check(op: ProcessAccessOp, ctx: AccessContext = AccessContext()): AccessDecision + + // Convenience helpers + suspend fun require(op: ProcessAccessOp, ctx: AccessContext = AccessContext()) { + val res = check(op, ctx) + if (!res.isAllowed()) throw ProcessAccessDeniedException(op, res.reason) + } + + suspend fun canExecute(executable: String, args: List, ctx: AccessContext = AccessContext()) = + check(ProcessAccessOp.Execute(executable, args), ctx).isAllowed() + + suspend fun canShell(command: String, ctx: AccessContext = AccessContext()) = + check(ProcessAccessOp.Shell(command), ctx).isAllowed() +} + +object PermitAllProcessAccessPolicy : ProcessAccessPolicy { + override suspend fun check(op: ProcessAccessOp, ctx: AccessContext): AccessDecision = + AccessDecision(Decision.Allow) +} diff --git a/lyngio/src/iosMain/kotlin/net/sergeych/lyngio/process/PlatformIos.kt b/lyngio/src/iosMain/kotlin/net/sergeych/lyngio/process/PlatformIos.kt new file mode 100644 index 0000000..8e9266a --- /dev/null +++ b/lyngio/src/iosMain/kotlin/net/sergeych/lyngio/process/PlatformIos.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyngio.process + +internal actual fun getNativeKernelVersion(): String? = null + +internal actual fun isNativeProcessSupported(): Boolean = false + +internal actual fun getNativeProcessRunner(): LyngProcessRunner { + throw UnsupportedOperationException("Processes are not supported on iOS") +} diff --git a/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/process/PlatformJs.kt b/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/process/PlatformJs.kt new file mode 100644 index 0000000..f3cd842 --- /dev/null +++ b/lyngio/src/jsMain/kotlin/net/sergeych/lyngio/process/PlatformJs.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyngio.process + +actual fun getPlatformDetails(): PlatformDetails { + return PlatformDetails( + name = "JavaScript", + version = "unknown", + arch = "unknown" + ) +} + +actual fun isProcessSupported(): Boolean = false + +actual fun getSystemProcessRunner(): LyngProcessRunner { + throw UnsupportedOperationException("Processes are not supported on JS") +} diff --git a/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/process/PlatformJvm.kt b/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/process/PlatformJvm.kt new file mode 100644 index 0000000..c883c58 --- /dev/null +++ b/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/process/PlatformJvm.kt @@ -0,0 +1,107 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyngio.process + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withContext + +actual fun getPlatformDetails(): PlatformDetails { + val osName = System.getProperty("os.name") + return PlatformDetails( + name = osName, + version = System.getProperty("os.version"), + arch = System.getProperty("os.arch"), + kernelVersion = if (osName.lowercase().contains("linux")) { + System.getProperty("os.version") + } else null + ) +} + +actual fun isProcessSupported(): Boolean = true + +actual fun getSystemProcessRunner(): LyngProcessRunner = JvmProcessRunner + +object JvmProcessRunner : LyngProcessRunner { + override suspend fun execute(executable: String, args: List): LyngProcess { + val process = ProcessBuilder(listOf(executable) + args) + .start() + return JvmLyngProcess(process) + } + + override suspend fun shell(command: String): LyngProcess { + val os = System.getProperty("os.name").lowercase() + val shellCmd = if (os.contains("win")) { + listOf("cmd.exe", "/c", command) + } else { + listOf("sh", "-c", command) + } + val process = ProcessBuilder(shellCmd) + .start() + return JvmLyngProcess(process) + } +} + +class JvmLyngProcess(private val process: Process) : LyngProcess { + override val stdout: Flow = flow { + val reader = process.inputStream.bufferedReader() + while (true) { + val line = reader.readLine() ?: break + emit(line) + } + } + + override val stderr: Flow = flow { + val reader = process.errorStream.bufferedReader() + while (true) { + val line = reader.readLine() ?: break + emit(line) + } + } + + override suspend fun sendSignal(signal: ProcessSignal) { + when (signal) { + ProcessSignal.SIGINT -> { + // SIGINT is hard on JVM without native calls or external 'kill' + val os = System.getProperty("os.name").lowercase() + if (os.contains("win")) { + throw UnsupportedOperationException("SIGINT not supported on Windows JVM") + } else { + // Try to use kill -2 + try { + val pid = process.pid() + Runtime.getRuntime().exec(arrayOf("kill", "-2", pid.toString())).waitFor() + } catch (e: Exception) { + throw UnsupportedOperationException("Failed to send SIGINT: ${e.message}") + } + } + } + ProcessSignal.SIGTERM -> process.destroy() + ProcessSignal.SIGKILL -> process.destroyForcibly() + } + } + + override suspend fun waitFor(): Int = withContext(Dispatchers.IO) { + process.waitFor() + } + + override fun destroy() { + process.destroy() + } +} diff --git a/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/process/LyngProcessModuleTest.kt b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/process/LyngProcessModuleTest.kt new file mode 100644 index 0000000..979ecba --- /dev/null +++ b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/process/LyngProcessModuleTest.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyng.io.process + +import kotlinx.coroutines.runBlocking +import net.sergeych.lyng.Compiler +import net.sergeych.lyng.Script +import net.sergeych.lyngio.process.security.PermitAllProcessAccessPolicy +import kotlin.test.Test +import kotlin.test.assertTrue + +class LyngProcessModuleTest { + + @Test + fun testLyngProcess() = runBlocking { + val scope = Script.newScope() + createProcessModule(PermitAllProcessAccessPolicy, scope) + + val code = """ + import lyng.io.process + + var p = Process.execute("echo", ["hello", "lyng"]) + var output = [] + for (line in p.stdout()) { + output.add(line) + } + p.waitFor() + output + """.trimIndent() + + val script = Compiler.compile(code) + val result = script.execute(scope) + assertTrue(result.inspect(scope).contains("hello lyng")) + } + + @Test + fun testLyngShell() = runBlocking { + val scope = Script.newScope() + createProcessModule(PermitAllProcessAccessPolicy, scope) + + val code = """ + import lyng.io.process + + var p = Process.shell("echo 'shell lyng'") + var output = "" + for (line in p.stdout()) { + output = output + line + } + p.waitFor() + output + """.trimIndent() + + val script = Compiler.compile(code) + val result = script.execute(scope) + assertTrue(result.inspect(scope).contains("shell lyng")) + } + + @Test + fun testPlatformDetails() = runBlocking { + val scope = Script.newScope() + createProcessModule(PermitAllProcessAccessPolicy, scope) + + val code = """ + import lyng.io.process + Platform.details() + """.trimIndent() + + val script = Compiler.compile(code) + val result = script.execute(scope) + assertTrue(result.inspect(scope).contains("name"), "Result should contain 'name', but was: ${result.inspect(scope)}") + } +} diff --git a/lyngio/src/jvmTest/kotlin/net/sergeych/lyngio/process/JvmProcessTest.kt b/lyngio/src/jvmTest/kotlin/net/sergeych/lyngio/process/JvmProcessTest.kt new file mode 100644 index 0000000..1f24e25 --- /dev/null +++ b/lyngio/src/jvmTest/kotlin/net/sergeych/lyngio/process/JvmProcessTest.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyngio.process + +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import net.sergeych.lyngio.process.security.PermitAllProcessAccessPolicy +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class JvmProcessTest { + + @Test + fun testExecute() = runBlocking { + val runner = getSystemProcessRunner() + val secured = SecuredLyngProcessRunner(runner, PermitAllProcessAccessPolicy) + + val process = secured.execute("echo", listOf("hello", "world")) + val output = process.stdout.toList() + assertEquals(listOf("hello world"), output) + assertEquals(0, process.waitFor()) + } + + @Test + fun testShell() = runBlocking { + val runner = getSystemProcessRunner() + val secured = SecuredLyngProcessRunner(runner, PermitAllProcessAccessPolicy) + + val process = secured.shell("echo 'hello shell'") + val output = process.stdout.toList() + assertEquals(listOf("hello shell"), output) + assertEquals(0, process.waitFor()) + } + + @Test + fun testPlatformDetails() { + val details = getPlatformDetails() + assertTrue(details.name.isNotEmpty()) + assertTrue(isProcessSupported()) + } +} diff --git a/lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/process/PlatformLinux.kt b/lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/process/PlatformLinux.kt new file mode 100644 index 0000000..bdb78e1 --- /dev/null +++ b/lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/process/PlatformLinux.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyngio.process + +import kotlinx.cinterop.* +import platform.posix.* + +@OptIn(ExperimentalForeignApi::class) +internal actual fun getNativeKernelVersion(): String? { + return memScoped { + val u = alloc() + if (uname(u.ptr) == 0) { + u.release.toKString() + } else null + } +} + +internal actual fun isNativeProcessSupported(): Boolean = true + +internal actual fun getNativeProcessRunner(): LyngProcessRunner = PosixProcessRunner diff --git a/lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/process/PosixProcessRunner.kt b/lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/process/PosixProcessRunner.kt new file mode 100644 index 0000000..44a4123 --- /dev/null +++ b/lyngio/src/linuxMain/kotlin/net/sergeych/lyngio/process/PosixProcessRunner.kt @@ -0,0 +1,152 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyngio.process + +import kotlinx.cinterop.* +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import platform.posix.* + +@OptIn(ExperimentalForeignApi::class) +internal class NativeLyngProcess( + private val pid: pid_t, + private val stdoutFd: Int, + private val stderrFd: Int +) : LyngProcess { + override val stdout: Flow = createPipeFlow(stdoutFd) + override val stderr: Flow = createPipeFlow(stderrFd) + + override suspend fun sendSignal(signal: ProcessSignal) { + val sig = when (signal) { + ProcessSignal.SIGINT -> SIGINT + ProcessSignal.SIGTERM -> SIGTERM + ProcessSignal.SIGKILL -> SIGKILL + } + if (kill(pid, sig) != 0) { + throw RuntimeException("Failed to send signal $signal to process $pid: ${strerror(errno)?.toKString()}") + } + } + + override suspend fun waitFor(): Int = withContext(Dispatchers.Default) { + memScoped { + val status = alloc() + if (waitpid(pid, status.ptr, 0) == -1) { + throw RuntimeException("Failed to wait for process $pid: ${strerror(errno)?.toKString()}") + } + val s = status.value + if ((s and 0x7f) == 0) (s shr 8) and 0xff else -1 + } + } + + override fun destroy() { + kill(pid, SIGKILL) + } +} + +@OptIn(ExperimentalForeignApi::class) +private fun createPipeFlow(fd: Int): Flow = flow { + val buffer = ByteArray(4096) + val lineBuffer = StringBuilder() + + try { + while (true) { + val bytesRead = buffer.usePinned { pinned -> + read(fd, pinned.addressOf(0), buffer.size.toULong()) + } + + if (bytesRead <= 0L) break + + val text = buffer.decodeToString(endIndex = bytesRead.toInt()) + lineBuffer.append(text) + + var newlineIdx = lineBuffer.indexOf('\n') + while (newlineIdx != -1) { + val line = lineBuffer.substring(0, newlineIdx) + emit(line) + lineBuffer.deleteRange(0, newlineIdx + 1) + newlineIdx = lineBuffer.indexOf('\n') + } + } + if (lineBuffer.isNotEmpty()) { + emit(lineBuffer.toString()) + } + } finally { + close(fd) + } +}.flowOn(Dispatchers.Default) + +@OptIn(ExperimentalForeignApi::class) +object PosixProcessRunner : LyngProcessRunner { + override suspend fun execute(executable: String, args: List): LyngProcess = withContext(Dispatchers.Default) { + memScoped { + val pipeStdout = allocArray(2) + val pipeStderr = allocArray(2) + + if (pipe(pipeStdout) != 0) throw RuntimeException("Failed to create stdout pipe") + if (pipe(pipeStderr) != 0) { + close(pipeStdout[0]) + close(pipeStdout[1]) + throw RuntimeException("Failed to create stderr pipe") + } + + val pid = fork() + if (pid == -1) { + close(pipeStdout[0]) + close(pipeStdout[1]) + close(pipeStderr[0]) + close(pipeStderr[1]) + throw RuntimeException("Failed to fork: ${strerror(errno)?.toKString()}") + } + + if (pid == 0) { + // Child process + dup2(pipeStdout[1], 1) + dup2(pipeStderr[1], 2) + + close(pipeStdout[0]) + close(pipeStdout[1]) + close(pipeStderr[0]) + close(pipeStderr[1]) + + val argv = allocArray>(args.size + 2) + argv[0] = executable.cstr.ptr + for (i in args.indices) { + argv[i + 1] = args[i].cstr.ptr + } + argv[args.size + 1] = null + + execvp(executable, argv) + + // If we are here, exec failed + _exit(1) + } + + // Parent process + close(pipeStdout[1]) + close(pipeStderr[1]) + + NativeLyngProcess(pid, pipeStdout[0], pipeStderr[0]) + } + } + + override suspend fun shell(command: String): LyngProcess { + return execute("/bin/sh", listOf("-c", command)) + } +} diff --git a/lyngio/src/linuxTest/kotlin/net/sergeych/lyngio/process/LinuxProcessTest.kt b/lyngio/src/linuxTest/kotlin/net/sergeych/lyngio/process/LinuxProcessTest.kt new file mode 100644 index 0000000..b133ed6 --- /dev/null +++ b/lyngio/src/linuxTest/kotlin/net/sergeych/lyngio/process/LinuxProcessTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyngio.process + +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import net.sergeych.lyng.Compiler +import net.sergeych.lyng.Script +import net.sergeych.lyng.io.process.createProcessModule +import net.sergeych.lyngio.process.security.PermitAllProcessAccessPolicy +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class LinuxProcessTest { + + @Test + fun testExecuteEcho() = runBlocking { + val process = PosixProcessRunner.execute("echo", listOf("hello", "native")) + val stdout = process.stdout.toList() + val exitCode = process.waitFor() + + assertEquals(0, exitCode) + assertEquals(listOf("hello native"), stdout) + } + + @Test + fun testShellCommand() = runBlocking { + val process = PosixProcessRunner.shell("echo 'shell native' && printf 'line2'") + val stdout = process.stdout.toList() + val exitCode = process.waitFor() + + assertEquals(0, exitCode) + assertEquals(listOf("shell native", "line2"), stdout) + } + + @Test + fun testStderrCapture() = runBlocking { + val process = PosixProcessRunner.shell("echo 'to stdout'; echo 'to stderr' >&2") + val stdout = process.stdout.toList() + val stderr = process.stderr.toList() + process.waitFor() + + assertEquals(listOf("to stdout"), stdout) + assertEquals(listOf("to stderr"), stderr) + } + + @Test + fun testPlatformDetails() { + val details = getPlatformDetails() + assertEquals("LINUX", details.name) + assertTrue(details.kernelVersion != null) + assertTrue(details.kernelVersion!!.isNotEmpty()) + println("Linux Native Details: $details") + } + + @Test + fun testLyngModuleNative() = runBlocking { + val scope = Script.newScope() + createProcessModule(PermitAllProcessAccessPolicy, scope) + + val code = """ + import lyng.io.process + + var p = Process.execute("echo", ["hello", "lyng", "native"]) + var output = [] + for (line in p.stdout()) { + output.add(line) + } + p.waitFor() + println(output) + assertEquals("hello lyng native", output.joinToString(" ")) + output + """.trimIndent() + + val script = Compiler.compile(code) + val result = script.execute(scope) + assertTrue(result.inspect(scope).contains("hello lyng native")) + } +} diff --git a/lyngio/src/macosMain/kotlin/net/sergeych/lyngio/process/PlatformMacos.kt b/lyngio/src/macosMain/kotlin/net/sergeych/lyngio/process/PlatformMacos.kt new file mode 100644 index 0000000..bdb78e1 --- /dev/null +++ b/lyngio/src/macosMain/kotlin/net/sergeych/lyngio/process/PlatformMacos.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyngio.process + +import kotlinx.cinterop.* +import platform.posix.* + +@OptIn(ExperimentalForeignApi::class) +internal actual fun getNativeKernelVersion(): String? { + return memScoped { + val u = alloc() + if (uname(u.ptr) == 0) { + u.release.toKString() + } else null + } +} + +internal actual fun isNativeProcessSupported(): Boolean = true + +internal actual fun getNativeProcessRunner(): LyngProcessRunner = PosixProcessRunner diff --git a/lyngio/src/macosMain/kotlin/net/sergeych/lyngio/process/PosixProcessRunner.kt b/lyngio/src/macosMain/kotlin/net/sergeych/lyngio/process/PosixProcessRunner.kt new file mode 100644 index 0000000..44a4123 --- /dev/null +++ b/lyngio/src/macosMain/kotlin/net/sergeych/lyngio/process/PosixProcessRunner.kt @@ -0,0 +1,152 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyngio.process + +import kotlinx.cinterop.* +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import platform.posix.* + +@OptIn(ExperimentalForeignApi::class) +internal class NativeLyngProcess( + private val pid: pid_t, + private val stdoutFd: Int, + private val stderrFd: Int +) : LyngProcess { + override val stdout: Flow = createPipeFlow(stdoutFd) + override val stderr: Flow = createPipeFlow(stderrFd) + + override suspend fun sendSignal(signal: ProcessSignal) { + val sig = when (signal) { + ProcessSignal.SIGINT -> SIGINT + ProcessSignal.SIGTERM -> SIGTERM + ProcessSignal.SIGKILL -> SIGKILL + } + if (kill(pid, sig) != 0) { + throw RuntimeException("Failed to send signal $signal to process $pid: ${strerror(errno)?.toKString()}") + } + } + + override suspend fun waitFor(): Int = withContext(Dispatchers.Default) { + memScoped { + val status = alloc() + if (waitpid(pid, status.ptr, 0) == -1) { + throw RuntimeException("Failed to wait for process $pid: ${strerror(errno)?.toKString()}") + } + val s = status.value + if ((s and 0x7f) == 0) (s shr 8) and 0xff else -1 + } + } + + override fun destroy() { + kill(pid, SIGKILL) + } +} + +@OptIn(ExperimentalForeignApi::class) +private fun createPipeFlow(fd: Int): Flow = flow { + val buffer = ByteArray(4096) + val lineBuffer = StringBuilder() + + try { + while (true) { + val bytesRead = buffer.usePinned { pinned -> + read(fd, pinned.addressOf(0), buffer.size.toULong()) + } + + if (bytesRead <= 0L) break + + val text = buffer.decodeToString(endIndex = bytesRead.toInt()) + lineBuffer.append(text) + + var newlineIdx = lineBuffer.indexOf('\n') + while (newlineIdx != -1) { + val line = lineBuffer.substring(0, newlineIdx) + emit(line) + lineBuffer.deleteRange(0, newlineIdx + 1) + newlineIdx = lineBuffer.indexOf('\n') + } + } + if (lineBuffer.isNotEmpty()) { + emit(lineBuffer.toString()) + } + } finally { + close(fd) + } +}.flowOn(Dispatchers.Default) + +@OptIn(ExperimentalForeignApi::class) +object PosixProcessRunner : LyngProcessRunner { + override suspend fun execute(executable: String, args: List): LyngProcess = withContext(Dispatchers.Default) { + memScoped { + val pipeStdout = allocArray(2) + val pipeStderr = allocArray(2) + + if (pipe(pipeStdout) != 0) throw RuntimeException("Failed to create stdout pipe") + if (pipe(pipeStderr) != 0) { + close(pipeStdout[0]) + close(pipeStdout[1]) + throw RuntimeException("Failed to create stderr pipe") + } + + val pid = fork() + if (pid == -1) { + close(pipeStdout[0]) + close(pipeStdout[1]) + close(pipeStderr[0]) + close(pipeStderr[1]) + throw RuntimeException("Failed to fork: ${strerror(errno)?.toKString()}") + } + + if (pid == 0) { + // Child process + dup2(pipeStdout[1], 1) + dup2(pipeStderr[1], 2) + + close(pipeStdout[0]) + close(pipeStdout[1]) + close(pipeStderr[0]) + close(pipeStderr[1]) + + val argv = allocArray>(args.size + 2) + argv[0] = executable.cstr.ptr + for (i in args.indices) { + argv[i + 1] = args[i].cstr.ptr + } + argv[args.size + 1] = null + + execvp(executable, argv) + + // If we are here, exec failed + _exit(1) + } + + // Parent process + close(pipeStdout[1]) + close(pipeStderr[1]) + + NativeLyngProcess(pid, pipeStdout[0], pipeStderr[0]) + } + } + + override suspend fun shell(command: String): LyngProcess { + return execute("/bin/sh", listOf("-c", command)) + } +} diff --git a/lyngio/src/mingwMain/kotlin/net/sergeych/lyngio/process/PlatformMingw.kt b/lyngio/src/mingwMain/kotlin/net/sergeych/lyngio/process/PlatformMingw.kt new file mode 100644 index 0000000..80a8853 --- /dev/null +++ b/lyngio/src/mingwMain/kotlin/net/sergeych/lyngio/process/PlatformMingw.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyngio.process + +internal actual fun getNativeKernelVersion(): String? = null + +internal actual fun isNativeProcessSupported(): Boolean = true + +internal actual fun getNativeProcessRunner(): LyngProcessRunner = WindowsProcessRunner + +object WindowsProcessRunner : LyngProcessRunner { + override suspend fun execute(executable: String, args: List): LyngProcess { + throw UnsupportedOperationException("Windows native process execution not implemented yet") + } + + override suspend fun shell(command: String): LyngProcess { + return execute("cmd.exe", listOf("/c", command)) + } +} diff --git a/lyngio/src/nativeMain/kotlin/net/sergeych/lyngio/process/PlatformNative.kt b/lyngio/src/nativeMain/kotlin/net/sergeych/lyngio/process/PlatformNative.kt new file mode 100644 index 0000000..35e036d --- /dev/null +++ b/lyngio/src/nativeMain/kotlin/net/sergeych/lyngio/process/PlatformNative.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyngio.process + +import kotlin.experimental.ExperimentalNativeApi + +@OptIn(ExperimentalNativeApi::class) +actual fun getPlatformDetails(): PlatformDetails { + return PlatformDetails( + name = Platform.osFamily.name, + version = "unknown", + arch = Platform.cpuArchitecture.name, + kernelVersion = getNativeKernelVersion() + ) +} + +internal expect fun getNativeKernelVersion(): String? + +actual fun isProcessSupported(): Boolean = isNativeProcessSupported() + +internal expect fun isNativeProcessSupported(): Boolean + +actual fun getSystemProcessRunner(): LyngProcessRunner = getNativeProcessRunner() + +internal expect fun getNativeProcessRunner(): LyngProcessRunner