Add verified Ktor-backed networking modules to lyngio
This commit is contained in:
1 parent
f168e9f6ed
commit
d409a4bb8b
35 files changed
+4025
-23
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user