Add verified Ktor-backed networking modules to lyngio

This commit is contained in:
sergeych committed 2026-04-02 16:19:14 +03:00
1 parent f168e9f6ed
commit d409a4bb8b
35 files changed
+4025 -23

No files matched your search

@@ -0,0 +1,3 @@
package net.sergeych.lyngio.net
actual fun getSystemNetEngine(): LyngNetEngine = UnsupportedLyngNetEngine
@@ -0,0 +1,3 @@
package net.sergeych.lyngio.ws
actual fun getSystemWsEngine(): LyngWsEngine = UnsupportedLyngWsEngine
@@ -0,0 +1,388 @@
/*
* 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.http
import net.sergeych.lyng.ModuleScope
import net.sergeych.lyng.Scope
import net.sergeych.lyng.ScopeFacade
import net.sergeych.lyng.Source
import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjBool
import net.sergeych.lyng.obj.ObjBuffer
import net.sergeych.lyng.obj.ObjClass
import net.sergeych.lyng.obj.ObjImmutableMap
import net.sergeych.lyng.obj.ObjInt
import net.sergeych.lyng.obj.ObjList
import net.sergeych.lyng.obj.ObjMap
import net.sergeych.lyng.obj.ObjMapEntry
import net.sergeych.lyng.obj.ObjNull
import net.sergeych.lyng.obj.ObjString
import net.sergeych.lyng.obj.ObjVoid
import net.sergeych.lyng.obj.requiredArg
import net.sergeych.lyng.obj.thisAs
import net.sergeych.lyng.pacman.ImportManager
import net.sergeych.lyng.raiseIllegalOperation
import net.sergeych.lyng.requireNoArgs
import net.sergeych.lyng.requireScope
import net.sergeych.lyngio.http.LyngHttpRequest
import net.sergeych.lyngio.http.LyngHttpResponse
import net.sergeych.lyngio.http.getSystemHttpEngine
import net.sergeych.lyngio.http.security.HttpAccessDeniedException
import net.sergeych.lyngio.http.security.HttpAccessOp
import net.sergeych.lyngio.http.security.HttpAccessPolicy
import net.sergeych.lyngio.stdlib_included.httpLyng
private const val HTTP_MODULE_NAME = "lyng.io.http"
fun createHttpModule(policy: HttpAccessPolicy, scope: Scope): Boolean =
createHttpModule(policy, scope.importManager)
fun createHttp(policy: HttpAccessPolicy, scope: Scope): Boolean = createHttpModule(policy, scope)
fun createHttpModule(policy: HttpAccessPolicy, manager: ImportManager): Boolean {
if (manager.packageNames.contains(HTTP_MODULE_NAME)) return false
manager.addPackage(HTTP_MODULE_NAME) { module ->
buildHttpModule(module, policy)
}
return true
}
fun createHttp(policy: HttpAccessPolicy, manager: ImportManager): Boolean = createHttpModule(policy, manager)
private suspend fun buildHttpModule(module: ModuleScope, policy: HttpAccessPolicy) {
module.eval(Source(HTTP_MODULE_NAME, httpLyng))
val engine = getSystemHttpEngine()
val headersType = ObjHttpHeaders.type
val requestType = ObjHttpRequest.type
val responseType = ObjHttpResponse.type
val httpType = object : ObjClass("Http") {}
httpType.addClassFn("isSupported") {
ObjBool(engine.isSupported)
}
httpType.addClassFn("request") {
httpGuard {
val req = requiredArg<ObjHttpRequest>(0)
val built = req.toRequest(this)
policy.require(HttpAccessOp.Request(built.method, built.url))
ObjHttpResponse.from(engine.request(built))
}
}
httpType.addClassFn("get") {
httpGuard {
val url = requiredArg<ObjString>(0).value
val headers = parseHeaderEntries(args.list.drop(1))
policy.require(HttpAccessOp.Request("GET", url))
ObjHttpResponse.from(engine.request(LyngHttpRequest(method = "GET", url = url, headers = headers)))
}
}
httpType.addClassFn("post") {
httpGuard {
val url = requiredArg<ObjString>(0).value
val bodyText = requiredArg<ObjString>(1).value
val contentType = args.list.getOrNull(2)?.let { objOrNullToString(this, it) }
val headers = parseHeaderEntries(args.list.drop(3)).toMutableMap()
if (contentType != null && "Content-Type" !in headers) headers["Content-Type"] = contentType
policy.require(HttpAccessOp.Request("POST", url))
ObjHttpResponse.from(
engine.request(
LyngHttpRequest(method = "POST", url = url, headers = headers, bodyText = bodyText)
)
)
}
}
httpType.addClassFn("postBytes") {
httpGuard {
val url = requiredArg<ObjString>(0).value
val body = requiredArg<ObjBuffer>(1).byteArray.toByteArray()
val contentType = args.list.getOrNull(2)?.let { objOrNullToString(this, it) }
val headers = parseHeaderEntries(args.list.drop(3)).toMutableMap()
if (contentType != null && "Content-Type" !in headers) headers["Content-Type"] = contentType
policy.require(HttpAccessOp.Request("POST", url))
ObjHttpResponse.from(
engine.request(
LyngHttpRequest(method = "POST", url = url, headers = headers, bodyBytes = body)
)
)
}
}
module.addConst("Http", httpType)
module.addConst("HttpHeaders", headersType)
module.addConst("HttpRequest", requestType)
module.addConst("HttpResponse", responseType)
}
private suspend inline fun ScopeFacade.httpGuard(crossinline block: suspend () -> Obj): Obj {
return try {
block()
} catch (e: HttpAccessDeniedException) {
raiseIllegalOperation(e.reasonDetail ?: "http access denied")
} catch (e: Exception) {
raiseIllegalOperation(e.message ?: "http error")
}
}
private class ObjHttpHeaders(
singleValueHeaders: Map<String, String> = emptyMap(),
private val allHeaders: Map<String, List<String>> = emptyMap(),
) : Obj() {
private val entries: LinkedHashMap<Obj, Obj> =
LinkedHashMap(singleValueHeaders.entries.associate { ObjString(it.key) to ObjString(it.value) })
override val objClass: ObjClass
get() = type
override suspend fun getAt(scope: Scope, index: Obj): Obj = findEntry(index)?.value ?: ObjNull
override suspend fun contains(scope: Scope, other: Obj): Boolean = findEntry(other) != null
override suspend fun defaultToString(scope: Scope): ObjString {
val rendered = buildString {
append("HttpHeaders(")
var first = true
for ((k, v) in entries) {
if (!first) append(", ")
append(k.toString(scope).value)
append(" => ")
append(v.toString(scope).value)
first = false
}
append(")")
}
return ObjString(rendered)
}
companion object {
val type = object : ObjClass("HttpHeaders", ObjMap.type) {
override suspend fun callOn(scope: Scope): Obj = ObjHttpHeaders()
}.apply {
addFn("get") {
val self = thisAs<ObjHttpHeaders>()
val name = requiredArg<ObjString>(0).value
self.firstValue(name)?.let(::ObjString) ?: ObjNull
}
addFn("getAll") {
val self = thisAs<ObjHttpHeaders>()
val name = requiredArg<ObjString>(0).value
ObjList(self.valuesOf(name).map(::ObjString).toMutableList())
}
addFn("names") {
val self = thisAs<ObjHttpHeaders>()
ObjList(self.allHeaders.keys.map(::ObjString).toMutableList())
}
addFn("getOrNull") {
val self = thisAs<ObjHttpHeaders>()
val name = requiredArg<ObjString>(0).value
self.firstValue(name)?.let(::ObjString) ?: ObjNull
}
addProperty("size", getter = { ObjInt(thisAs<ObjHttpHeaders>().entries.size.toLong()) })
addProperty("keys", getter = { ObjList(thisAs<ObjHttpHeaders>().entries.keys.toMutableList()) })
addProperty("values", getter = { ObjList(thisAs<ObjHttpHeaders>().entries.values.toMutableList()) })
addFn("iterator") {
ObjList(
thisAs<ObjHttpHeaders>().entries.map { (k, v) -> ObjMapEntry(k, v) }.toMutableList()
).invokeInstanceMethod(requireScope(), "iterator")
}
}
}
private fun valuesOf(name: String): List<String> = allHeaders[lookupKey(name)] ?: emptyList()
private fun firstValue(name: String): String? = valuesOf(name).firstOrNull()
private fun lookupKey(name: String): String =
allHeaders.keys.firstOrNull { it.equals(name, ignoreCase = true) } ?: name
private fun findEntry(index: Obj): Map.Entry<Obj, Obj>? {
if (index is ObjString) {
return entries.entries.firstOrNull { (k, _) ->
(k as? ObjString)?.value?.equals(index.value, ignoreCase = true) == true
}
}
return entries.entries.firstOrNull { it.key == index }
}
}
private class ObjHttpRequest(
var method: String = "GET",
var url: String = "",
val headers: MutableMap<String, String> = linkedMapOf(),
var bodyText: String? = null,
var bodyBytes: ByteArray? = null,
var timeoutMillis: Long? = null,
) : Obj() {
override val objClass: ObjClass
get() = type
suspend fun toRequest(scope: ScopeFacade): LyngHttpRequest {
if (bodyText != null && bodyBytes != null) {
scope.raiseIllegalArgument("Only one of bodyText or bodyBytes may be set")
}
return LyngHttpRequest(
method = method,
url = url,
headers = LinkedHashMap(headers),
bodyText = bodyText,
bodyBytes = bodyBytes,
timeoutMillis = timeoutMillis,
)
}
companion object {
val type = object : ObjClass("HttpRequest") {
override suspend fun callOn(scope: Scope): Obj {
if (scope.args.list.isNotEmpty()) scope.raiseError("HttpRequest() does not accept arguments")
return ObjHttpRequest()
}
}.apply {
addProperty("method",
getter = { ObjString(thisAs<ObjHttpRequest>().method) },
setter = { value ->
thisAs<ObjHttpRequest>().method = objOrNullToString(this, value)
?: raiseIllegalArgument("method cannot be null")
}
)
addProperty("url",
getter = { ObjString(thisAs<ObjHttpRequest>().url) },
setter = { value ->
thisAs<ObjHttpRequest>().url = objOrNullToString(this, value)
?: raiseIllegalArgument("url cannot be null")
}
)
addProperty("headers",
getter = { thisAs<ObjHttpRequest>().headers.toObjMap() },
setter = { value ->
thisAs<ObjHttpRequest>().headers.clear()
thisAs<ObjHttpRequest>().headers.putAll(mapObjToStrings(this, value))
}
)
addProperty("bodyText",
getter = { thisAs<ObjHttpRequest>().bodyText?.let(::ObjString) ?: ObjNull },
setter = { value ->
thisAs<ObjHttpRequest>().bodyText = objOrNullToString(this, value)
}
)
addProperty("bodyBytes",
getter = { thisAs<ObjHttpRequest>().bodyBytes?.let { ObjBuffer(it.toUByteArray()) } ?: ObjNull },
setter = { value ->
thisAs<ObjHttpRequest>().bodyBytes = when (value) {
ObjNull -> null
is ObjBuffer -> value.byteArray.toByteArray()
else -> raiseClassCastError("bodyBytes must be Buffer or null")
}
}
)
addProperty("timeoutMillis",
getter = { thisAs<ObjHttpRequest>().timeoutMillis?.let { ObjInt(it) } ?: ObjNull },
setter = { value ->
thisAs<ObjHttpRequest>().timeoutMillis = when (value) {
ObjNull -> null
is ObjInt -> value.value
else -> raiseClassCastError("timeoutMillis must be Int or null")
}
}
)
}
}
}
private class ObjHttpResponse(
val status: Long,
val statusText: String,
val headers: ObjHttpHeaders,
private val bodyBytes: ByteArray,
) : Obj() {
override val objClass: ObjClass
get() = type
companion object {
val type = object : ObjClass("HttpResponse") {
override suspend fun callOn(scope: Scope): Obj {
scope.raiseError("HttpResponse cannot be created directly")
}
}.apply {
addProperty("status", getter = { ObjInt(thisAs<ObjHttpResponse>().status) })
addProperty("statusText", getter = { ObjString(thisAs<ObjHttpResponse>().statusText) })
addProperty("headers", getter = { thisAs<ObjHttpResponse>().headers })
addFn("text") {
ObjString(thisAs<ObjHttpResponse>().bodyBytes.decodeToString())
}
addFn("bytes") {
ObjBuffer(thisAs<ObjHttpResponse>().bodyBytes.toUByteArray())
}
}
fun from(response: LyngHttpResponse): ObjHttpResponse {
val single = linkedMapOf<String, String>()
response.headers.forEach { (name, values) ->
if (values.isNotEmpty()) single.putIfAbsent(name, values.first())
}
return ObjHttpResponse(
status = response.status.toLong(),
statusText = response.statusText,
headers = ObjHttpHeaders(singleValueHeaders = single, allHeaders = response.headers),
bodyBytes = response.bodyBytes,
)
}
}
}
private suspend fun ScopeFacade.parseHeaderEntries(values: List<Obj>): Map<String, String> {
val out = linkedMapOf<String, String>()
values.forEach { value ->
when (value) {
is ObjMapEntry -> {
out[toStringOf(value.key).value] = toStringOf(value.value).value
}
else -> {
if (!value.isInstanceOf(net.sergeych.lyng.obj.ObjArray)) {
raiseIllegalArgument("headers entries must be MapEntry or [key, value]")
}
val size = (value.invokeInstanceMethod(requireScope(), "size") as ObjInt).value.toInt()
if (size != 2) {
raiseIllegalArgument("header entry array must contain exactly 2 items")
}
out[toStringOf(value.getAt(requireScope(), ObjInt.Zero)).value] =
toStringOf(value.getAt(requireScope(), ObjInt.One)).value
}
}
}
return out
}
private suspend fun mapObjToStrings(scope: ScopeFacade, value: Obj): MutableMap<String, String> {
val entries = when (value) {
is ObjMap -> value.map
is ObjImmutableMap -> value.map
ObjNull -> return linkedMapOf()
else -> scope.raiseClassCastError("headers must be Map<String, String>")
}
return entries.entries.associateTo(linkedMapOf()) { (k, v) ->
scope.toStringOf(k).value to scope.toStringOf(v).value
}
}
private suspend fun objOrNullToString(scope: ScopeFacade, value: Obj): String? = when (value) {
ObjNull -> null
else -> scope.toStringOf(value).value
}
private fun Map<String, String>.toObjMap(): ObjMap =
ObjMap(entries.associate { ObjString(it.key) to ObjString(it.value) }.toMutableMap())
@@ -0,0 +1,376 @@
/*
* 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.net
import net.sergeych.lyng.ModuleScope
import net.sergeych.lyng.Scope
import net.sergeych.lyng.ScopeFacade
import net.sergeych.lyng.Source
import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjBool
import net.sergeych.lyng.obj.ObjBuffer
import net.sergeych.lyng.obj.ObjClass
import net.sergeych.lyng.obj.ObjInt
import net.sergeych.lyng.obj.ObjList
import net.sergeych.lyng.obj.ObjNull
import net.sergeych.lyng.obj.ObjString
import net.sergeych.lyng.obj.ObjVoid
import net.sergeych.lyng.obj.requiredArg
import net.sergeych.lyng.obj.thisAs
import net.sergeych.lyng.pacman.ImportManager
import net.sergeych.lyng.raiseIllegalOperation
import net.sergeych.lyng.requireNoArgs
import net.sergeych.lyngio.net.LyngDatagram
import net.sergeych.lyngio.net.LyngIpVersion
import net.sergeych.lyngio.net.LyngNetEngine
import net.sergeych.lyngio.net.LyngSocketAddress
import net.sergeych.lyngio.net.LyngTcpServer
import net.sergeych.lyngio.net.LyngTcpSocket
import net.sergeych.lyngio.net.LyngUdpSocket
import net.sergeych.lyngio.net.getSystemNetEngine
import net.sergeych.lyngio.net.security.NetAccessDeniedException
import net.sergeych.lyngio.net.security.NetAccessOp
import net.sergeych.lyngio.net.security.NetAccessPolicy
import net.sergeych.lyngio.stdlib_included.netLyng
private const val NET_MODULE_NAME = "lyng.io.net"
fun createNetModule(policy: NetAccessPolicy, scope: Scope): Boolean =
createNetModule(policy, scope.importManager)
fun createNet(policy: NetAccessPolicy, scope: Scope): Boolean = createNetModule(policy, scope)
fun createNetModule(policy: NetAccessPolicy, manager: ImportManager): Boolean {
if (manager.packageNames.contains(NET_MODULE_NAME)) return false
manager.addPackage(NET_MODULE_NAME) { module ->
buildNetModule(module, policy)
}
return true
}
fun createNet(policy: NetAccessPolicy, manager: ImportManager): Boolean = createNetModule(policy, manager)
private suspend fun buildNetModule(module: ModuleScope, policy: NetAccessPolicy) {
module.eval(Source(NET_MODULE_NAME, netLyng))
val engine = getSystemNetEngine()
val enumValues = NetEnumValues.load(module)
val netType = object : ObjClass("Net") {}
netType.addClassFn("isSupported") { ObjBool(engine.isSupported) }
netType.addClassFn("isTcpAvailable") { ObjBool(engine.isTcpAvailable) }
netType.addClassFn("isTcpServerAvailable") { ObjBool(engine.isTcpServerAvailable) }
netType.addClassFn("isUdpAvailable") { ObjBool(engine.isUdpAvailable) }
netType.addClassFn("resolve") {
netGuard {
val host = requiredArg<ObjString>(0).value
val port = requirePort(requiredArg<ObjInt>(1).value)
policy.require(NetAccessOp.Resolve(host, port))
ObjList(engine.resolve(host, port).map { ObjSocketAddress(it, enumValues) }.toMutableList())
}
}
netType.addClassFn("tcpConnect") {
netGuard {
val host = requiredArg<ObjString>(0).value
val port = requirePort(requiredArg<ObjInt>(1).value)
val timeoutMillis = args.list.getOrNull(2)?.let { objOrNullToLong(this, it, "timeoutMillis") }
val noDelay = args.list.getOrNull(3)?.let { objToBool(this, it, "noDelay") } ?: true
policy.require(NetAccessOp.TcpConnect(host, port))
ObjTcpSocket(engine.tcpConnect(host, port, timeoutMillis, noDelay), enumValues)
}
}
netType.addClassFn("tcpListen") {
netGuard {
val port = requirePort(requiredArg<ObjInt>(0).value)
val host = args.list.getOrNull(1)?.let { objOrNullToString(this, it, "host") }
val backlog = args.list.getOrNull(2)?.let { objToInt(this, it, "backlog") } ?: 128
requirePositive(backlog, "backlog")
val reuseAddress = args.list.getOrNull(3)?.let { objToBool(this, it, "reuseAddress") } ?: true
policy.require(NetAccessOp.TcpListen(host, port, backlog))
ObjTcpServer(engine.tcpListen(host, port, backlog, reuseAddress), enumValues)
}
}
netType.addClassFn("udpBind") {
netGuard {
val port = args.list.getOrNull(0)?.let { objToInt(this, it, "port") } ?: 0
requirePort(port)
val host = args.list.getOrNull(1)?.let { objOrNullToString(this, it, "host") }
val reuseAddress = args.list.getOrNull(2)?.let { objToBool(this, it, "reuseAddress") } ?: true
policy.require(NetAccessOp.UdpBind(host, port))
ObjUdpSocket(engine.udpBind(host, port, reuseAddress), enumValues)
}
}
module.addConst("Net", netType)
module.addConst("SocketAddress", ObjSocketAddress.type(enumValues))
module.addConst("Datagram", ObjDatagram.type(enumValues))
module.addConst("TcpSocket", ObjTcpSocket.type(enumValues))
module.addConst("TcpServer", ObjTcpServer.type(enumValues))
module.addConst("UdpSocket", ObjUdpSocket.type(enumValues))
}
private suspend inline fun ScopeFacade.netGuard(crossinline block: suspend () -> Obj): Obj {
return try {
block()
} catch (e: NetAccessDeniedException) {
raiseIllegalOperation(e.reasonDetail ?: "network access denied")
} catch (e: Exception) {
raiseIllegalOperation(e.message ?: "network error")
}
}
private class NetEnumValues(
val ipv4: Obj,
val ipv6: Obj,
) {
fun of(version: LyngIpVersion): Obj = when (version) {
LyngIpVersion.IPV4 -> ipv4
LyngIpVersion.IPV6 -> ipv6
}
companion object {
suspend fun load(module: ModuleScope): NetEnumValues {
val ipVersionClass = module["IpVersion"]?.value as? ObjClass
?: error("lyng.io.net.IpVersion is missing after declaration load")
return NetEnumValues(
ipv4 = ipVersionClass.readField(module, "IPV4").value,
ipv6 = ipVersionClass.readField(module, "IPV6").value,
)
}
}
}
private class ObjSocketAddress(
private val address: LyngSocketAddress,
private val enumValues: NetEnumValues,
) : Obj() {
override val objClass: ObjClass
get() = type(enumValues)
override suspend fun defaultToString(scope: Scope): ObjString = ObjString(renderAddress(address))
companion object {
private val types = mutableMapOf<NetEnumValues, ObjClass>()
fun type(enumValues: NetEnumValues): ObjClass =
types.getOrPut(enumValues) {
object : ObjClass("SocketAddress") {
override suspend fun callOn(scope: Scope): Obj {
scope.raiseError("SocketAddress cannot be created directly")
}
}.apply {
addProperty("host", getter = { ObjString(thisAs<ObjSocketAddress>().address.host) })
addProperty("port", getter = { ObjInt(thisAs<ObjSocketAddress>().address.port.toLong()) })
addProperty("ipVersion", getter = { enumValues.of(thisAs<ObjSocketAddress>().address.ipVersion) })
addProperty("resolved", getter = { ObjBool(thisAs<ObjSocketAddress>().address.resolved) })
addFn("toString") { ObjString(renderAddress(thisAs<ObjSocketAddress>().address)) }
}
}
}
}
private class ObjDatagram(
private val datagram: LyngDatagram,
private val enumValues: NetEnumValues,
) : Obj() {
override val objClass: ObjClass
get() = type(enumValues)
companion object {
private val types = mutableMapOf<NetEnumValues, ObjClass>()
fun type(enumValues: NetEnumValues): ObjClass =
types.getOrPut(enumValues) {
object : ObjClass("Datagram") {
override suspend fun callOn(scope: Scope): Obj {
scope.raiseError("Datagram cannot be created directly")
}
}.apply {
addProperty("data", getter = {
ObjBuffer(thisAs<ObjDatagram>().datagram.data.toUByteArray())
})
addProperty("address", getter = {
ObjSocketAddress(thisAs<ObjDatagram>().datagram.address, enumValues)
})
}
}
}
}
private class ObjTcpSocket(
private val socket: LyngTcpSocket,
private val enumValues: NetEnumValues,
) : Obj() {
override val objClass: ObjClass
get() = type(enumValues)
companion object {
private val types = mutableMapOf<NetEnumValues, ObjClass>()
fun type(enumValues: NetEnumValues): ObjClass =
types.getOrPut(enumValues) {
object : ObjClass("TcpSocket") {
override suspend fun callOn(scope: Scope): Obj {
scope.raiseError("TcpSocket cannot be created directly")
}
}.apply {
addFn("isOpen") { ObjBool(thisAs<ObjTcpSocket>().socket.isOpen()) }
addFn("localAddress") { ObjSocketAddress(thisAs<ObjTcpSocket>().socket.localAddress(), enumValues) }
addFn("remoteAddress") { ObjSocketAddress(thisAs<ObjTcpSocket>().socket.remoteAddress(), enumValues) }
addFn("read") {
val maxBytes = args.list.getOrNull(0)?.let { objToInt(this, it, "maxBytes") } ?: 65536
requirePositive(maxBytes, "maxBytes")
thisAs<ObjTcpSocket>().socket.read(maxBytes)?.let { ObjBuffer(it.toUByteArray()) } ?: ObjNull
}
addFn("readLine") {
thisAs<ObjTcpSocket>().socket.readLine()?.let(::ObjString) ?: ObjNull
}
addFn("write") {
val data = requiredArg<ObjBuffer>(0).byteArray.toByteArray()
thisAs<ObjTcpSocket>().socket.write(data)
ObjVoid
}
addFn("writeUtf8") {
val text = requiredArg<ObjString>(0).value
thisAs<ObjTcpSocket>().socket.writeUtf8(text)
ObjVoid
}
addFn("flush") {
requireNoArgs()
thisAs<ObjTcpSocket>().socket.flush()
ObjVoid
}
addFn("close") {
requireNoArgs()
thisAs<ObjTcpSocket>().socket.close()
ObjVoid
}
}
}
}
}
private class ObjTcpServer(
private val server: LyngTcpServer,
private val enumValues: NetEnumValues,
) : Obj() {
override val objClass: ObjClass
get() = type(enumValues)
companion object {
private val types = mutableMapOf<NetEnumValues, ObjClass>()
fun type(enumValues: NetEnumValues): ObjClass =
types.getOrPut(enumValues) {
object : ObjClass("TcpServer") {
override suspend fun callOn(scope: Scope): Obj {
scope.raiseError("TcpServer cannot be created directly")
}
}.apply {
addFn("isOpen") { ObjBool(thisAs<ObjTcpServer>().server.isOpen()) }
addFn("localAddress") { ObjSocketAddress(thisAs<ObjTcpServer>().server.localAddress(), enumValues) }
addFn("accept") {
ObjTcpSocket(thisAs<ObjTcpServer>().server.accept(), enumValues)
}
addFn("close") {
requireNoArgs()
thisAs<ObjTcpServer>().server.close()
ObjVoid
}
}
}
}
}
private class ObjUdpSocket(
private val socket: LyngUdpSocket,
private val enumValues: NetEnumValues,
) : Obj() {
override val objClass: ObjClass
get() = type(enumValues)
companion object {
private val types = mutableMapOf<NetEnumValues, ObjClass>()
fun type(enumValues: NetEnumValues): ObjClass =
types.getOrPut(enumValues) {
object : ObjClass("UdpSocket") {
override suspend fun callOn(scope: Scope): Obj {
scope.raiseError("UdpSocket cannot be created directly")
}
}.apply {
addFn("isOpen") { ObjBool(thisAs<ObjUdpSocket>().socket.isOpen()) }
addFn("localAddress") { ObjSocketAddress(thisAs<ObjUdpSocket>().socket.localAddress(), enumValues) }
addFn("receive") {
val maxBytes = args.list.getOrNull(0)?.let { objToInt(this, it, "maxBytes") } ?: 65536
requirePositive(maxBytes, "maxBytes")
thisAs<ObjUdpSocket>().socket.receive(maxBytes)?.let { ObjDatagram(it, enumValues) } ?: ObjNull
}
addFn("send") {
val data = requiredArg<ObjBuffer>(0).byteArray.toByteArray()
val host = requiredArg<ObjString>(1).value
val port = requirePort(requiredArg<ObjInt>(2).value)
thisAs<ObjUdpSocket>().socket.send(data, host, port)
ObjVoid
}
addFn("close") {
requireNoArgs()
thisAs<ObjUdpSocket>().socket.close()
ObjVoid
}
}
}
}
}
private fun renderAddress(address: LyngSocketAddress): String =
if (address.ipVersion == LyngIpVersion.IPV6) "[${address.host}]:${address.port}" else "${address.host}:${address.port}"
private fun ScopeFacade.requirePort(value: Long): Int {
if (value !in 0..65535) raiseIllegalArgument("port must be in 0..65535")
return value.toInt()
}
private fun ScopeFacade.requirePort(value: Int): Int {
if (value !in 0..65535) raiseIllegalArgument("port must be in 0..65535")
return value
}
private fun ScopeFacade.requirePositive(value: Int, name: String) {
if (value <= 0) raiseIllegalArgument("$name must be positive")
}
private suspend fun objOrNullToString(scope: ScopeFacade, value: Obj, name: String): String? = when (value) {
ObjNull -> null
else -> scope.toStringOf(value).value
}
private fun objToInt(scope: ScopeFacade, value: Obj, name: String): Int = when (value) {
is ObjInt -> value.value.toInt()
else -> scope.raiseClassCastError("$name must be Int")
}
private fun objToBool(scope: ScopeFacade, value: Obj, name: String): Boolean = when (value) {
is ObjBool -> value.value
else -> scope.raiseClassCastError("$name must be Bool")
}
private fun objOrNullToLong(scope: ScopeFacade, value: Obj, name: String): Long? = when (value) {
ObjNull -> null
is ObjInt -> value.value
else -> scope.raiseClassCastError("$name must be Int or null")
}
@@ -0,0 +1,199 @@
/*
* 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.ws
import net.sergeych.lyng.ModuleScope
import net.sergeych.lyng.Scope
import net.sergeych.lyng.ScopeFacade
import net.sergeych.lyng.Source
import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjBool
import net.sergeych.lyng.obj.ObjBuffer
import net.sergeych.lyng.obj.ObjClass
import net.sergeych.lyng.obj.ObjInt
import net.sergeych.lyng.obj.ObjMapEntry
import net.sergeych.lyng.obj.ObjNull
import net.sergeych.lyng.obj.ObjString
import net.sergeych.lyng.obj.ObjVoid
import net.sergeych.lyng.obj.requiredArg
import net.sergeych.lyng.obj.thisAs
import net.sergeych.lyng.pacman.ImportManager
import net.sergeych.lyng.raiseIllegalOperation
import net.sergeych.lyng.requireNoArgs
import net.sergeych.lyng.requireScope
import net.sergeych.lyngio.stdlib_included.wsLyng
import net.sergeych.lyngio.ws.LyngWsEngine
import net.sergeych.lyngio.ws.LyngWsMessage
import net.sergeych.lyngio.ws.LyngWsSession
import net.sergeych.lyngio.ws.getSystemWsEngine
import net.sergeych.lyngio.ws.security.WsAccessDeniedException
import net.sergeych.lyngio.ws.security.WsAccessOp
import net.sergeych.lyngio.ws.security.WsAccessPolicy
private const val WS_MODULE_NAME = "lyng.io.ws"
fun createWsModule(policy: WsAccessPolicy, scope: Scope): Boolean =
createWsModule(policy, scope.importManager)
fun createWs(policy: WsAccessPolicy, scope: Scope): Boolean = createWsModule(policy, scope)
fun createWsModule(policy: WsAccessPolicy, manager: ImportManager): Boolean {
if (manager.packageNames.contains(WS_MODULE_NAME)) return false
manager.addPackage(WS_MODULE_NAME) { module ->
buildWsModule(module, policy)
}
return true
}
fun createWs(policy: WsAccessPolicy, manager: ImportManager): Boolean = createWsModule(policy, manager)
private suspend fun buildWsModule(module: ModuleScope, policy: WsAccessPolicy) {
module.eval(Source(WS_MODULE_NAME, wsLyng))
val engine = getSystemWsEngine()
val wsType = object : ObjClass("Ws") {}
wsType.addClassFn("isSupported") { ObjBool(engine.isSupported) }
wsType.addClassFn("connect") {
wsGuard {
val url = requiredArg<ObjString>(0).value
val headers = parseHeaderEntries(args.list.drop(1))
policy.require(WsAccessOp.Connect(url))
ObjWsSession(url, engine.connect(url, headers), policy)
}
}
module.addConst("Ws", wsType)
module.addConst("WsMessage", ObjWsMessage.type)
module.addConst("WsSession", ObjWsSession.type)
}
private suspend inline fun ScopeFacade.wsGuard(crossinline block: suspend () -> Obj): Obj {
return try {
block()
} catch (e: WsAccessDeniedException) {
raiseIllegalOperation(e.reasonDetail ?: "websocket access denied")
} catch (e: Exception) {
raiseIllegalOperation(e.message ?: "websocket error")
}
}
private class ObjWsMessage(
private val message: LyngWsMessage,
) : Obj() {
override val objClass: ObjClass
get() = type
companion object {
val type = object : ObjClass("WsMessage") {
override suspend fun callOn(scope: Scope): Obj {
scope.raiseError("WsMessage cannot be created directly")
}
}.apply {
addProperty("isText", getter = { ObjBool(thisAs<ObjWsMessage>().message.isText) })
addProperty("text", getter = {
thisAs<ObjWsMessage>().message.text?.let(::ObjString) ?: ObjNull
})
addProperty("data", getter = {
thisAs<ObjWsMessage>().message.data?.let { ObjBuffer(it.toUByteArray()) } ?: ObjNull
})
}
}
}
private class ObjWsSession(
private val targetUrl: String,
private val session: LyngWsSession,
private val policy: WsAccessPolicy,
) : Obj() {
override val objClass: ObjClass
get() = type
companion object {
val type = object : ObjClass("WsSession") {
override suspend fun callOn(scope: Scope): Obj {
scope.raiseError("WsSession cannot be created directly")
}
}.apply {
addFn("isOpen") {
ObjBool(thisAs<ObjWsSession>().session.isOpen())
}
addFn("url") {
ObjString(thisAs<ObjWsSession>().targetUrl)
}
addFn("sendText") {
val self = thisAs<ObjWsSession>()
val text = requiredArg<ObjString>(0).value
self.policy.require(WsAccessOp.Send(self.targetUrl, text.encodeToByteArray().size, isText = true))
self.session.sendText(text)
ObjVoid
}
addFn("sendBytes") {
val self = thisAs<ObjWsSession>()
val data = requiredArg<ObjBuffer>(0).byteArray.toByteArray()
self.policy.require(WsAccessOp.Send(self.targetUrl, data.size, isText = false))
self.session.sendBytes(data)
ObjVoid
}
addFn("receive") {
val self = thisAs<ObjWsSession>()
self.policy.require(WsAccessOp.Receive(self.targetUrl))
self.session.receive()?.let(::ObjWsMessage) ?: ObjNull
}
addFn("close") {
val self = thisAs<ObjWsSession>()
val code = args.list.getOrNull(0)?.let { objToInt(this, it, "code") } ?: 1000
val reason = args.list.getOrNull(1)?.let { objOrNullToString(this, it, "reason") } ?: ""
self.session.close(code, reason)
ObjVoid
}
}
}
}
private suspend fun ScopeFacade.parseHeaderEntries(values: List<Obj>): Map<String, String> {
val out = linkedMapOf<String, String>()
values.forEach { value ->
when (value) {
is ObjMapEntry -> {
out[toStringOf(value.key).value] = toStringOf(value.value).value
}
else -> {
if (!value.isInstanceOf(net.sergeych.lyng.obj.ObjArray)) {
raiseIllegalArgument("headers entries must be MapEntry or [key, value]")
}
val size = (value.invokeInstanceMethod(requireScope(), "size") as ObjInt).value.toInt()
if (size != 2) {
raiseIllegalArgument("header entry array must contain exactly 2 items")
}
out[toStringOf(value.getAt(requireScope(), ObjInt.Zero)).value] =
toStringOf(value.getAt(requireScope(), ObjInt.One)).value
}
}
}
return out
}
private suspend fun objOrNullToString(scope: ScopeFacade, value: Obj, name: String): String? = when (value) {
ObjNull -> null
else -> scope.toStringOf(value).value
}
private fun objToInt(scope: ScopeFacade, value: Obj, name: String): Int = when (value) {
is ObjInt -> value.value.toInt()
else -> scope.raiseClassCastError("$name must be Int")
}
@@ -0,0 +1,104 @@
/*
* 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.http
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.timeout
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.request
import io.ktor.client.request.setBody
import io.ktor.http.HttpMethod
import io.ktor.http.headers
import io.ktor.http.takeFrom
data class LyngHttpRequest(
val method: String,
val url: String,
val headers: Map<String, String> = emptyMap(),
val bodyText: String? = null,
val bodyBytes: ByteArray? = null,
val timeoutMillis: Long? = null,
)
data class LyngHttpResponse(
val status: Int,
val statusText: String,
val headers: Map<String, List<String>>,
val bodyBytes: ByteArray,
)
interface LyngHttpEngine {
val isSupported: Boolean
suspend fun request(request: LyngHttpRequest): LyngHttpResponse
}
private object UnsupportedHttpEngine : LyngHttpEngine {
override val isSupported: Boolean = false
override suspend fun request(request: LyngHttpRequest): LyngHttpResponse {
throw UnsupportedOperationException("HTTP client is not supported on this runtime")
}
}
private object KtorLyngHttpEngine : LyngHttpEngine {
private val clientResult by lazy {
runCatching {
HttpClient(CIO) {
expectSuccess = false
}
}
}
override val isSupported: Boolean
get() = clientResult.isSuccess
override suspend fun request(request: LyngHttpRequest): LyngHttpResponse {
val httpClient = clientResult.getOrElse {
throw UnsupportedOperationException(it.message ?: "HTTP client is not supported")
}
val response = httpClient.request {
applyRequest(request)
}
return LyngHttpResponse(
status = response.status.value,
statusText = response.status.description,
headers = response.headers.entries().associate { it.key to it.value.toList() },
bodyBytes = response.body<ByteArray>(),
)
}
private fun HttpRequestBuilder.applyRequest(request: LyngHttpRequest) {
method = HttpMethod.parse(request.method.uppercase())
url.takeFrom(request.url)
headers {
request.headers.forEach { (name, value) -> append(name, value) }
}
request.timeoutMillis?.let { timeout { requestTimeoutMillis = it } }
when {
request.bodyBytes != null && request.bodyText != null ->
throw IllegalArgumentException("Only one of bodyText or bodyBytes may be set")
request.bodyBytes != null -> setBody(request.bodyBytes)
request.bodyText != null -> setBody(request.bodyText)
}
}
}
fun getSystemHttpEngine(): LyngHttpEngine = KtorLyngHttpEngine
@@ -0,0 +1,45 @@
/*
* 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.http.security
import net.sergeych.lyngio.fs.security.AccessContext
import net.sergeych.lyngio.fs.security.AccessDecision
import net.sergeych.lyngio.fs.security.Decision
sealed interface HttpAccessOp {
data class Request(val method: String, val url: String) : HttpAccessOp
}
class HttpAccessDeniedException(
val op: HttpAccessOp,
val reasonDetail: String? = null,
) : IllegalStateException("HTTP access denied for $op" + (reasonDetail?.let { ": $it" } ?: ""))
interface HttpAccessPolicy {
suspend fun check(op: HttpAccessOp, ctx: AccessContext = AccessContext()): AccessDecision
suspend fun require(op: HttpAccessOp, ctx: AccessContext = AccessContext()) {
val res = check(op, ctx)
if (!res.isAllowed()) throw HttpAccessDeniedException(op, res.reason)
}
}
object PermitAllHttpAccessPolicy : HttpAccessPolicy {
override suspend fun check(op: HttpAccessOp, ctx: AccessContext): AccessDecision =
AccessDecision(Decision.Allow)
}
@@ -0,0 +1,99 @@
/*
* 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.net
enum class LyngIpVersion {
IPV4,
IPV6,
}
data class LyngSocketAddress(
val host: String,
val port: Int,
val ipVersion: LyngIpVersion,
val resolved: Boolean,
)
data class LyngDatagram(
val data: ByteArray,
val address: LyngSocketAddress,
)
interface LyngTcpSocket {
fun isOpen(): Boolean
fun localAddress(): LyngSocketAddress
fun remoteAddress(): LyngSocketAddress
suspend fun read(maxBytes: Int): ByteArray?
suspend fun readLine(): String?
suspend fun write(data: ByteArray)
suspend fun writeUtf8(text: String)
suspend fun flush()
fun close()
}
interface LyngTcpServer {
fun isOpen(): Boolean
fun localAddress(): LyngSocketAddress
suspend fun accept(): LyngTcpSocket
fun close()
}
interface LyngUdpSocket {
fun isOpen(): Boolean
fun localAddress(): LyngSocketAddress
suspend fun receive(maxBytes: Int): LyngDatagram?
suspend fun send(data: ByteArray, host: String, port: Int)
fun close()
}
interface LyngNetEngine {
val isSupported: Boolean
val isTcpAvailable: Boolean
val isTcpServerAvailable: Boolean
val isUdpAvailable: Boolean
suspend fun resolve(host: String, port: Int): List<LyngSocketAddress>
suspend fun tcpConnect(host: String, port: Int, timeoutMillis: Long?, noDelay: Boolean): LyngTcpSocket
suspend fun tcpListen(host: String?, port: Int, backlog: Int, reuseAddress: Boolean): LyngTcpServer
suspend fun udpBind(host: String?, port: Int, reuseAddress: Boolean): LyngUdpSocket
}
internal object UnsupportedLyngNetEngine : LyngNetEngine {
override val isSupported: Boolean = false
override val isTcpAvailable: Boolean = false
override val isTcpServerAvailable: Boolean = false
override val isUdpAvailable: Boolean = false
override suspend fun resolve(host: String, port: Int): List<LyngSocketAddress> {
throw UnsupportedOperationException("Raw networking is not supported on this runtime")
}
override suspend fun tcpConnect(host: String, port: Int, timeoutMillis: Long?, noDelay: Boolean): LyngTcpSocket {
throw UnsupportedOperationException("TCP client sockets are not supported on this runtime")
}
override suspend fun tcpListen(host: String?, port: Int, backlog: Int, reuseAddress: Boolean): LyngTcpServer {
throw UnsupportedOperationException("TCP server sockets are not supported on this runtime")
}
override suspend fun udpBind(host: String?, port: Int, reuseAddress: Boolean): LyngUdpSocket {
throw UnsupportedOperationException("UDP sockets are not supported on this runtime")
}
}
expect fun getSystemNetEngine(): LyngNetEngine
@@ -0,0 +1,48 @@
/*
* 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.net.security
import net.sergeych.lyngio.fs.security.AccessContext
import net.sergeych.lyngio.fs.security.AccessDecision
import net.sergeych.lyngio.fs.security.Decision
sealed interface NetAccessOp {
data class Resolve(val host: String, val port: Int) : NetAccessOp
data class TcpConnect(val host: String, val port: Int) : NetAccessOp
data class TcpListen(val host: String?, val port: Int, val backlog: Int) : NetAccessOp
data class UdpBind(val host: String?, val port: Int) : NetAccessOp
}
class NetAccessDeniedException(
val op: NetAccessOp,
val reasonDetail: String? = null,
) : IllegalStateException("Network access denied for $op" + (reasonDetail?.let { ": $it" } ?: ""))
interface NetAccessPolicy {
suspend fun check(op: NetAccessOp, ctx: AccessContext = AccessContext()): AccessDecision
suspend fun require(op: NetAccessOp, ctx: AccessContext = AccessContext()) {
val res = check(op, ctx)
if (!res.isAllowed()) throw NetAccessDeniedException(op, res.reason)
}
}
object PermitAllNetAccessPolicy : NetAccessPolicy {
override suspend fun check(op: NetAccessOp, ctx: AccessContext): AccessDecision =
AccessDecision(Decision.Allow)
}
@@ -0,0 +1,48 @@
/*
* 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.ws
data class LyngWsMessage(
val isText: Boolean,
val text: String? = null,
val data: ByteArray? = null,
)
interface LyngWsSession {
fun isOpen(): Boolean
fun url(): String
suspend fun sendText(text: String)
suspend fun sendBytes(data: ByteArray)
suspend fun receive(): LyngWsMessage?
suspend fun close(code: Int, reason: String)
}
interface LyngWsEngine {
val isSupported: Boolean
suspend fun connect(url: String, headers: Map<String, String>): LyngWsSession
}
internal object UnsupportedLyngWsEngine : LyngWsEngine {
override val isSupported: Boolean = false
override suspend fun connect(url: String, headers: Map<String, String>): LyngWsSession {
throw UnsupportedOperationException("WebSocket client is not supported on this runtime")
}
}
expect fun getSystemWsEngine(): LyngWsEngine
@@ -0,0 +1,47 @@
/*
* 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.ws.security
import net.sergeych.lyngio.fs.security.AccessContext
import net.sergeych.lyngio.fs.security.AccessDecision
import net.sergeych.lyngio.fs.security.Decision
sealed interface WsAccessOp {
data class Connect(val url: String) : WsAccessOp
data class Send(val url: String, val bytes: Int, val isText: Boolean) : WsAccessOp
data class Receive(val url: String) : WsAccessOp
}
class WsAccessDeniedException(
val op: WsAccessOp,
val reasonDetail: String? = null,
) : IllegalStateException("WebSocket access denied for $op" + (reasonDetail?.let { ": $it" } ?: ""))
interface WsAccessPolicy {
suspend fun check(op: WsAccessOp, ctx: AccessContext = AccessContext()): AccessDecision
suspend fun require(op: WsAccessOp, ctx: AccessContext = AccessContext()) {
val res = check(op, ctx)
if (!res.isAllowed()) throw WsAccessDeniedException(op, res.reason)
}
}
object PermitAllWsAccessPolicy : WsAccessPolicy {
override suspend fun check(op: WsAccessOp, ctx: AccessContext): AccessDecision =
AccessDecision(Decision.Allow)
}
@@ -0,0 +1,3 @@
package net.sergeych.lyngio.net
actual fun getSystemNetEngine(): LyngNetEngine = UnsupportedLyngNetEngine
@@ -0,0 +1,3 @@
package net.sergeych.lyngio.ws
actual fun getSystemWsEngine(): LyngWsEngine = UnsupportedLyngWsEngine
@@ -0,0 +1,223 @@
/*
* 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.net
import io.ktor.network.selector.ActorSelectorManager
import io.ktor.network.selector.SelectorManager
import io.ktor.network.sockets.BoundDatagramSocket
import io.ktor.network.sockets.InetSocketAddress
import io.ktor.network.sockets.ServerSocket
import io.ktor.network.sockets.Socket
import io.ktor.network.sockets.aSocket
import io.ktor.network.sockets.isClosed
import io.ktor.network.sockets.openReadChannel
import io.ktor.network.sockets.openWriteChannel
import io.ktor.network.sockets.toJavaAddress
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.ByteWriteChannel
import io.ktor.utils.io.readAvailable
import io.ktor.utils.io.readUTF8Line
import io.ktor.utils.io.writeFully
import io.ktor.utils.io.writeStringUtf8
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.io.Buffer
import kotlinx.io.readByteArray
import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetAddress
actual fun getSystemNetEngine(): LyngNetEngine = JvmKtorNetEngine
private object JvmKtorNetEngine : LyngNetEngine {
private val selectorManager: SelectorManager by lazy { ActorSelectorManager(Dispatchers.IO) }
override val isSupported: Boolean = true
override val isTcpAvailable: Boolean = true
override val isTcpServerAvailable: Boolean = true
override val isUdpAvailable: Boolean = true
override suspend fun resolve(host: String, port: Int): List<LyngSocketAddress> = withContext(Dispatchers.IO) {
InetAddress.getAllByName(host).map { address ->
address.toLyngSocketAddress(port = port, resolved = true)
}
}
override suspend fun tcpConnect(
host: String,
port: Int,
timeoutMillis: Long?,
noDelay: Boolean,
): LyngTcpSocket {
val connectBlock: suspend () -> Socket = {
aSocket(selectorManager).tcp().connect(host, port) {
this.noDelay = noDelay
}
}
val socket = if (timeoutMillis != null) withTimeout(timeoutMillis) { connectBlock() } else connectBlock()
return JvmLyngTcpSocket(socket)
}
override suspend fun tcpListen(
host: String?,
port: Int,
backlog: Int,
reuseAddress: Boolean,
): LyngTcpServer {
val bindHost = host ?: "0.0.0.0"
val server = aSocket(selectorManager).tcp().bind(bindHost, port) {
backlogSize = backlog
this.reuseAddress = reuseAddress
}
return JvmLyngTcpServer(server)
}
override suspend fun udpBind(host: String?, port: Int, reuseAddress: Boolean): LyngUdpSocket {
val bindHost = host ?: "0.0.0.0"
val socket = aSocket(selectorManager).udp().bind(bindHost, port) {
this.reuseAddress = reuseAddress
}
return JvmLyngUdpSocket(socket)
}
}
private class JvmLyngTcpSocket(
private val socket: Socket,
) : LyngTcpSocket {
private val input: ByteReadChannel by lazy { socket.openReadChannel() }
private val output: ByteWriteChannel by lazy { socket.openWriteChannel(autoFlush = true) }
override fun isOpen(): Boolean = !socket.isClosed
override fun localAddress(): LyngSocketAddress = socket.localAddress.toLyngSocketAddress(resolved = true)
override fun remoteAddress(): LyngSocketAddress = socket.remoteAddress.toLyngSocketAddress(resolved = true)
override suspend fun read(maxBytes: Int): ByteArray? {
if (!input.awaitContent(1)) return null
val buffer = ByteArray(maxBytes)
val count = input.readAvailable(buffer, 0, maxBytes)
return when {
count <= 0 -> null
count == maxBytes -> buffer
else -> buffer.copyOf(count)
}
}
override suspend fun readLine(): String? = input.readUTF8Line()
override suspend fun write(data: ByteArray) {
output.writeFully(data, 0, data.size)
}
override suspend fun writeUtf8(text: String) {
output.writeStringUtf8(text)
}
override suspend fun flush() {
output.flush()
}
override fun close() {
socket.close()
}
}
private class JvmLyngTcpServer(
private val server: ServerSocket,
) : LyngTcpServer {
override fun isOpen(): Boolean = !server.isClosed
override fun localAddress(): LyngSocketAddress = server.localAddress.toLyngSocketAddress(resolved = true)
override suspend fun accept(): LyngTcpSocket = JvmLyngTcpSocket(server.accept())
override fun close() {
server.close()
}
}
private class JvmLyngUdpSocket(
private val socket: BoundDatagramSocket,
) : LyngUdpSocket {
override fun isOpen(): Boolean = !socket.isClosed
override fun localAddress(): LyngSocketAddress = socket.localAddress.toLyngSocketAddress(resolved = true)
override suspend fun receive(maxBytes: Int): LyngDatagram? {
val datagram = try {
socket.receive()
} catch (e: Throwable) {
if (!isOpen()) return null
throw e
}
val bytes = datagram.packet.readByteArray().let {
if (it.size <= maxBytes) it else it.copyOf(maxBytes)
}
return LyngDatagram(bytes, datagram.address.toLyngSocketAddress(resolved = true))
}
override suspend fun send(data: ByteArray, host: String, port: Int) {
val packet = Buffer()
packet.write(data)
socket.send(io.ktor.network.sockets.Datagram(packet, InetSocketAddress(host, port)))
}
override fun close() {
socket.close()
}
}
private fun io.ktor.network.sockets.SocketAddress.toLyngSocketAddress(
port: Int? = null,
resolved: Boolean,
): LyngSocketAddress {
val javaAddress = this.toJavaAddress()
val inetSocket = javaAddress as? java.net.InetSocketAddress
if (inetSocket != null) {
val inetAddress = inetSocket.address
val host = inetAddress?.hostAddress ?: inetSocket.hostString
val actualPort = port ?: inetSocket.port
val version = when (inetAddress) {
is Inet6Address -> LyngIpVersion.IPV6
is Inet4Address -> LyngIpVersion.IPV4
else -> if (host.contains(':')) LyngIpVersion.IPV6 else LyngIpVersion.IPV4
}
return LyngSocketAddress(host = host, port = actualPort, ipVersion = version, resolved = resolved)
}
val rendered = toString()
return LyngSocketAddress(
host = rendered,
port = port ?: 0,
ipVersion = if (rendered.contains(':')) LyngIpVersion.IPV6 else LyngIpVersion.IPV4,
resolved = resolved,
)
}
private fun InetAddress.toLyngSocketAddress(port: Int, resolved: Boolean): LyngSocketAddress =
LyngSocketAddress(
host = hostAddress,
port = port,
ipVersion = when (this) {
is Inet6Address -> LyngIpVersion.IPV6
else -> LyngIpVersion.IPV4
},
resolved = resolved,
)
@@ -0,0 +1,110 @@
/*
* 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.ws
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.plugins.websocket.webSocketSession
import io.ktor.client.request.header
import io.ktor.client.request.url
import io.ktor.websocket.CloseReason
import io.ktor.websocket.DefaultWebSocketSession
import io.ktor.websocket.Frame
import io.ktor.websocket.close
import io.ktor.websocket.readText
import io.ktor.websocket.send
import kotlinx.coroutines.channels.ClosedReceiveChannelException
actual fun getSystemWsEngine(): LyngWsEngine = JvmKtorWsEngine
private object JvmKtorWsEngine : LyngWsEngine {
private val clientResult by lazy {
runCatching {
HttpClient(CIO) {
install(WebSockets)
}
}
}
override val isSupported: Boolean
get() = clientResult.isSuccess
override suspend fun connect(url: String, headers: Map<String, String>): LyngWsSession {
val client = clientResult.getOrElse {
throw UnsupportedOperationException(it.message ?: "WebSocket client is not supported")
}
val session = client.webSocketSession {
url(url)
headers.forEach { (name, value) -> header(name, value) }
}
return JvmLyngWsSession(url, session)
}
}
private class JvmLyngWsSession(
private val targetUrl: String,
private val session: DefaultWebSocketSession,
) : LyngWsSession {
@Volatile
private var closed = false
override fun isOpen(): Boolean = !closed
override fun url(): String = targetUrl
override suspend fun sendText(text: String) {
ensureOpen()
session.send(text)
}
override suspend fun sendBytes(data: ByteArray) {
ensureOpen()
session.send(data)
}
override suspend fun receive(): LyngWsMessage? {
if (closed) return null
val frame = try {
session.incoming.receive()
} catch (_: ClosedReceiveChannelException) {
closed = true
return null
}
return when (frame) {
is Frame.Text -> LyngWsMessage(isText = true, text = frame.readText())
is Frame.Binary -> LyngWsMessage(isText = false, data = frame.data.copyOf())
is Frame.Close -> {
closed = true
null
}
else -> receive()
}
}
override suspend fun close(code: Int, reason: String) {
if (closed) return
closed = true
val safeCode = code.toShort()
session.close(CloseReason(safeCode, reason))
}
private fun ensureOpen() {
if (closed) throw IllegalStateException("websocket session is closed")
}
}
+256
View File
@@ -0,0 +1,256 @@
/*
* 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.
*
*/
import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpServer
import com.sun.net.httpserver.HttpsConfigurator
import com.sun.net.httpserver.HttpsServer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.runBlocking
import net.sergeych.lyng.Scope
import net.sergeych.lyng.Script
import net.sergeych.lyng.io.http.createHttpModule
import net.sergeych.lyng.io.net.createNetModule
import net.sergeych.lyng.io.testtls.TlsTestMaterial
import net.sergeych.lyng.io.ws.TestWebSocketServer
import net.sergeych.lyng.io.ws.createWsModule
import net.sergeych.lyng.leftMargin
import net.sergeych.lyng.obj.ObjInt
import net.sergeych.lyng.obj.ObjString
import net.sergeych.lyng.obj.ObjVoid
import net.sergeych.lyngio.http.security.PermitAllHttpAccessPolicy
import net.sergeych.lyngio.net.security.PermitAllNetAccessPolicy
import net.sergeych.lyngio.ws.security.PermitAllWsAccessPolicy
import java.net.InetSocketAddress
import java.net.ServerSocket
import java.nio.file.Files.readAllLines
import java.nio.file.Paths
import kotlin.concurrent.thread
import kotlin.io.path.absolutePathString
import kotlin.test.Test
import kotlin.test.assertEquals
data class IoDocTest(
val fileName: String,
val line: Int,
val code: String,
val expectedOutput: String,
val expectedResult: String,
) {
val fileNamePart by lazy { Paths.get(fileName).fileName.toString() }
override fun toString(): String = "DocTest: ${Paths.get(fileName).absolutePathString()}:${line + 1}"
}
private fun parseIoDocTests(fileName: String): Flow<IoDocTest> = flow {
val book = readAllLines(Paths.get(fileName))
var startOffset = 0
val block = mutableListOf<String>()
var startIndex = 0
for ((index, l) in book.withIndex()) {
val off = leftMargin(l)
when {
off < startOffset && startOffset != 0 -> {
if (l.isBlank()) continue
if (block.size > 1) {
for ((i, s) in block.withIndex()) {
var x = s
val initial = leftMargin(x)
do {
x = x.drop(1)
} while (initial - leftMargin(x) != startOffset)
block[i] = x
}
val outStart = block.indexOfFirst { it.startsWith(">>>") }
if (outStart >= 0) {
val result = mutableListOf<String>()
while (block.lastOrNull()?.isEmpty() == true) block.removeLast()
var valid = true
while (block.size > outStart) {
val line = block.removeAt(outStart)
if (!line.startsWith(">>> ")) {
valid = false
break
}
result.add(line.drop(4))
}
if (valid) {
emit(
IoDocTest(
fileName = fileName,
line = startIndex,
code = block.joinToString("\n"),
expectedOutput = if (result.size > 1) result.dropLast(1).joinToString("") { "$it\n" } else "",
expectedResult = result.last(),
)
)
}
}
}
block.clear()
startOffset = 0
}
off != 0 && startOffset == 0 -> {
block.clear()
startIndex = index
block.add(l)
startOffset = off
}
off != 0 -> block.add(l)
}
}
}.flowOn(Dispatchers.IO)
private suspend fun IoDocTest.run(scope: Scope) {
val output = StringBuilder()
scope.addFn("println") {
for ((i, a) in args.withIndex()) {
if (i > 0) output.append(' ')
output.append(toStringOf(a).value)
}
output.append('\n')
ObjVoid
}
val result = scope.eval(code).inspect(scope).replace(Regex("@\\d+"), "@...")
assertEquals(expectedOutput, output.toString(), "script output mismatch at $this")
assertEquals(expectedResult, result, "script result mismatch at $this")
}
private suspend fun runIoDocTests(
fileName: String,
installModules: suspend (Scope) -> Unit,
installConsts: suspend (Scope) -> Unit = {},
) {
parseIoDocTests(fileName).collect { test ->
val scope = Script.newScope()
installModules(scope)
installConsts(scope)
test.run(scope)
}
}
class LyngioBookTest {
@Test
fun testHttpDocs() = runBlocking {
val server = newHttpServer(secure = false)
val secureServer = newHttpServer(secure = true)
try {
runIoDocTests(
fileName = "../docs/lyng.io.http.md",
installModules = { scope -> createHttpModule(PermitAllHttpAccessPolicy, scope) },
installConsts = { scope ->
scope.addConst("HTTP_TEST_URL", ObjString("http://127.0.0.1:${server.address.port}"))
scope.addConst("HTTPS_TEST_URL", ObjString("https://127.0.0.1:${secureServer.address.port}"))
},
)
} finally {
server.stop(0)
secureServer.stop(0)
}
}
@Test
fun testNetDocs() = runBlocking {
ServerSocket(0, 50).use { server ->
val worker = thread(start = true) {
server.accept().use { client ->
val line = client.getInputStream().readNBytes(4).decodeToString()
client.getOutputStream().write(("reply:" + line).toByteArray())
client.getOutputStream().flush()
}
}
try {
runIoDocTests(
fileName = "../docs/lyng.io.net.md",
installModules = { scope -> createNetModule(PermitAllNetAccessPolicy, scope) },
installConsts = { scope -> scope.addConst("NET_TEST_TCP_PORT", ObjInt(server.localPort.toLong())) },
)
} finally {
worker.join(2000)
}
}
}
@Test
fun testWsDocs() = runBlocking {
TestWebSocketServer { connection ->
val text = connection.receiveText()
connection.sendText("echo:$text")
connection.close()
}.use { textServer ->
TestWebSocketServer { connection ->
val data = connection.receiveBinary()
connection.sendBinary(byteArrayOf(1, 2, 3) + data)
connection.close()
}.use { binaryServer ->
TestWebSocketServer(secure = true) { connection ->
val text = connection.receiveText()
connection.sendText("secure:$text")
connection.close()
}.use { secureServer ->
runIoDocTests(
fileName = "../docs/lyng.io.ws.md",
installModules = { scope -> createWsModule(PermitAllWsAccessPolicy, scope) },
installConsts = { scope ->
scope.addConst("WS_TEST_URL", ObjString(textServer.url))
scope.addConst("WS_TEST_BINARY_URL", ObjString(binaryServer.url))
scope.addConst("WSS_TEST_URL", ObjString(secureServer.url))
},
)
}
}
}
}
private fun writeResponse(exchange: HttpExchange, status: Int, body: String) {
val bytes = body.toByteArray()
exchange.sendResponseHeaders(status, bytes.size.toLong())
exchange.responseBody.use { it.write(bytes) }
}
private fun newHttpServer(secure: Boolean): HttpServer {
if (secure) TlsTestMaterial.installJvmClientTrust()
val server = if (secure) {
HttpsServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
httpsConfigurator = HttpsConfigurator(TlsTestMaterial.sslContext)
}
} else {
HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
}
server.createContext("/hello") { exchange ->
exchange.responseHeaders.add("Content-Type", "text/plain; charset=utf-8")
writeResponse(exchange, 200, "hello from test")
}
server.createContext("/headers") { exchange ->
exchange.responseHeaders.add("X-Reply", "one")
exchange.responseHeaders.add("X-Reply", "two")
writeResponse(exchange, 200, "header demo")
}
server.createContext("/echo") { exchange ->
val body = exchange.requestBody.readBytes().decodeToString()
writeResponse(exchange, 200, exchange.requestMethod + ":" + body)
}
server.start()
return server
}
}
@@ -0,0 +1,140 @@
/*
* 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.http
import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpServer
import kotlinx.coroutines.runBlocking
import net.sergeych.lyng.Compiler
import net.sergeych.lyng.ExecutionError
import net.sergeych.lyng.Script
import net.sergeych.lyngio.fs.security.AccessContext
import net.sergeych.lyngio.fs.security.AccessDecision
import net.sergeych.lyngio.fs.security.Decision
import net.sergeych.lyngio.http.security.HttpAccessOp
import net.sergeych.lyngio.http.security.HttpAccessPolicy
import net.sergeych.lyngio.http.security.PermitAllHttpAccessPolicy
import java.net.InetSocketAddress
import kotlin.test.Test
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class LyngHttpModuleTest {
@Test
fun testConvenienceGetAndHeaders() = runBlocking {
val server = newServer { exchange ->
exchange.responseHeaders.add("Content-Type", "text/plain; charset=utf-8")
exchange.responseHeaders.add("X-Reply", "one")
exchange.responseHeaders.add("X-Reply", "two")
writeResponse(exchange, 200, "hello from test")
}
try {
val scope = Script.newScope()
createHttpModule(PermitAllHttpAccessPolicy, scope)
val code = """
import lyng.io.http
val r = Http.get(
"http://127.0.0.1:${server.address.port}/hello",
"Accept" => "text/plain",
"X-Test" => "yes"
)
[r.status, r.headers["Content-Type"], r.headers.getAll("X-Reply").size, r.text()]
""".trimIndent()
val result = Compiler.compile(code).execute(scope)
val rendered = result.inspect(scope)
assertTrue(rendered.contains("200"), rendered)
assertTrue(rendered.contains("text/plain"), rendered)
assertTrue(rendered.contains("2"), rendered)
assertTrue(rendered.contains("hello from test"), rendered)
} finally {
server.stop(0)
}
}
@Test
fun testMutableRequestPost() = runBlocking {
val server = newServer { exchange ->
val body = exchange.requestBody.readBytes().decodeToString()
exchange.responseHeaders.add("Content-Type", "text/plain")
writeResponse(exchange, 200, exchange.requestMethod + ":" + body)
}
try {
val scope = Script.newScope()
createHttpModule(PermitAllHttpAccessPolicy, scope)
val code = """
import lyng.io.http
val q = HttpRequest()
q.method = "POST"
q.url = "http://127.0.0.1:${server.address.port}/echo"
q.headers = Map("Content-Type" => "text/plain")
q.bodyText = "ping"
val r = Http.request(q)
r.text()
""".trimIndent()
val result = Compiler.compile(code).execute(scope)
assertTrue(result.inspect(scope).contains("POST:ping"))
} finally {
server.stop(0)
}
}
@Test
fun testPolicyDenialSurfacesAsLyngError() = runBlocking {
val scope = Script.newScope()
val denyAll = object : HttpAccessPolicy {
override suspend fun check(op: HttpAccessOp, ctx: AccessContext): AccessDecision =
AccessDecision(Decision.Deny, "blocked by test policy")
}
createHttpModule(denyAll, scope)
val code = """
import lyng.io.http
Http.get("http://127.0.0.1:1/")
""".trimIndent()
val error = assertFailsWith<ExecutionError> {
Compiler.compile(code).execute(scope)
}
assertTrue(error.errorMessage.isNotBlank())
}
private fun newServer(handler: (HttpExchange) -> Unit): HttpServer {
val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
server.createContext("/") { exchange ->
handler(exchange)
}
server.start()
return server
}
private fun writeResponse(exchange: HttpExchange, status: Int, body: String) {
val bytes = body.toByteArray()
exchange.sendResponseHeaders(status, bytes.size.toLong())
exchange.responseBody.use { out ->
out.write(bytes)
}
}
}
@@ -0,0 +1,168 @@
/*
* 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.net
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import net.sergeych.lyng.Compiler
import net.sergeych.lyng.ExecutionError
import net.sergeych.lyng.Script
import net.sergeych.lyngio.fs.security.AccessContext
import net.sergeych.lyngio.fs.security.AccessDecision
import net.sergeych.lyngio.fs.security.Decision
import net.sergeych.lyngio.net.security.NetAccessOp
import net.sergeych.lyngio.net.security.NetAccessPolicy
import net.sergeych.lyngio.net.security.PermitAllNetAccessPolicy
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.ServerSocket
import java.net.Socket
import kotlin.concurrent.thread
import kotlin.test.Test
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class LyngNetModuleTest {
@Test
fun testResolveAndCapabilities() = runBlocking {
val scope = Script.newScope()
createNetModule(PermitAllNetAccessPolicy, scope)
val code = """
import lyng.io.net
val a: SocketAddress = Net.resolve("127.0.0.1", 4040)[0]
[Net.isSupported(), Net.isTcpAvailable(), Net.isTcpServerAvailable(), Net.isUdpAvailable(), a.toString(), a.resolved, a.ipVersion == IpVersion.IPV4]
""".trimIndent()
val result = Compiler.compile(code).execute(scope).inspect(scope)
assertTrue(result.contains("true,true,true,true"), result)
assertTrue(result.contains("127.0.0.1:4040"), result)
}
@Test
fun testTcpConnectConvenience() = runBlocking {
ServerSocket(0, 50).use { server ->
val worker = thread(start = true) {
server.accept().use { client ->
val line = client.getInputStream().readNBytes(4).decodeToString()
client.getOutputStream().write(("reply:" + line).toByteArray())
client.getOutputStream().flush()
}
}
val scope = Script.newScope()
createNetModule(PermitAllNetAccessPolicy, scope)
val code = """
import lyng.buffer
import lyng.io.net
val socket = Net.tcpConnect("127.0.0.1", ${server.localPort})
socket.writeUtf8("ping")
socket.flush()
val reply = (socket.read(16) as Buffer).decodeUtf8()
val localPort = socket.localAddress().port
val remotePort = socket.remoteAddress().port
socket.close()
[reply, localPort > 0, remotePort == ${server.localPort}]
""".trimIndent()
val result = Compiler.compile(code).execute(scope).inspect(scope)
worker.join(2000)
assertTrue(result.contains("reply:ping"), result)
assertTrue(result.contains("true,true"), result)
}
}
@Test
fun testTcpListenAndAcceptInLyng() = runBlocking {
val scope = Script.newScope()
createNetModule(PermitAllNetAccessPolicy, scope)
val code = """
import lyng.buffer
import lyng.io.net
val server = Net.tcpListen(0, "127.0.0.1")
val port = server.localAddress().port
val accepted = launch {
val client = server.accept()
val line = (client.read(4) as Buffer).decodeUtf8()
client.writeUtf8("echo:" + line)
client.flush()
client.close()
server.close()
line
}
val socket = Net.tcpConnect("127.0.0.1", port)
socket.writeUtf8("ping")
socket.flush()
val reply = (socket.read(16) as Buffer).decodeUtf8()
socket.close()
[accepted.await(), reply]
""".trimIndent()
val result = Compiler.compile(code).execute(scope).inspect(scope)
assertTrue(result.contains("[ping,echo:ping]"), result)
}
@Test
fun testUdpLoopback() = runBlocking {
val scope = Script.newScope()
createNetModule(PermitAllNetAccessPolicy, scope)
val code = """
import lyng.buffer
import lyng.io.net
val server = Net.udpBind(0, "127.0.0.1")
val client = Net.udpBind(0, "127.0.0.1")
client.send(Buffer("ping"), "127.0.0.1", server.localAddress().port)
val d = server.receive()
client.close()
server.close()
[d.data.decodeUtf8(), d.address.port > 0]
""".trimIndent()
val result = Compiler.compile(code).execute(scope).inspect(scope)
assertTrue(result.contains("[ping,true]"), result)
}
@Test
fun testPolicyDenialSurfacesAsLyngError() = runBlocking {
val scope = Script.newScope()
val denyAll = object : NetAccessPolicy {
override suspend fun check(op: NetAccessOp, ctx: AccessContext): AccessDecision =
AccessDecision(Decision.Deny, "blocked by test policy")
}
createNetModule(denyAll, scope)
val code = """
import lyng.io.net
Net.tcpConnect("127.0.0.1", 1)
""".trimIndent()
val error = assertFailsWith<ExecutionError> {
Compiler.compile(code).execute(scope)
}
assertTrue(error.errorMessage.isNotBlank())
}
}
@@ -0,0 +1,123 @@
/*
* 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.testtls
import java.nio.file.Files
import java.nio.file.Path
import java.security.KeyStore
import javax.net.ssl.KeyManagerFactory
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLServerSocketFactory
import javax.net.ssl.TrustManagerFactory
import kotlin.io.path.absolutePathString
internal object TlsTestMaterial {
private const val STORE_PASSWORD = "changeit"
private const val KEY_ALIAS = "lyng-io-test"
private val dir: Path by lazy { Files.createTempDirectory("lyng-io-tls-test-") }
private val serverStore: Path by lazy { dir.resolve("server.p12") }
private val trustStore: Path by lazy { dir.resolve("trust.p12") }
private val certFile: Path by lazy { dir.resolve("server.cer") }
val serverSocketFactory: SSLServerSocketFactory by lazy {
ensureGenerated()
val keyStore = KeyStore.getInstance("PKCS12")
serverStore.toFile().inputStream().use { keyStore.load(it, STORE_PASSWORD.toCharArray()) }
val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
kmf.init(keyStore, STORE_PASSWORD.toCharArray())
SSLContext.getInstance("TLS").apply {
init(kmf.keyManagers, null, null)
}.serverSocketFactory
}
val sslContext: SSLContext by lazy {
ensureGenerated()
val keyStore = KeyStore.getInstance("PKCS12")
serverStore.toFile().inputStream().use { keyStore.load(it, STORE_PASSWORD.toCharArray()) }
val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
kmf.init(keyStore, STORE_PASSWORD.toCharArray())
val trust = KeyStore.getInstance("PKCS12")
trustStore.toFile().inputStream().use { trust.load(it, STORE_PASSWORD.toCharArray()) }
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
tmf.init(trust)
SSLContext.getInstance("TLS").apply {
init(kmf.keyManagers, tmf.trustManagers, null)
}
}
fun installJvmClientTrust() {
ensureGenerated()
System.setProperty("javax.net.ssl.trustStore", trustStore.absolutePathString())
System.setProperty("javax.net.ssl.trustStorePassword", STORE_PASSWORD)
System.setProperty("javax.net.ssl.trustStoreType", "PKCS12")
val trust = KeyStore.getInstance("PKCS12")
trustStore.toFile().inputStream().use { trust.load(it, STORE_PASSWORD.toCharArray()) }
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
tmf.init(trust)
SSLContext.setDefault(
SSLContext.getInstance("TLS").apply {
init(null, tmf.trustManagers, null)
}
)
}
private fun ensureGenerated() {
if (Files.exists(serverStore) && Files.exists(trustStore) && Files.exists(certFile)) return
runKeytool(
"-genkeypair",
"-alias", KEY_ALIAS,
"-keyalg", "RSA",
"-keysize", "2048",
"-validity", "2",
"-storetype", "PKCS12",
"-keystore", serverStore.absolutePathString(),
"-storepass", STORE_PASSWORD,
"-keypass", STORE_PASSWORD,
"-dname", "CN=127.0.0.1, OU=Lyng, O=Lyng, L=Test, ST=Test, C=US",
"-ext", "SAN=ip:127.0.0.1,dns:localhost",
)
runKeytool(
"-exportcert",
"-alias", KEY_ALIAS,
"-keystore", serverStore.absolutePathString(),
"-storepass", STORE_PASSWORD,
"-rfc",
"-file", certFile.absolutePathString(),
)
runKeytool(
"-importcert",
"-noprompt",
"-alias", KEY_ALIAS,
"-storetype", "PKCS12",
"-keystore", trustStore.absolutePathString(),
"-storepass", STORE_PASSWORD,
"-file", certFile.absolutePathString(),
)
}
private fun runKeytool(vararg args: String) {
val process = ProcessBuilder(listOf("keytool") + args)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().readText()
val code = process.waitFor()
require(code == 0) { "keytool failed: $output" }
}
}
@@ -0,0 +1,135 @@
/*
* 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.ws
import kotlinx.coroutines.runBlocking
import net.sergeych.lyng.Compiler
import net.sergeych.lyng.ExecutionError
import net.sergeych.lyng.Script
import net.sergeych.lyngio.fs.security.AccessContext
import net.sergeych.lyngio.fs.security.AccessDecision
import net.sergeych.lyngio.fs.security.Decision
import net.sergeych.lyngio.ws.security.PermitAllWsAccessPolicy
import net.sergeych.lyngio.ws.security.WsAccessOp
import net.sergeych.lyngio.ws.security.WsAccessPolicy
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class LyngWsModuleTest {
@Test
fun testTextSessionAndHeaders() = runBlocking {
TestWebSocketServer { connection ->
assertEquals("yes", connection.requestHeaders["x-test"])
val text = connection.receiveText()
connection.sendText("echo:$text")
connection.close()
}.use { server ->
val scope = Script.newScope()
createWsModule(PermitAllWsAccessPolicy, scope)
val code = """
import lyng.io.ws
val ws = Ws.connect("${server.url}", "X-Test" => "yes")
ws.sendText("ping")
val m: WsMessage = ws.receive()
ws.close()
[ws.url(), m.isText, m.text]
""".trimIndent()
val result = Compiler.compile(code).execute(scope).inspect(scope)
assertTrue(result.contains(server.url), result)
assertTrue(result.contains("true,echo:ping"), result)
}
}
@Test
fun testBinarySession() = runBlocking {
TestWebSocketServer { connection ->
val data = connection.receiveBinary()
connection.sendBinary(byteArrayOf(1, 2, 3) + data)
connection.close()
}.use { server ->
val scope = Script.newScope()
createWsModule(PermitAllWsAccessPolicy, scope)
val code = """
import lyng.buffer
import lyng.io.ws
val ws = Ws.connect("${server.url}")
ws.sendBytes(Buffer(9, 8, 7))
val m: WsMessage = ws.receive()
ws.close()
[m.isText, (m.data as Buffer).hex]
""".trimIndent()
val result = Compiler.compile(code).execute(scope).inspect(scope)
assertTrue(result.contains("false,010203090807"), result)
}
}
@Test
fun testSecureTextSession() = runBlocking {
TestWebSocketServer(secure = true) { connection ->
val text = connection.receiveText()
connection.sendText("secure:$text")
connection.close()
}.use { server ->
val scope = Script.newScope()
createWsModule(PermitAllWsAccessPolicy, scope)
val code = """
import lyng.io.ws
val ws = Ws.connect("${server.url}")
ws.sendText("ping")
val m: WsMessage = ws.receive()
ws.close()
[ws.url(), m.text]
""".trimIndent()
val result = Compiler.compile(code).execute(scope).inspect(scope)
assertTrue(result.contains(server.url), result)
assertTrue(result.contains("secure:ping"), result)
}
}
@Test
fun testPolicyDenialSurfacesAsLyngError() = runBlocking {
val scope = Script.newScope()
val denyAll = object : WsAccessPolicy {
override suspend fun check(op: WsAccessOp, ctx: AccessContext): AccessDecision =
AccessDecision(Decision.Deny, "blocked by test policy")
}
createWsModule(denyAll, scope)
val code = """
import lyng.io.ws
Ws.connect("ws://127.0.0.1:1/ws")
""".trimIndent()
val error = assertFailsWith<ExecutionError> {
Compiler.compile(code).execute(scope)
}
assertTrue(error.errorMessage.isNotBlank())
}
}
@@ -0,0 +1,180 @@
/*
* 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.ws
import net.sergeych.lyng.io.testtls.TlsTestMaterial
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.net.InetAddress
import java.net.ServerSocket
import java.net.Socket
import java.security.MessageDigest
import java.util.Base64
import kotlin.concurrent.thread
internal class TestWebSocketServer(
secure: Boolean = false,
private val handler: (TestWebSocketConnection) -> Unit,
) : AutoCloseable {
private val server: ServerSocket = if (secure) {
TlsTestMaterial.installJvmClientTrust()
TlsTestMaterial.serverSocketFactory.createServerSocket(0, 50, InetAddress.getByName("127.0.0.1")) as ServerSocket
} else {
ServerSocket(0, 50, InetAddress.getByName("127.0.0.1"))
}
private val scheme = if (secure) "wss" else "ws"
private val worker = thread(start = true, name = "ws-test-server") {
try {
server.accept().use { socket ->
val connection = TestWebSocketConnection(socket)
connection.handshake()
handler(connection)
}
} catch (_: Exception) {
}
}
val url: String = "$scheme://127.0.0.1:${server.localPort}/ws"
override fun close() {
server.close()
worker.join(2000)
}
}
internal class TestWebSocketConnection(socket: Socket) {
private val input = BufferedInputStream(socket.getInputStream())
private val output = BufferedOutputStream(socket.getOutputStream())
val requestHeaders = linkedMapOf<String, String>()
fun handshake() {
val requestLine = input.readAsciiLine() ?: error("missing websocket request line")
require(requestLine.startsWith("GET ")) { "unexpected request: $requestLine" }
while (true) {
val line = input.readAsciiLine() ?: error("unexpected EOF during websocket handshake")
if (line.isEmpty()) break
val colon = line.indexOf(':')
if (colon > 0) {
requestHeaders[line.substring(0, colon).trim().lowercase()] = line.substring(colon + 1).trim()
}
}
val key = requestHeaders["sec-websocket-key"] ?: error("missing Sec-WebSocket-Key")
val accept = Base64.getEncoder().encodeToString(
MessageDigest.getInstance("SHA-1")
.digest((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").toByteArray())
)
output.write(
(
"HTTP/1.1 101 Switching Protocols\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
"Sec-WebSocket-Accept: $accept\r\n" +
"\r\n"
).toByteArray()
)
output.flush()
}
fun receiveText(): String = receiveFrame().let { frame ->
require(frame.opcode == 0x1) { "expected text frame, got opcode=${frame.opcode}" }
frame.payload.decodeToString()
}
fun receiveBinary(): ByteArray = receiveFrame().let { frame ->
require(frame.opcode == 0x2) { "expected binary frame, got opcode=${frame.opcode}" }
frame.payload
}
fun sendText(text: String) {
sendFrame(0x1, text.encodeToByteArray())
}
fun sendBinary(data: ByteArray) {
sendFrame(0x2, data)
}
fun close(code: Int = 1000, reason: String = "") {
val reasonBytes = reason.encodeToByteArray()
val payload = ByteArray(2 + reasonBytes.size)
payload[0] = ((code shr 8) and 0xff).toByte()
payload[1] = (code and 0xff).toByte()
reasonBytes.copyInto(payload, 2)
sendFrame(0x8, payload)
}
private fun sendFrame(opcode: Int, payload: ByteArray) {
output.write(0x80 or opcode)
when {
payload.size < 126 -> output.write(payload.size)
payload.size <= 0xffff -> {
output.write(126)
output.write((payload.size ushr 8) and 0xff)
output.write(payload.size and 0xff)
}
else -> error("payload too large for test websocket server")
}
output.write(payload)
output.flush()
}
private fun receiveFrame(): IncomingFrame {
val b0 = input.read()
require(b0 >= 0) { "unexpected EOF reading websocket frame" }
val b1 = input.read()
require(b1 >= 0) { "unexpected EOF reading websocket frame length" }
val opcode = b0 and 0x0f
var length = b1 and 0x7f
if (length == 126) {
length = (input.read() shl 8) or input.read()
} else if (length == 127) {
error("64-bit websocket lengths are not supported in tests")
}
val masked = (b1 and 0x80) != 0
val mask = if (masked) ByteArray(4).also { input.readFully(it) } else null
val payload = ByteArray(length)
input.readFully(payload)
if (mask != null) {
for (i in payload.indices) payload[i] = (payload[i].toInt() xor mask[i % 4].toInt()).toByte()
}
return IncomingFrame(opcode, payload)
}
}
private data class IncomingFrame(val opcode: Int, val payload: ByteArray)
private fun BufferedInputStream.readAsciiLine(): String? {
val out = StringBuilder()
while (true) {
val b = read()
if (b < 0) return if (out.isEmpty()) null else out.toString()
if (b == '\n'.code) {
if (out.endsWith("\r")) out.setLength(out.length - 1)
return out.toString()
}
out.append(b.toChar())
}
}
private fun BufferedInputStream.readFully(target: ByteArray) {
var offset = 0
while (offset < target.size) {
val read = read(target, offset, target.size - offset)
require(read > 0) { "unexpected EOF" }
offset += read
}
}
@@ -0,0 +1,3 @@
package net.sergeych.lyngio.net
actual fun getSystemNetEngine(): LyngNetEngine = UnsupportedLyngNetEngine
@@ -0,0 +1,3 @@
package net.sergeych.lyngio.ws
actual fun getSystemWsEngine(): LyngWsEngine = UnsupportedLyngWsEngine