From ee8ac0c310c42d27c7fc8069233c72a7cf9b5960 Mon Sep 17 00:00:00 2001 From: James Power Date: Mon, 29 Jun 2026 00:00:42 -0400 Subject: [PATCH] Add MXP telnet negotiation (option 91) with locked-default line mode. --- Sources/SwiftMTH/CommFlags.swift | 1 + Sources/SwiftMTH/TelnetSession.swift | 33 ++++++++++ Sources/SwiftMTHCore/TelnetConstants.swift | 2 +- Tests/MTHTests/MXPNegotiationTests.swift | 72 ++++++++++++++++++++++ 4 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 Tests/MTHTests/MXPNegotiationTests.swift diff --git a/Sources/SwiftMTH/CommFlags.swift b/Sources/SwiftMTH/CommFlags.swift index 9c983cf..5ed2e0f 100644 --- a/Sources/SwiftMTH/CommFlags.swift +++ b/Sources/SwiftMTH/CommFlags.swift @@ -16,4 +16,5 @@ public struct CommFlags: OptionSet, Sendable { public static let colors256 = CommFlags(rawValue: 1 << 5) public static let utf8 = CommFlags(rawValue: 1 << 6) public static let gmcp = CommFlags(rawValue: 1 << 7) + public static let mxp = CommFlags(rawValue: 1 << 8) } diff --git a/Sources/SwiftMTH/TelnetSession.swift b/Sources/SwiftMTH/TelnetSession.swift index a85f98a..325e16c 100644 --- a/Sources/SwiftMTH/TelnetSession.swift +++ b/Sources/SwiftMTH/TelnetSession.swift @@ -132,6 +132,16 @@ public final class TelnetSession { write([TC.IAC, TC.WILL, TO.GMCP]) } + /// Whether the client negotiated MXP (telnet option 91). + public var mxpEnabled: Bool { commFlags.contains(.mxp) } + + /// Re-assert the locked-default MXP line mode after a copyover restore, for a + /// client that had MXP enabled. See `processDoMxp`. + public func reassertMXP() { + guard commFlags.contains(.mxp) else { return } + write(TelnetSession.mxpLockedDefault) + } + /// Send echo-off (password mode). public func sendEchoOff() { commFlags.insert(.password) @@ -332,6 +342,11 @@ public final class TelnetSession { handler: { s, src, i, n in s.processDoGmcp(); return 3 }), TeloptPattern(pattern: [TC.IAC, TC.SB, TO.GMCP], handler: { s, src, i, n in s.processSbGmcp(src, at: i, srclen: n) }), + + TeloptPattern(pattern: [TC.IAC, TC.DO, TO.MXP], + handler: { s, src, i, n in s.processDoMxp(); return 3 }), + TeloptPattern(pattern: [TC.IAC, TC.DONT, TO.MXP], + handler: { s, src, i, n in s.processDontMxp(); return 3 }), ] #if canImport(CZlib) patterns += [ @@ -721,6 +736,24 @@ public final class TelnetSession { return sbLen } + // MARK: - Handler: MXP + + /// `ESC[7z` — Lock Locked: makes "locked" the persistent default line mode across + /// newlines, so normal output (with stray `< > &`) is never parsed as MXP markup. + /// Links opt back in per-span with `ESC[1z … ESC[2z`. (Zugg MXP line-mode spec.) + private static let mxpLockedDefault: [UInt8] = [0x1B, 0x5B, 0x37, 0x7A] + + private func processDoMxp() { + guard !commFlags.contains(.mxp) else { return } + commFlags.insert(.mxp) + write(TelnetSession.mxpLockedDefault) + log("INFO MXP ENABLED") + } + + private func processDontMxp() { + commFlags.remove(.mxp) + } + // MARK: - Handler: GMCP private func processDoGmcp() { diff --git a/Sources/SwiftMTHCore/TelnetConstants.swift b/Sources/SwiftMTHCore/TelnetConstants.swift index 700942f..b618d99 100644 --- a/Sources/SwiftMTHCore/TelnetConstants.swift +++ b/Sources/SwiftMTHCore/TelnetConstants.swift @@ -109,7 +109,7 @@ public let defaultTelnetTable: [TelnetOptionEntry] = { table[86] = TelnetOptionEntry("MCCP2", .will) table[87] = TelnetOptionEntry("MCCP3", .will) table[90] = TelnetOptionEntry("MSP", .will) - table[91] = TelnetOptionEntry("MXP") + table[91] = TelnetOptionEntry("MXP", .will) table[201] = TelnetOptionEntry("GMCP", .will) return table diff --git a/Tests/MTHTests/MXPNegotiationTests.swift b/Tests/MTHTests/MXPNegotiationTests.swift new file mode 100644 index 0000000..12f6fb0 --- /dev/null +++ b/Tests/MTHTests/MXPNegotiationTests.swift @@ -0,0 +1,72 @@ +import Foundation +import Testing +import MTH + +private let IAC: UInt8 = 255 +private let DONT: UInt8 = 254 +private let DO: UInt8 = 253 +private let WILL: UInt8 = 251 +private let MXP: UInt8 = 91 + +// ESC[7z — Lock Locked (persistent locked default line mode) +private let lockedDefault: [UInt8] = [0x1B, 0x5B, 0x37, 0x7A] + +@Suite("MXP negotiation") +struct MXPNegotiationTests { + + @Test func announcesWillMxp() { + let d = FakeDelegateMXP() + let s = TelnetSession(delegate: d) + s.announceSupport() + #expect(containsSubsequence(d.allBytes, [IAC, WILL, MXP])) + } + + @Test func doMxpEnablesAndLocksDefault() { + let d = FakeDelegateMXP() + let s = TelnetSession(delegate: d) + _ = s.processInput([IAC, DO, MXP]) + #expect(s.mxpEnabled) + #expect(containsSubsequence(d.allBytes, lockedDefault)) + } + + @Test func dontMxpDisables() { + let d = FakeDelegateMXP() + let s = TelnetSession(delegate: d) + _ = s.processInput([IAC, DO, MXP]) + #expect(s.mxpEnabled) + _ = s.processInput([IAC, DONT, MXP]) + #expect(!s.mxpEnabled) + } + + @Test func doMxpIsIdempotent() { + let d = FakeDelegateMXP() + let s = TelnetSession(delegate: d) + _ = s.processInput([IAC, DO, MXP]) + d.writtenChunks.removeAll() + _ = s.processInput([IAC, DO, MXP]) + // Second DO MXP should not re-emit the locked-default marker. + #expect(!containsSubsequence(d.allBytes, lockedDefault)) + } + + @Test func mxpDisabledByDefault() { + let (s, _) = (TelnetSession(delegate: FakeDelegateMXP()), ()) + #expect(!s.mxpEnabled) + } +} + +private final class FakeDelegateMXP: TelnetSessionDelegate { + var writtenChunks: [[UInt8]] = [] + var allBytes: [UInt8] { writtenChunks.flatMap { $0 } } + func telnetSession(_ session: TelnetSession, write data: [UInt8]) { writtenChunks.append(data) } + func telnetSession(_ session: TelnetSession, log message: String) {} + func telnetSessionMSSPData(_ session: TelnetSession) -> [(key: String, value: String)] { [] } + func telnetSession(_ session: TelnetSession, gmcpReceived packet: GMCPPacket) {} +} + +private func containsSubsequence(_ haystack: [UInt8], _ needle: [UInt8]) -> Bool { + guard !needle.isEmpty, haystack.count >= needle.count else { return false } + for start in 0...(haystack.count - needle.count) where Array(haystack[start..