Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Sources/SwiftMTHClient/TelnetClientDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -28,6 +31,7 @@ public protocol TelnetClientDelegate: AnyObject {

public extension TelnetClientDelegate {
func onGMCPNegotiated() {}
func onMSSPReceived(data: [String: String]) {}
func onBellReceived() {}
func log(message: String) {}
}
58 changes: 58 additions & 0 deletions Sources/SwiftMTHClient/TelnetClientSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 }),
Expand Down Expand Up @@ -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() {
Expand Down
40 changes: 40 additions & 0 deletions Tests/MTHTests/TelnetClientSessionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) }
Expand Down Expand Up @@ -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")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ interface TelnetClientDelegate {
/** Server sent EOR or GA prompt marker. */
fun onPromptReceived()

/** MSSP data received from server. */
fun onMSSPReceived(data: Map<String, String>) {}

/** Server sent BEL (0x07) character. */
fun onBellReceived() {}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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<String, String>()
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<Byte>()
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<Byte>()
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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -47,6 +48,7 @@ private class FakeClientDelegate : TelnetClientDelegate {
val logMessages = mutableListOf<String>()
val gmcpMessages = mutableListOf<Pair<String, String>>()
val msdpVariables = mutableListOf<Pair<String, String>>()
val msspData = mutableListOf<Map<String, String>>()
var localEchoEnabled: Boolean? = null
var promptCount = 0
var bellCount = 0
Expand All @@ -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<String, String>) { msspData.add(data) }
override fun onPromptReceived() {
promptCount++
}
Expand Down Expand Up @@ -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"])
}
}
Loading