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
1 change: 1 addition & 0 deletions Sources/SwiftMTH/CommFlags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
33 changes: 33 additions & 0 deletions Sources/SwiftMTH/TelnetSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 += [
Expand Down Expand Up @@ -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() {
Expand Down
2 changes: 1 addition & 1 deletion Sources/SwiftMTHCore/TelnetConstants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions Tests/MTHTests/MXPNegotiationTests.swift
Original file line number Diff line number Diff line change
@@ -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..<start + needle.count]) == needle {
return true
}
return false
}
Loading