diff --git a/Sources/SwiftMTHClient/TelnetClientDelegate.swift b/Sources/SwiftMTHClient/TelnetClientDelegate.swift index 0979ff2..0b69320 100644 --- a/Sources/SwiftMTHClient/TelnetClientDelegate.swift +++ b/Sources/SwiftMTHClient/TelnetClientDelegate.swift @@ -16,6 +16,9 @@ public protocol TelnetClientDelegate: AnyObject { /// Called when an MSDP variable is received from the server. func onMSDPVariable(name: String, value: String) + /// Called when MSSP data is received from the server. + func onMSSPReceived(data: [String: String]) + /// Called when a prompt marker (GA or EOR) is received. func onPromptReceived() @@ -28,6 +31,7 @@ public protocol TelnetClientDelegate: AnyObject { public extension TelnetClientDelegate { func onGMCPNegotiated() {} + func onMSSPReceived(data: [String: String]) {} func onBellReceived() {} func log(message: String) {} } diff --git a/Sources/SwiftMTHClient/TelnetClientSession.swift b/Sources/SwiftMTHClient/TelnetClientSession.swift index e1ae8f2..56f8fd1 100644 --- a/Sources/SwiftMTHClient/TelnetClientSession.swift +++ b/Sources/SwiftMTHClient/TelnetClientSession.swift @@ -23,6 +23,7 @@ public final class TelnetClientSession { public private(set) var serverEcho: Bool = false public private(set) var gmcpEnabled: Bool = false public private(set) var msdpEnabled: Bool = false + public private(set) var msspEnabled: Bool = false // MARK: - Private State @@ -193,6 +194,12 @@ public final class TelnetClientSession { TeloptPattern(pattern: [TC.IAC, TC.SB, TO.MSDP], handler: { s, src, i, n in s.processSbMsdp(src, at: i, srclen: n) }), + // MSSP + TeloptPattern(pattern: [TC.IAC, TC.WILL, TO.MSSP], + handler: { s, _, _, _ in s.processWillMssp(); return 3 }), + TeloptPattern(pattern: [TC.IAC, TC.SB, TO.MSSP], + handler: { s, src, i, n in s.processSbMssp(src, at: i, srclen: n) }), + // Echo TeloptPattern(pattern: [TC.IAC, TC.WILL, TO.ECHO], handler: { s, _, _, _ in s.processWillEcho(); return 3 }), @@ -395,6 +402,57 @@ public final class TelnetClientSession { return sbLen } + // MARK: - Handler: MSSP + + private func processWillMssp() { + msspEnabled = true + serverOptions.insert(TO.MSSP) + write([TC.IAC, TC.DO, TO.MSSP]) + } + + private func processSbMssp(_ src: [UInt8], at offset: Int, srclen: Int) -> Int { + let sbLen = skipSB(src, at: offset, srclen: srclen) + if sbLen > srclen { return srclen + 1 } + + var data: [String: String] = [:] + var varName = "" + var j = offset + 3 + let end = offset + srclen + + while j < end && src[j] != TC.SE { + switch src[j] { + case 1: // MSSP_VAR + j += 1 + var buf: [UInt8] = [] + while j < end && src[j] != 1 && src[j] != 2 && src[j] != TC.IAC { + buf.append(src[j]) + j += 1 + } + varName = String(decoding: buf, as: UTF8.self) + + case 2: // MSSP_VAL + j += 1 + var buf: [UInt8] = [] + while j < end && src[j] != 1 && src[j] != 2 && src[j] != TC.IAC { + buf.append(src[j]) + j += 1 + } + if !varName.isEmpty { + data[varName] = String(decoding: buf, as: UTF8.self) + } + + default: + j += 1 + } + } + + if !data.isEmpty { + delegate?.onMSSPReceived(data: data) + } + + return sbLen + } + // MARK: - Handler: Echo private func processWillEcho() { diff --git a/Tests/MTHTests/TelnetClientSessionTests.swift b/Tests/MTHTests/TelnetClientSessionTests.swift index 1b133e6..021005b 100644 --- a/Tests/MTHTests/TelnetClientSessionTests.swift +++ b/Tests/MTHTests/TelnetClientSessionTests.swift @@ -11,6 +11,7 @@ final class FakeClientDelegate: TelnetClientDelegate { var logMessages: [String] = [] var gmcpMessages: [(module: String, json: String)] = [] var msdpVariables: [(name: String, value: String)] = [] + var msspData: [[String: String]] = [] var localEchoEnabled: Bool? = nil var promptCount = 0 var bellCount = 0 @@ -25,6 +26,7 @@ final class FakeClientDelegate: TelnetClientDelegate { func onGMCPNegotiated() { gmcpNegotiatedCount += 1 } func onGMCPReceived(module: String, json: String) { gmcpMessages.append((module, json)) } func onMSDPVariable(name: String, value: String) { msdpVariables.append((name, value)) } + func onMSSPReceived(data: [String: String]) { msspData.append(data) } func onPromptReceived() { promptCount += 1 } func onBellReceived() { bellCount += 1 } func log(message: String) { logMessages.append(message) } @@ -360,4 +362,42 @@ struct TelnetClientSessionTests { _ = s.processInput([TC.IAC, TC.WILL, TO.GMCP]) #expect(d.gmcpNegotiatedCount == 2) } + + @Test func serverWillMsspRespondsDo() { + let (s, d) = makeSession() + _ = s.processInput([TC.IAC, TC.WILL, TO.MSSP]) + #expect(s.msspEnabled) + #expect(d.allWrittenBytes == [TC.IAC, TC.DO, TO.MSSP]) + } + + @Test func serverSendsMsspData() { + let (s, d) = makeSession() + _ = s.processInput([TC.IAC, TC.WILL, TO.MSSP]) + d.writtenChunks.removeAll() + let MV: UInt8 = 1; let ML: UInt8 = 2 + let packet: [UInt8] = [TC.IAC, TC.SB, TO.MSSP, MV] + Array("NAME".utf8) + + [ML] + Array("TestMUD".utf8) + + [MV] + Array("PLAYERS".utf8) + + [ML] + Array("42".utf8) + + [TC.IAC, TC.SE] + _ = s.processInput(packet) + #expect(d.msspData.count == 1) + #expect(d.msspData[0]["NAME"] == "TestMUD") + #expect(d.msspData[0]["PLAYERS"] == "42") + } + + @Test func msspWithMultipleValues() { + let (s, d) = makeSession() + _ = s.processInput([TC.IAC, TC.WILL, TO.MSSP]) + d.writtenChunks.removeAll() + let MV: UInt8 = 1; let ML: UInt8 = 2 + // MSSP_VAR "GENRE" MSSP_VAL "Fantasy" MSSP_VAL "Adventure" — last value wins + let packet: [UInt8] = [TC.IAC, TC.SB, TO.MSSP, MV] + Array("GENRE".utf8) + + [ML] + Array("Fantasy".utf8) + + [ML] + Array("Adventure".utf8) + + [TC.IAC, TC.SE] + _ = s.processInput(packet) + #expect(d.msspData.count == 1) + #expect(d.msspData[0]["GENRE"] == "Adventure") + } } diff --git a/kotlin/mth-core/src/main/kotlin/mth/core/client/TelnetClientDelegate.kt b/kotlin/mth-core/src/main/kotlin/mth/core/client/TelnetClientDelegate.kt index 9d6b7a5..dbb0c21 100644 --- a/kotlin/mth-core/src/main/kotlin/mth/core/client/TelnetClientDelegate.kt +++ b/kotlin/mth-core/src/main/kotlin/mth/core/client/TelnetClientDelegate.kt @@ -19,6 +19,9 @@ interface TelnetClientDelegate { /** Server sent EOR or GA prompt marker. */ fun onPromptReceived() + /** MSSP data received from server. */ + fun onMSSPReceived(data: Map) {} + /** Server sent BEL (0x07) character. */ fun onBellReceived() {} diff --git a/kotlin/mth-core/src/main/kotlin/mth/core/client/TelnetClientSession.kt b/kotlin/mth-core/src/main/kotlin/mth/core/client/TelnetClientSession.kt index 734883d..a6c9610 100644 --- a/kotlin/mth-core/src/main/kotlin/mth/core/client/TelnetClientSession.kt +++ b/kotlin/mth-core/src/main/kotlin/mth/core/client/TelnetClientSession.kt @@ -35,6 +35,10 @@ class TelnetClientSession( var msdpEnabled: Boolean = false private set + /** Whether MSSP has been negotiated. */ + var msspEnabled: Boolean = false + private set + // -- Private State -- /** Buffer for incomplete telnet sequences (packet fragmentation). */ @@ -238,6 +242,12 @@ class TelnetClientSession( TeloptPattern(byteArrayOf(TC.IAC, TC.SB, TO.MSDP)) { s, src, i, n -> s.processSbMsdp(src, i, n) }, + // Server offers MSSP + TeloptPattern(byteArrayOf(TC.IAC, TC.WILL, TO.MSSP)) + { s, _, _, _ -> s.processWillMssp(); 3 }, + TeloptPattern(byteArrayOf(TC.IAC, TC.SB, TO.MSSP)) + { s, src, i, n -> s.processSbMssp(src, i, n) }, + // Server offers ECHO TeloptPattern(byteArrayOf(TC.IAC, TC.WILL, TO.ECHO)) { s, _, _, _ -> s.processWillEcho(); 3 }, @@ -425,6 +435,56 @@ class TelnetClientSession( return sbLen } + // -- Handler: MSSP -- + + private fun processWillMssp() { + msspEnabled = true + serverOptions.add(TO.MSSP) + write(byteArrayOf(TC.IAC, TC.DO, TO.MSSP)) + } + + private fun processSbMssp(src: ByteArray, offset: Int, srclen: Int): Int { + val sbLen = skipSB(src, offset, srclen) + if (sbLen > srclen) return srclen + 1 + + val data = mutableMapOf() + var varName = "" + var j = offset + 3 + val end = offset + srclen + + while (j < end && src[j] != TC.SE) { + when (src[j]) { + 1.toByte() -> { // MSSP_VAR + j++ + val buf = mutableListOf() + while (j < end && src[j] != 1.toByte() && src[j] != 2.toByte() && src[j] != TC.IAC) { + buf.add(src[j]) + j++ + } + varName = String(buf.toByteArray(), Charsets.UTF_8) + } + 2.toByte() -> { // MSSP_VAL + j++ + val buf = mutableListOf() + while (j < end && src[j] != 1.toByte() && src[j] != 2.toByte() && src[j] != TC.IAC) { + buf.add(src[j]) + j++ + } + if (varName.isNotEmpty()) { + data[varName] = String(buf.toByteArray(), Charsets.UTF_8) + } + } + else -> j++ + } + } + + if (data.isNotEmpty()) { + delegate?.onMSSPReceived(data) + } + + return sbLen + } + // -- Handler: ECHO -- private fun processWillEcho() { diff --git a/kotlin/mth-core/src/test/kotlin/mth/core/client/TelnetClientSessionTest.kt b/kotlin/mth-core/src/test/kotlin/mth/core/client/TelnetClientSessionTest.kt index 33494e3..0395b20 100644 --- a/kotlin/mth-core/src/test/kotlin/mth/core/client/TelnetClientSessionTest.kt +++ b/kotlin/mth-core/src/test/kotlin/mth/core/client/TelnetClientSessionTest.kt @@ -24,6 +24,7 @@ private const val TTYPE: Byte = 24 private const val EOR_OPT: Byte = 25 private const val NAWS: Byte = 31 private const val MSDP: Byte = 69 +private const val MSSP: Byte = 70 private const val MCCP2: Byte = 86 private const val GMCP: Byte = 0xC9.toByte() @@ -47,6 +48,7 @@ private class FakeClientDelegate : TelnetClientDelegate { val logMessages = mutableListOf() val gmcpMessages = mutableListOf>() val msdpVariables = mutableListOf>() + val msspData = mutableListOf>() var localEchoEnabled: Boolean? = null var promptCount = 0 var bellCount = 0 @@ -69,6 +71,7 @@ private class FakeClientDelegate : TelnetClientDelegate { override fun onMSDPVariable(name: String, value: String) { msdpVariables.add(Pair(name, value)) } + override fun onMSSPReceived(data: Map) { msspData.add(data) } override fun onPromptReceived() { promptCount++ } @@ -474,4 +477,50 @@ class TelnetClientSessionTest { // but in practice servers only send it once assertEquals(2, d.gmcpNegotiatedCount) } + + // -- MSSP -- + + @Test fun serverWillMsspRespondsDo() { + val (s, d) = makeSession() + s.processInput(byteArrayOf(IAC, WILL, MSSP)) + assertTrue(s.msspEnabled) + assertContentEquals(byteArrayOf(IAC, DO, MSSP), d.allWrittenBytes) + } + + @Test fun serverSendsMsspData() { + val (s, d) = makeSession() + s.processInput(byteArrayOf(IAC, WILL, MSSP)) + d.writtenChunks.clear() + + val MV: Byte = 1 + val ML: Byte = 2 + val packet = byteArrayOf(IAC, SB, MSSP, MV) + textBytes("NAME") + + byteArrayOf(ML) + textBytes("TestMUD") + + byteArrayOf(MV) + textBytes("PLAYERS") + + byteArrayOf(ML) + textBytes("42") + + byteArrayOf(IAC, SE) + s.processInput(packet) + + assertEquals(1, d.msspData.size) + assertEquals("TestMUD", d.msspData[0]["NAME"]) + assertEquals("42", d.msspData[0]["PLAYERS"]) + } + + @Test fun msspWithMultipleValues() { + val (s, d) = makeSession() + s.processInput(byteArrayOf(IAC, WILL, MSSP)) + d.writtenChunks.clear() + + val MV: Byte = 1 + val ML: Byte = 2 + // MSSP_VAR "GENRE" MSSP_VAL "Fantasy" MSSP_VAL "Adventure" — last value wins + val packet = byteArrayOf(IAC, SB, MSSP, MV) + textBytes("GENRE") + + byteArrayOf(ML) + textBytes("Fantasy") + + byteArrayOf(ML) + textBytes("Adventure") + + byteArrayOf(IAC, SE) + s.processInput(packet) + + assertEquals(1, d.msspData.size) + assertEquals("Adventure", d.msspData[0]["GENRE"]) + } }