From fa235fe9536f7ba173f965789d522d9dc643fc82 Mon Sep 17 00:00:00 2001 From: James Power Date: Sun, 29 Mar 2026 23:08:10 -0400 Subject: [PATCH 1/3] Handle MCCP1 non-standard SB terminator that broke nanvaent connection. --- .../SwiftMTHClient/TelnetClientSession.swift | 5 + Sources/SwiftMTHCore/TelnetConstants.swift | 2 + Tests/MTHTests/TelnetClientSessionTests.swift | 141 ++++++++++++++++++ 3 files changed, 148 insertions(+) diff --git a/Sources/SwiftMTHClient/TelnetClientSession.swift b/Sources/SwiftMTHClient/TelnetClientSession.swift index 56f8fd1..8f61ebc 100644 --- a/Sources/SwiftMTHClient/TelnetClientSession.swift +++ b/Sources/SwiftMTHClient/TelnetClientSession.swift @@ -224,6 +224,11 @@ public final class TelnetClientSession { TeloptPattern(pattern: [TC.IAC, TC.DO, TO.NAWS], handler: { s, _, _, _ in s.processDoNaws(); return 3 }), + // MCCP1 (option 85) uses a non-standard SB terminator: IAC SB 85 WILL SE + // where SE appears without a preceding IAC. Skip the 5-byte start sequence. + TeloptPattern(pattern: [TC.IAC, TC.SB, TO.MCCP1, TC.WILL, TC.SE], + handler: { _, _, _, _ in return 5 }), + // Prompt markers TeloptPattern(pattern: [TC.IAC, TC.EOR], handler: { s, _, _, _ in s.delegate?.onPromptReceived(); return 2 }), diff --git a/Sources/SwiftMTHCore/TelnetConstants.swift b/Sources/SwiftMTHCore/TelnetConstants.swift index f73d9e1..700942f 100644 --- a/Sources/SwiftMTHCore/TelnetConstants.swift +++ b/Sources/SwiftMTHCore/TelnetConstants.swift @@ -40,6 +40,7 @@ public enum TelnetOption { public static let CHARSET: UInt8 = 42 public static let MSDP: UInt8 = 69 public static let MSSP: UInt8 = 70 + public static let MCCP1: UInt8 = 85 public static let MCCP2: UInt8 = 86 public static let MCCP3: UInt8 = 87 public static let MSP: UInt8 = 90 @@ -104,6 +105,7 @@ public let defaultTelnetTable: [TelnetOptionEntry] = { table[42] = TelnetOptionEntry("CHARSET", .will) table[69] = TelnetOptionEntry("MSDP", .will) table[70] = TelnetOptionEntry("MSSP", .will) + table[85] = TelnetOptionEntry("MCCP1") table[86] = TelnetOptionEntry("MCCP2", .will) table[87] = TelnetOptionEntry("MCCP3", .will) table[90] = TelnetOptionEntry("MSP", .will) diff --git a/Tests/MTHTests/TelnetClientSessionTests.swift b/Tests/MTHTests/TelnetClientSessionTests.swift index 021005b..d1bbe48 100644 --- a/Tests/MTHTests/TelnetClientSessionTests.swift +++ b/Tests/MTHTests/TelnetClientSessionTests.swift @@ -400,4 +400,145 @@ struct TelnetClientSessionTests { #expect(d.msspData.count == 1) #expect(d.msspData[0]["GENRE"] == "Adventure") } + + // MARK: - Nanvaent Replay Tests + + /// Captured bytes from nanvaent.org:23 for replay testing. + /// These exercise MCCP2 with standard zlib (windowBits=15, header 78 DA), + /// embedded telnet commands in compressed stream, and unknown option handling. + private enum Nanvaent { + // Round 1: IAC DO TTYPE + static let round1: [UInt8] = [0xFF, 0xFD, 0x18] + + // Round 2: DO NAWS, WILL MCCP2, DO MXP(91), WILL MSSP, WILL 93, DO NEW_ENVIRON + static let round2: [UInt8] = [ + 0xFF, 0xFD, 0x1F, 0xFF, 0xFB, 0x56, 0xFF, 0xFD, 0x5B, 0xFF, 0xFB, 0x46, 0xFF, 0xFB, 0x5D, 0xFF, + 0xFD, 0x27, + ] + + // Round 3: SB TTYPE SEND IAC SE + static let round3: [UInt8] = [0xFF, 0xFA, 0x18, 0x01, 0xFF, 0xF0] + + // Round 4: SB MCCP2 IAC SE (compression starts) + static let round4: [UInt8] = [0xFF, 0xFA, 0x56, 0xFF, 0xF0] + + // Round 5: compressed data — MSSP subneg + unknown option SBs (standard zlib, 78 DA header) + static let round5: [UInt8] = [ + 0x78, 0xDA, 0xFA, 0xFF, 0xCB, 0x0D, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x62, 0xF4, 0x73, 0xF4, 0x75, + 0x65, 0xF2, 0x4B, 0xCC, 0x2B, 0x4B, 0x4C, 0xCD, 0x2B, 0x01, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x62, + 0x0C, 0xF0, 0x71, 0x8C, 0x74, 0x0D, 0x0A, 0x66, 0xB2, 0x04, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x62, + 0x0C, 0x0D, 0x08, 0xF1, 0x04, 0xCA, 0x18, 0x9A, 0x9B, 0x1B, 0x5A, 0x98, 0x5B, 0x9A, 0x1A, 0x1A, + 0x01, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFA, 0xFF, 0x01, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFA, 0xFF, + 0x2B, 0xF4, 0xF7, 0x07, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, + ] + + // Round 6: compressed banner text (370 bytes) + static let round6: [UInt8] = [ + 0xB4, 0x53, 0x3D, 0x6F, 0xC3, 0x20, 0x10, 0xDD, 0x2D, 0xF9, 0x3F, 0x9C, 0xB2, 0xA4, 0x1D, 0x12, + 0xF6, 0x48, 0x91, 0xDA, 0xC1, 0x5B, 0x87, 0x48, 0x55, 0x87, 0xAA, 0x54, 0x16, 0x49, 0x88, 0x4D, + 0x6B, 0x43, 0x64, 0x83, 0x2A, 0xFF, 0xFB, 0xDE, 0x61, 0x9C, 0x92, 0xC4, 0xAD, 0xBB, 0xE4, 0x84, + 0x6C, 0xEE, 0xE3, 0x3D, 0xB8, 0x07, 0xA4, 0x09, 0xBC, 0xAD, 0xD7, 0xEB, 0xC5, 0x99, 0x61, 0xE0, + 0x1D, 0xB2, 0x45, 0x2D, 0x54, 0x05, 0x2B, 0xD0, 0x81, 0xED, 0x61, 0x98, 0x2C, 0x4D, 0x53, 0xFC, + 0x82, 0x4A, 0x13, 0x24, 0x9C, 0x32, 0xC6, 0xFD, 0x6F, 0xBA, 0x92, 0x01, 0xF0, 0xEB, 0x68, 0x9E, + 0x4F, 0x22, 0x19, 0xAD, 0xC1, 0xC7, 0xE8, 0xA6, 0xA0, 0xAC, 0x5F, 0xF4, 0x12, 0x8B, 0x3E, 0x9B, + 0x80, 0x7A, 0xE4, 0x08, 0x96, 0xF5, 0xC8, 0x3C, 0x32, 0xEF, 0x84, 0x10, 0x16, 0xE4, 0xAC, 0x77, + 0x08, 0x1A, 0x65, 0x10, 0x78, 0x05, 0xF2, 0x86, 0x74, 0x77, 0x71, 0x62, 0x58, 0x97, 0xE6, 0xBC, + 0x77, 0x42, 0x13, 0xA7, 0x50, 0x0E, 0x43, 0x86, 0xE8, 0x91, 0x2A, 0xA2, 0xBE, 0x0F, 0x8D, 0x9D, + 0x38, 0xB9, 0xEF, 0x77, 0x80, 0x0D, 0x83, 0xE8, 0x4E, 0x21, 0xDA, 0xDE, 0x90, 0xF1, 0x9F, 0x0B, + 0xAE, 0x1F, 0x3E, 0x4E, 0x83, 0xF1, 0xBE, 0xA3, 0xC8, 0x89, 0x42, 0xD4, 0x6A, 0x9F, 0xA1, 0xFD, + 0xB1, 0x4B, 0x9E, 0x71, 0x9D, 0xE3, 0xB3, 0xE1, 0x91, 0xCE, 0xFF, 0xC3, 0x04, 0x54, 0xC0, 0xBC, + 0xB4, 0x12, 0xE6, 0x85, 0x93, 0xAD, 0x9D, 0x83, 0x3A, 0xFC, 0x71, 0xF7, 0xBC, 0x04, 0xCC, 0xDF, + 0xDC, 0xCE, 0x38, 0xF8, 0x70, 0xAD, 0x85, 0x2F, 0xA1, 0x2D, 0x58, 0x13, 0xC4, 0x65, 0x30, 0x02, + 0x22, 0x32, 0xC2, 0x54, 0xC6, 0x7C, 0x82, 0x68, 0x8C, 0xD3, 0xFB, 0xE5, 0xC4, 0x0D, 0xF6, 0xA7, + 0x15, 0x5E, 0x93, 0x95, 0x95, 0x96, 0x16, 0xCE, 0x9E, 0xDF, 0xA4, 0x95, 0xD6, 0x1E, 0x57, 0x8C, + 0xC5, 0x20, 0x76, 0x83, 0xC7, 0xFE, 0x6A, 0x5C, 0x03, 0x8D, 0x2A, 0x4A, 0xDB, 0x42, 0x2D, 0x3A, + 0xD8, 0x4A, 0xD8, 0xAB, 0x76, 0x57, 0x09, 0x55, 0x4B, 0x6C, 0x72, 0x53, 0x49, 0x81, 0xE2, 0xDA, + 0xEE, 0x28, 0x61, 0x56, 0xCA, 0xEA, 0x18, 0x6A, 0x67, 0x20, 0x0E, 0x56, 0x36, 0xA8, 0x48, 0xA1, + 0xF4, 0x32, 0x4D, 0x36, 0x4F, 0xD9, 0xE3, 0x73, 0x06, 0x8E, 0x6A, 0x4B, 0x3C, 0x8C, 0xAD, 0x2B, + 0xE6, 0xB0, 0x33, 0x75, 0x2D, 0xF4, 0x9E, 0xC4, 0xC5, 0x3A, 0xC0, 0x58, 0xEB, 0x65, 0x3F, 0x28, + 0xD4, 0x2F, 0x4D, 0x32, 0x4D, 0x0C, 0x1D, 0x6D, 0x40, 0x8B, 0x5A, 0xAE, 0xE0, 0x1B, 0x00, 0x00, + 0xFF, 0xFF, + ] + } + + #if canImport(CZlib) + @Test func nanvaentNegotiationAndMCCP2() { + let (s, d) = makeSession() + s.terminalType = "Wammer" + + // Round 1: DO TTYPE + _ = s.processInput(Nanvaent.round1) + #expect(d.allWrittenBytes.containsSequence([TC.IAC, TC.WILL, TO.TTYPE])) + + // Round 2: DO NAWS, WILL MCCP2, DO MXP(91), WILL MSSP, WILL 93, DO NEW_ENVIRON + d.writtenChunks.removeAll() + _ = s.processInput(Nanvaent.round2) + let r2 = d.allWrittenBytes + #expect(r2.containsSequence([TC.IAC, TC.WILL, TO.NAWS])) // WILL NAWS + #expect(r2.containsSequence([TC.IAC, TC.DO, TO.MCCP2])) // DO MCCP2 + #expect(r2.containsSequence([TC.IAC, TC.WONT, 91])) // WONT MXP + #expect(r2.containsSequence([TC.IAC, TC.DO, TO.MSSP])) // DO MSSP + #expect(r2.containsSequence([TC.IAC, TC.DONT, 93])) // DONT unknown(93) + #expect(r2.containsSequence([TC.IAC, TC.WONT, 39])) // WONT NEW_ENVIRON + + // Round 3: SB TTYPE SEND + d.writtenChunks.removeAll() + _ = s.processInput(Nanvaent.round3) + let ttypeResponse: [UInt8] = [TC.IAC, TC.SB, TO.TTYPE, TS.ENV_IS] + Array("Wammer".utf8) + [TC.IAC, TC.SE] + #expect(d.allWrittenBytes == ttypeResponse) + + // Round 4: SB MCCP2 (compression starts, no trailing data) + d.writtenChunks.removeAll() + _ = s.processInput(Nanvaent.round4) + #expect(s.isMCCP2Active) + + // Round 5: compressed telnet data (MSSP + unknown SBs) + let out5 = s.processInput(Nanvaent.round5) + #expect(d.msspData.count == 1, "MSSP data should be received from compressed stream") + #expect(d.msspData[0]["NAME"] == "Nanvaent") + // Output should be empty (only telnet commands, no visible text) + #expect(out5.isEmpty, "Round 5 should contain only telnet commands, no visible text") + + // Round 6: compressed banner text + let out6 = s.processInput(Nanvaent.round6) + let bannerText = String(decoding: out6, as: UTF8.self) + #expect(!out6.isEmpty) + #expect(bannerText.contains("Enter your name:")) + #expect(bannerText.contains("nanvaent.org")) + } + + @Test func nanvaentInflateStreamDirectly() throws { + let inflater = try #require(InflateStream()) + + let r5 = try #require(inflater.decompress(Nanvaent.round5)) + #expect(!r5.decompressed.isEmpty) + + let r6 = try #require(inflater.decompress(Nanvaent.round6)) + let text = String(decoding: r6.decompressed, as: UTF8.self) + #expect(text.contains("nanvaent")) + } + + @Test func nanvaentCombinedMCCP2Packet() { + // Test the case where MCCP2 SB and compressed data arrive in one packet + let (s, d) = makeSession() + s.terminalType = "Wammer" + + _ = s.processInput(Nanvaent.round1) + _ = s.processInput(Nanvaent.round2) + _ = s.processInput(Nanvaent.round3) + + // Combine rounds 4+5 into one packet (MCCP2 SB + compressed data together) + let combined = Nanvaent.round4 + Nanvaent.round5 + _ = s.processInput(combined) + #expect(s.isMCCP2Active) + #expect(d.msspData.count == 1) + #expect(d.msspData[0]["NAME"] == "Nanvaent") + + // Banner still arrives in next packet + let out6 = s.processInput(Nanvaent.round6) + let bannerText = String(decoding: out6, as: UTF8.self) + #expect(bannerText.contains("Enter your name:")) + } + + #endif } From 2e6781a43ca9ba547a5591c76531d9ad20c3451b Mon Sep 17 00:00:00 2001 From: James Power Date: Sun, 29 Mar 2026 23:18:23 -0400 Subject: [PATCH 2/3] Handle MCCP1 non-standard SB terminator in Kotlin client. --- .../main/kotlin/mth/core/TelnetConstants.kt | 1 + .../mth/core/client/TelnetClientSession.kt | 5 + .../core/client/TelnetClientSessionTest.kt | 116 ++++++++++++++++++ 3 files changed, 122 insertions(+) diff --git a/kotlin/mth-core/src/main/kotlin/mth/core/TelnetConstants.kt b/kotlin/mth-core/src/main/kotlin/mth/core/TelnetConstants.kt index 2291d19..c065701 100644 --- a/kotlin/mth-core/src/main/kotlin/mth/core/TelnetConstants.kt +++ b/kotlin/mth-core/src/main/kotlin/mth/core/TelnetConstants.kt @@ -35,6 +35,7 @@ object TelnetOption { const val CHARSET: Byte = 42 const val MSDP: Byte = 69 const val MSSP: Byte = 70 + const val MCCP1: Byte = 85.toByte() const val MCCP2: Byte = 86.toByte() const val MCCP3: Byte = 87.toByte() const val MSP: Byte = 90.toByte() 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 a6c9610..8208562 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 @@ -272,6 +272,11 @@ class TelnetClientSession( TeloptPattern(byteArrayOf(TC.IAC, TC.DO, TO.NAWS)) { s, _, _, _ -> s.processDoNaws(); 3 }, + // MCCP1 (option 85) uses a non-standard SB terminator: IAC SB 85 WILL SE + // where SE appears without a preceding IAC. Skip the 5-byte start sequence. + TeloptPattern(byteArrayOf(TC.IAC, TC.SB, TO.MCCP1, TC.WILL, TC.SE)) + { _, _, _, _ -> 5 }, + // EOR command (prompt marker) TeloptPattern(byteArrayOf(TC.IAC, TC.EOR)) { s, _, _, _ -> s.processEorCommand(); 2 }, 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 0395b20..795cd5e 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 @@ -25,6 +25,7 @@ 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 MCCP1: Byte = 85 private const val MCCP2: Byte = 86 private const val GMCP: Byte = 0xC9.toByte() @@ -523,4 +524,119 @@ class TelnetClientSessionTest { assertEquals(1, d.msspData.size) assertEquals("Adventure", d.msspData[0]["GENRE"]) } + + // -- MCCP1 non-standard SB terminator -- + + @Test fun mccp1StartSequenceSkipped() { + // MCCP1 uses IAC SB 85 WILL SE (non-standard: SE without preceding IAC). + // The parser must skip these 5 bytes without poisoning telbuf. + val (s, _) = makeSession() + val mccp1Start = byteArrayOf(IAC, SB, MCCP1, WILL, SE) + val text = textBytes("Hello") + val out = s.processInput(mccp1Start + text) + assertContentEquals(text, out, "MCCP1 start marker should be skipped, text should pass through") + } + + @Test fun mccp1StartSequenceBetweenMsspAndText() { + // Reproduces the nanvaent bug: decompressed data contains MSSP SB + MCCP1 start, + // followed by banner text in the next chunk. + val (s, d) = makeSession() + + // Chunk 1: MSSP subneg + MCCP1 start marker (no IAC SE terminator for MCCP1) + val MV: Byte = 1; val ML: Byte = 2 + val chunk1 = byteArrayOf(IAC, SB, MSSP, MV) + textBytes("NAME") + + byteArrayOf(ML) + textBytes("Nanvaent") + + byteArrayOf(IAC, SE) + + byteArrayOf(IAC, SB, MCCP1, WILL, SE) // MCCP1 start (non-standard terminator) + val out1 = s.processInput(chunk1) + assertTrue(out1.isEmpty(), "Chunk 1 should contain only telnet commands") + assertEquals(1, d.msspData.size) + assertEquals("Nanvaent", d.msspData[0]["NAME"]) + + // Chunk 2: plain text banner + val banner = textBytes("Enter your name: ") + val out2 = s.processInput(banner) + assertContentEquals(banner, out2, "Banner text should not be swallowed by incomplete MCCP1 SB") + } + + // -- Nanvaent Replay -- + + @Test fun nanvaentNegotiationAndMCCP2() { + val (s, d) = makeSession() + s.terminalType = "Wammer" + + // Round 1: DO TTYPE + s.processInput(bytes(0xFF, 0xFD, 0x18)) + assertTrue(d.allWrittenBytes.containsSequence(byteArrayOf(IAC, WILL, TTYPE))) + + // Round 2: DO NAWS, WILL MCCP2, DO MXP(91), WILL MSSP, WILL 93, DO NEW_ENVIRON + d.writtenChunks.clear() + s.processInput(bytes( + 0xFF, 0xFD, 0x1F, 0xFF, 0xFB, 0x56, 0xFF, 0xFD, 0x5B, 0xFF, 0xFB, 0x46, 0xFF, 0xFB, 0x5D, 0xFF, + 0xFD, 0x27 + )) + val r2 = d.allWrittenBytes + assertTrue(r2.containsSequence(byteArrayOf(IAC, WILL, NAWS))) + assertTrue(r2.containsSequence(byteArrayOf(IAC, DO, MCCP2))) + assertTrue(r2.containsSequence(byteArrayOf(IAC, WONT, 91.toByte()))) // MXP + assertTrue(r2.containsSequence(byteArrayOf(IAC, DO, MSSP))) + assertTrue(r2.containsSequence(byteArrayOf(IAC, DONT, 93.toByte()))) // unknown + assertTrue(r2.containsSequence(byteArrayOf(IAC, WONT, 39))) // NEW_ENVIRON + + // Round 3: SB TTYPE SEND + d.writtenChunks.clear() + s.processInput(bytes(0xFF, 0xFA, 0x18, 0x01, 0xFF, 0xF0)) + val ttypeResponse = byteArrayOf(IAC, SB, TTYPE, ENV_IS) + textBytes("Wammer") + byteArrayOf(IAC, SE) + assertContentEquals(ttypeResponse, d.allWrittenBytes) + + // Round 4: SB MCCP2 (compression starts) + d.writtenChunks.clear() + s.processInput(bytes(0xFF, 0xFA, 0x56, 0xFF, 0xF0)) + assertTrue(s.isMCCP2Active) + + // Round 5: compressed telnet data (MSSP + MCCP1 start) + val out5 = s.processInput(bytes( + 0x78, 0xDA, 0xFA, 0xFF, 0xCB, 0x0D, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x62, 0xF4, 0x73, 0xF4, 0x75, + 0x65, 0xF2, 0x4B, 0xCC, 0x2B, 0x4B, 0x4C, 0xCD, 0x2B, 0x01, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x62, + 0x0C, 0xF0, 0x71, 0x8C, 0x74, 0x0D, 0x0A, 0x66, 0xB2, 0x04, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x62, + 0x0C, 0x0D, 0x08, 0xF1, 0x04, 0xCA, 0x18, 0x9A, 0x9B, 0x1B, 0x5A, 0x98, 0x5B, 0x9A, 0x1A, 0x1A, + 0x01, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFA, 0xFF, 0x01, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFA, 0xFF, + 0x2B, 0xF4, 0xF7, 0x07, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF + )) + assertEquals(1, d.msspData.size, "MSSP data should be received from compressed stream") + assertEquals("Nanvaent", d.msspData[0]["NAME"]) + assertTrue(out5.isEmpty(), "Round 5 should contain only telnet commands") + + // Round 6: compressed banner text + val out6 = s.processInput(bytes( + 0xB4, 0x53, 0x3D, 0x6F, 0xC3, 0x20, 0x10, 0xDD, 0x2D, 0xF9, 0x3F, 0x9C, 0xB2, 0xA4, 0x1D, 0x12, + 0xF6, 0x48, 0x91, 0xDA, 0xC1, 0x5B, 0x87, 0x48, 0x55, 0x87, 0xAA, 0x54, 0x16, 0x49, 0x88, 0x4D, + 0x6B, 0x43, 0x64, 0x83, 0x2A, 0xFF, 0xFB, 0xDE, 0x61, 0x9C, 0x92, 0xC4, 0xAD, 0xBB, 0xE4, 0x84, + 0x6C, 0xEE, 0xE3, 0x3D, 0xB8, 0x07, 0xA4, 0x09, 0xBC, 0xAD, 0xD7, 0xEB, 0xC5, 0x99, 0x61, 0xE0, + 0x1D, 0xB2, 0x45, 0x2D, 0x54, 0x05, 0x2B, 0xD0, 0x81, 0xED, 0x61, 0x98, 0x2C, 0x4D, 0x53, 0xFC, + 0x82, 0x4A, 0x13, 0x24, 0x9C, 0x32, 0xC6, 0xFD, 0x6F, 0xBA, 0x92, 0x01, 0xF0, 0xEB, 0x68, 0x9E, + 0x4F, 0x22, 0x19, 0xAD, 0xC1, 0xC7, 0xE8, 0xA6, 0xA0, 0xAC, 0x5F, 0xF4, 0x12, 0x8B, 0x3E, 0x9B, + 0x80, 0x7A, 0xE4, 0x08, 0x96, 0xF5, 0xC8, 0x3C, 0x32, 0xEF, 0x84, 0x10, 0x16, 0xE4, 0xAC, 0x77, + 0x08, 0x1A, 0x65, 0x10, 0x78, 0x05, 0xF2, 0x86, 0x74, 0x77, 0x71, 0x62, 0x58, 0x97, 0xE6, 0xBC, + 0x77, 0x42, 0x13, 0xA7, 0x50, 0x0E, 0x43, 0x86, 0xE8, 0x91, 0x2A, 0xA2, 0xBE, 0x0F, 0x8D, 0x9D, + 0x38, 0xB9, 0xEF, 0x77, 0x80, 0x0D, 0x83, 0xE8, 0x4E, 0x21, 0xDA, 0xDE, 0x90, 0xF1, 0x9F, 0x0B, + 0xAE, 0x1F, 0x3E, 0x4E, 0x83, 0xF1, 0xBE, 0xA3, 0xC8, 0x89, 0x42, 0xD4, 0x6A, 0x9F, 0xA1, 0xFD, + 0xB1, 0x4B, 0x9E, 0x71, 0x9D, 0xE3, 0xB3, 0xE1, 0x91, 0xCE, 0xFF, 0xC3, 0x04, 0x54, 0xC0, 0xBC, + 0xB4, 0x12, 0xE6, 0x85, 0x93, 0xAD, 0x9D, 0x83, 0x3A, 0xFC, 0x71, 0xF7, 0xBC, 0x04, 0xCC, 0xDF, + 0xDC, 0xCE, 0x38, 0xF8, 0x70, 0xAD, 0x85, 0x2F, 0xA1, 0x2D, 0x58, 0x13, 0xC4, 0x65, 0x30, 0x02, + 0x22, 0x32, 0xC2, 0x54, 0xC6, 0x7C, 0x82, 0x68, 0x8C, 0xD3, 0xFB, 0xE5, 0xC4, 0x0D, 0xF6, 0xA7, + 0x15, 0x5E, 0x93, 0x95, 0x95, 0x96, 0x16, 0xCE, 0x9E, 0xDF, 0xA4, 0x95, 0xD6, 0x1E, 0x57, 0x8C, + 0xC5, 0x20, 0x76, 0x83, 0xC7, 0xFE, 0x6A, 0x5C, 0x03, 0x8D, 0x2A, 0x4A, 0xDB, 0x42, 0x2D, 0x3A, + 0xD8, 0x4A, 0xD8, 0xAB, 0x76, 0x57, 0x09, 0x55, 0x4B, 0x6C, 0x72, 0x53, 0x49, 0x81, 0xE2, 0xDA, + 0xEE, 0x28, 0x61, 0x56, 0xCA, 0xEA, 0x18, 0x6A, 0x67, 0x20, 0x0E, 0x56, 0x36, 0xA8, 0x48, 0xA1, + 0xF4, 0x32, 0x4D, 0x36, 0x4F, 0xD9, 0xE3, 0x73, 0x06, 0x8E, 0x6A, 0x4B, 0x3C, 0x8C, 0xAD, 0x2B, + 0xE6, 0xB0, 0x33, 0x75, 0x2D, 0xF4, 0x9E, 0xC4, 0xC5, 0x3A, 0xC0, 0x58, 0xEB, 0x65, 0x3F, 0x28, + 0xD4, 0x2F, 0x4D, 0x32, 0x4D, 0x0C, 0x1D, 0x6D, 0x40, 0x8B, 0x5A, 0xAE, 0xE0, 0x1B, 0x00, 0x00, + 0xFF, 0xFF + )) + val bannerText = String(out6, Charsets.UTF_8) + assertTrue(out6.isNotEmpty(), "Banner should not be empty") + assertTrue(bannerText.contains("Enter your name:"), "Banner should contain login prompt") + assertTrue(bannerText.contains("nanvaent.org"), "Banner should contain server name") + } } From e8da8d264ef0d0bfa2e28476bd897f86fde7f1c2 Mon Sep 17 00:00:00 2001 From: James Power Date: Sun, 29 Mar 2026 23:32:05 -0400 Subject: [PATCH 3/3] Make CZlib dependency unconditional to fix Xcode canImport resolution. --- Package.swift | 2 +- Sources/SwiftMTHClient/TelnetClientSession.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index 5f89e37..f00ec2e 100644 --- a/Package.swift +++ b/Package.swift @@ -20,7 +20,7 @@ var targets: [Target] = [ .target( name: "MTHCore", dependencies: [ - .target(name: "CZlib", condition: .when(platforms: [.macOS, .linux, .iOS, .visionOS])), + "CZlib", ], path: "Sources/SwiftMTHCore" ), diff --git a/Sources/SwiftMTHClient/TelnetClientSession.swift b/Sources/SwiftMTHClient/TelnetClientSession.swift index 8f61ebc..b35ee61 100644 --- a/Sources/SwiftMTHClient/TelnetClientSession.swift +++ b/Sources/SwiftMTHClient/TelnetClientSession.swift @@ -344,7 +344,7 @@ public final class TelnetClientSession { private func processSbMccp2() { guard let stream = InflateStream() else { - log("MCCP2: Failed to initialize inflate stream.") + log("MCCP2: Failed to initialize inflate stream. InflateStream() returned nil.") return } mccp2 = stream