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
11 changes: 9 additions & 2 deletions Sources/SwiftMTH/TelnetSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -162,10 +162,11 @@ public final class TelnetSession {
/// 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`.
/// Re-assert MXP after a copyover restore, for a client that had MXP enabled:
/// resends the start command and the locked-default line mode. See `processDoMxp`.
public func reassertMXP() {
guard commFlags.contains(.mxp) else { return }
write(TelnetSession.mxpStart)
write(TelnetSession.mxpLockedDefault)
}

Expand Down Expand Up @@ -776,9 +777,15 @@ public final class TelnetSession {
/// 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]

/// The MXP start command: tells the client to begin parsing MXP. WILL/DO only
/// agrees the option; strict clients stay inert until they receive this
/// subnegotiation.
private static let mxpStart: [UInt8] = [TC.IAC, TC.SB, TO.MXP, TC.IAC, TC.SE]

private func processDoMxp() {
guard !commFlags.contains(.mxp) else { return }
commFlags.insert(.mxp)
write(TelnetSession.mxpStart)
write(TelnetSession.mxpLockedDefault)
log("INFO MXP ENABLED")
}
Expand Down
4 changes: 4 additions & 0 deletions Sources/cmth/telopt.c
Original file line number Diff line number Diff line change
Expand Up @@ -879,6 +879,10 @@ int process_do_mxp( DESCRIPTOR_DATA *d, unsigned char *src, int srclen )

SET_BIT(d->mth->comm_flags, COMM_FLAG_MXP);

/* The MXP start command: WILL/DO only agrees the option; strict clients
stay inert until they receive this subnegotiation. */
descriptor_printf(d, "%c%c%c%c%c", IAC, SB, TELOPT_MXP, IAC, SE);

/* ESC[7z — Lock Locked: make "locked" the persistent default line mode, so
normal output is never parsed as MXP markup. Links opt back in per-span. */
descriptor_printf(d, "\033[7z");
Expand Down
41 changes: 37 additions & 4 deletions Tests/MTHTests/MXPNegotiationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@ private let IAC: UInt8 = 255
private let DONT: UInt8 = 254
private let DO: UInt8 = 253
private let WILL: UInt8 = 251
private let SB: UInt8 = 250
private let SE: UInt8 = 240
private let MXP: UInt8 = 91

// ESC[7z — Lock Locked (persistent locked default line mode)
private let lockedDefault: [UInt8] = [0x1B, 0x5B, 0x37, 0x7A]

private let mxpStart: [UInt8] = [IAC, SB, MXP, IAC, SE]

@Suite("MXP negotiation")
struct MXPNegotiationTests {

Expand All @@ -29,6 +33,30 @@ struct MXPNegotiationTests {
#expect(containsSubsequence(d.allBytes, lockedDefault))
}

@Test func doMxpSendsStartCommandBeforeLockedDefault() {
let d = FakeDelegateMXP()
let s = TelnetSession(delegate: d)
_ = s.processInput([IAC, DO, MXP])
let bytes = d.allBytes
#expect(containsSubsequence(bytes, mxpStart))
// The start command must precede the line-mode escape: a client that is
// inert until the start command would otherwise miss the locked default.
if let startIndex = firstIndexOfSubsequence(bytes, mxpStart),
let lockIndex = firstIndexOfSubsequence(bytes, lockedDefault) {
#expect(startIndex < lockIndex)
}
}

@Test func reassertResendsStartCommandAndLockedDefault() {
let d = FakeDelegateMXP()
let s = TelnetSession(delegate: d)
_ = s.processInput([IAC, DO, MXP])
d.writtenChunks.removeAll()
s.reassertMXP()
#expect(containsSubsequence(d.allBytes, mxpStart))
#expect(containsSubsequence(d.allBytes, lockedDefault))
}

@Test func dontMxpDisables() {
let d = FakeDelegateMXP()
let s = TelnetSession(delegate: d)
Expand All @@ -44,7 +72,8 @@ struct MXPNegotiationTests {
_ = s.processInput([IAC, DO, MXP])
d.writtenChunks.removeAll()
_ = s.processInput([IAC, DO, MXP])
// Second DO MXP should not re-emit the locked-default marker.
// Second DO MXP should not re-emit the start command or locked-default marker.
#expect(!containsSubsequence(d.allBytes, mxpStart))
#expect(!containsSubsequence(d.allBytes, lockedDefault))
}

Expand All @@ -64,9 +93,13 @@ private final class FakeDelegateMXP: TelnetSessionDelegate {
}

private func containsSubsequence(_ haystack: [UInt8], _ needle: [UInt8]) -> Bool {
guard !needle.isEmpty, haystack.count >= needle.count else { return false }
firstIndexOfSubsequence(haystack, needle) != nil
}

private func firstIndexOfSubsequence(_ haystack: [UInt8], _ needle: [UInt8]) -> Int? {
guard !needle.isEmpty, haystack.count >= needle.count else { return nil }
for start in 0...(haystack.count - needle.count) where Array(haystack[start..<start + needle.count]) == needle {
return true
return start
}
return false
return nil
}
13 changes: 11 additions & 2 deletions kotlin/mth-core/src/main/kotlin/mth/core/server/TelnetSession.kt
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,13 @@ class TelnetSession(
/** Whether the client negotiated MXP (telnet option 91). */
val mxpEnabled: Boolean get() = CommFlags.MXP in commFlags

/** Re-assert the locked-default MXP line mode after a copyover restore. */
/** Re-assert MXP after a copyover restore: resends the start command and the
* locked-default line mode. */
fun reassertMXP() {
if (CommFlags.MXP in commFlags) write(MXP_LOCKED_DEFAULT)
if (CommFlags.MXP in commFlags) {
write(MXP_START)
write(MXP_LOCKED_DEFAULT)
}
}

/** Send echo-off (password mode). */
Expand Down Expand Up @@ -746,6 +750,7 @@ class TelnetSession(
private fun processDoMxp() {
if (CommFlags.MXP in commFlags) return
commFlags = commFlags.insert(CommFlags.MXP)
write(MXP_START)
write(MXP_LOCKED_DEFAULT)
log("INFO MXP ENABLED")
}
Expand Down Expand Up @@ -888,5 +893,9 @@ class TelnetSession(
// ESC[7z — Lock Locked: makes "locked" the persistent default line mode so normal
// output is never parsed as MXP markup; links opt back in per-span with ESC[1z…ESC[2z.
val MXP_LOCKED_DEFAULT = byteArrayOf(0x1B, 0x5B, 0x37, 0x7A)

// The MXP start command: WILL/DO only agrees the option; strict clients
// stay inert until they receive this subnegotiation.
val MXP_START = byteArrayOf(TC.IAC, TC.SB, TO.MXP, TC.IAC, TC.SE)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ private const val MSDP: Byte = 69
private const val MSSP: Byte = 70
private const val MCCP2: Byte = 86
private const val MCCP3: Byte = 87
private const val MXP: Byte = 91
private const val GMCP: Byte = 0xC9.toByte()

// Sub-negotiation constants
Expand Down Expand Up @@ -110,16 +111,22 @@ class TelnetSessionTest {
@Test fun doMxpEnablesAndLocksDefault() {
val (s, d) = makeSession()
assertFalse(s.mxpEnabled)
s.processInput(bytes(0xFF, 0xFD, 91))
s.processInput(byteArrayOf(IAC, DO, MXP))
assertTrue(s.mxpEnabled)
assertTrue(d.allWrittenBytes.containsSequence(bytes(0x1B, 0x5B, 0x37, 0x7A)))
}

@Test fun doMxpSendsStartCommand() {
val (s, d) = makeSession()
s.processInput(byteArrayOf(IAC, DO, MXP))
assertTrue(d.allWrittenBytes.containsSequence(byteArrayOf(IAC, SB, MXP, IAC, SE)))
}

@Test fun dontMxpDisables() {
val (s, _) = makeSession()
s.processInput(bytes(0xFF, 0xFD, 91))
s.processInput(byteArrayOf(IAC, DO, MXP))
assertTrue(s.mxpEnabled)
s.processInput(bytes(0xFF, 0xFE, 91))
s.processInput(byteArrayOf(IAC, DONT, MXP))
assertFalse(s.mxpEnabled)
}

Expand Down
Loading