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

+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
}
}