diff --git a/lib3270j/src/test/java/org/lib3270j/telnet/TelnetFSMTest.java b/lib3270j/src/test/java/org/lib3270j/telnet/TelnetFSMTest.java new file mode 100644 index 0000000..4d25ece --- /dev/null +++ b/lib3270j/src/test/java/org/lib3270j/telnet/TelnetFSMTest.java @@ -0,0 +1,222 @@ +package org.lib3270j.telnet; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.lib3270j.ConnectionConfig; +import org.lib3270j.ConnectionState; +import org.lib3270j.TerminalModel; +import org.lib3270j.charset.EbcdicTranslator; +import org.lib3270j.datastream.DataStreamProcessor; +import org.lib3270j.protocol.TelnetConstants; +import org.lib3270j.screen.ScreenBuffer; + +import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +public class TelnetFSMTest { + + private ConnectionConfig config; + private ScreenBuffer screenBuffer; + private DataStreamProcessor dsProcessor; + private TelnetFSM fsm; + private MockConnection connection; + + private static class MockConnection extends TelnetConnection { + final List sentData = new ArrayList<>(); + + MockConnection(ConnectionConfig config, TelnetFSM fsm) { + super(config, fsm); + } + + @Override + public synchronized void sendRaw(byte[] data) { + sentData.add(data.clone()); + } + + @Override + public synchronized void sendRaw(byte[] data, int offset, int length) { + byte[] b = new byte[length]; + System.arraycopy(data, offset, b, 0, length); + sentData.add(b); + } + } + + @BeforeEach + public void setup() { + config = new ConnectionConfig("localhost", 23, TerminalModel.IBM_3279_4); + screenBuffer = new ScreenBuffer(TerminalModel.IBM_3279_4, new EbcdicTranslator()); + dsProcessor = new DataStreamProcessor(screenBuffer, new EbcdicTranslator()); + fsm = new TelnetFSM(config, screenBuffer, dsProcessor); + connection = new MockConnection(config, fsm); + fsm.setConnection(connection); + } + + private void feedBytes(int... bytes) { + for (int b : bytes) { + fsm.feedByte(b & 0xFF); + } + } + + @Test + public void testHostPrefixParsingPlainTn3270() { + ConnectionConfig c1 = ConnectionConfig.parseHostString("P:mainframe.example.com", 23, TerminalModel.IBM_3279_4); + assertFalse(c1.isTn3270eEnabled(), "P: prefix should disable TN3270E"); + assertFalse(c1.isUseTls()); + assertEquals("mainframe.example.com", c1.getHost()); + + ConnectionConfig c2 = ConnectionConfig.parseHostString("plain:zos.net:2323", 23, TerminalModel.IBM_3279_4); + assertFalse(c2.isTn3270eEnabled()); + assertEquals("zos.net", c2.getHost()); + assertEquals(2323, c2.getPort()); + + ConnectionConfig c3 = ConnectionConfig.parseHostString("L:P:secure.mainframe.com:992", 0, TerminalModel.IBM_3279_4); + assertTrue(c3.isUseTls(), "L: should enable TLS"); + assertFalse(c3.isTn3270eEnabled(), "P: should disable TN3270E"); + assertEquals("secure.mainframe.com", c3.getHost()); + assertEquals(992, c3.getPort()); + + ConnectionConfig c4 = ConnectionConfig.parseHostString("non-e:vm.ibm.com", 23, TerminalModel.IBM_3279_4); + assertFalse(c4.isTn3270eEnabled()); + assertEquals("vm.ibm.com", c4.getHost()); + } + + @Test + public void testPlainTn3270NegotiationRejectsTn3270E() { + config.setTn3270eEnabled(false); + fsm.onConnected(); + assertEquals(ConnectionState.TELNET_PENDING, fsm.getConnectionState()); + + // Host offers DO TN3270E -> client must reply WONT TN3270E + feedBytes(TelnetConstants.IAC, TelnetConstants.DO, TelnetConstants.TELOPT_TN3270E); + assertFalse(fsm.getMyOpts()[TelnetConstants.TELOPT_TN3270E]); + + // Host offers WILL TN3270E -> client must reply DONT TN3270E + feedBytes(TelnetConstants.IAC, TelnetConstants.WILL, TelnetConstants.TELOPT_TN3270E); + assertFalse(fsm.getHisOpts()[TelnetConstants.TELOPT_TN3270E]); + + // Host negotiates standard 3270 options: DO BINARY, WILL BINARY, DO EOR, WILL EOR, DO TTYPE + feedBytes(TelnetConstants.IAC, TelnetConstants.DO, TelnetConstants.TELOPT_BINARY); + feedBytes(TelnetConstants.IAC, TelnetConstants.WILL, TelnetConstants.TELOPT_BINARY); + feedBytes(TelnetConstants.IAC, TelnetConstants.DO, TelnetConstants.TELOPT_EOR); + feedBytes(TelnetConstants.IAC, TelnetConstants.WILL, TelnetConstants.TELOPT_EOR); + feedBytes(TelnetConstants.IAC, TelnetConstants.DO, TelnetConstants.TELOPT_TTYPE); + + // Client should have transitioned to CONNECTED_3270 + assertEquals(ConnectionState.CONNECTED_3270, fsm.getConnectionState()); + + // Host asks for TTYPE + feedBytes(TelnetConstants.IAC, TelnetConstants.SB, TelnetConstants.TELOPT_TTYPE, + TelnetConstants.TELQUAL_SEND, TelnetConstants.IAC, TelnetConstants.SE); + + // Verify sent packets contain WONT TN3270E, DONT TN3270E, and TTYPE IS + boolean foundWontTn3270e = false; + boolean foundTtypeIs = false; + for (byte[] pkt : connection.sentData) { + if (pkt.length == 3 && (pkt[0] & 0xFF) == TelnetConstants.IAC && + (pkt[1] & 0xFF) == TelnetConstants.WONT && (pkt[2] & 0xFF) == TelnetConstants.TELOPT_TN3270E) { + foundWontTn3270e = true; + } + if (pkt.length >= 4 && (pkt[0] & 0xFF) == TelnetConstants.IAC && + (pkt[1] & 0xFF) == TelnetConstants.SB && (pkt[2] & 0xFF) == TelnetConstants.TELOPT_TTYPE) { + foundTtypeIs = true; + } + } + assertTrue(foundWontTn3270e, "Expected WONT TN3270E response"); + assertTrue(foundTtypeIs, "Expected TTYPE IS response"); + } + + @Test + public void testTn3270eFunctionsNegotiationWithoutBindImageTransitionsToConnected() { + config.setTn3270eEnabled(true); + fsm.onConnected(); + assertEquals(ConnectionState.TELNET_PENDING, fsm.getConnectionState()); + + // Host offers DO TN3270E -> client accepts with WILL TN3270E + feedBytes(TelnetConstants.IAC, TelnetConstants.DO, TelnetConstants.TELOPT_TN3270E); + assertTrue(fsm.getMyOpts()[TelnetConstants.TELOPT_TN3270E]); + + // Host responds with DEVICE-TYPE IS IBM-3279-4-E CONNECT LU01 + byte[] devTypeIs = new byte[]{ + (byte) TelnetConstants.IAC, (byte) TelnetConstants.SB, (byte) TelnetConstants.TELOPT_TN3270E, + 0x02, 0x04, // OP_DEVICE_TYPE, OP_IS + 'I', 'B', 'M', '-', '3', '2', '7', '9', '-', '4', '-', 'E', + 0x01, // OP_CONNECT + 'L', 'U', '0', '1', + (byte) TelnetConstants.IAC, (byte) TelnetConstants.SE + }; + for (byte b : devTypeIs) fsm.feedByte(b & 0xFF); + + // Host responds with FUNCTIONS REQUEST (e.g. pytn3270 / RFC 2355 server) + byte[] funcsReq = new byte[]{ + (byte) TelnetConstants.IAC, (byte) TelnetConstants.SB, (byte) TelnetConstants.TELOPT_TN3270E, + 0x03, 0x07, // OP_FUNCTIONS, OP_REQUEST + (byte) TelnetConstants.IAC, (byte) TelnetConstants.SE + }; + for (byte b : funcsReq) fsm.feedByte(b & 0xFF); + + // Per RFC 2355: Client must reply with FUNCTIONS IS, and without BIND-IMAGE, session is bound immediately + assertEquals(ConnectionState.CONNECTED_TN3270E, fsm.getConnectionState(), + "Expected CONNECTED_TN3270E when BIND-IMAGE is not negotiated"); + + // Verify that client sent SB TN3270E FUNCTIONS IS SE + boolean foundFunctionsIs = false; + for (byte[] pkt : connection.sentData) { + if (pkt.length >= 5 && (pkt[0] & 0xFF) == TelnetConstants.IAC && + (pkt[1] & 0xFF) == TelnetConstants.SB && (pkt[2] & 0xFF) == TelnetConstants.TELOPT_TN3270E && + (pkt[3] & 0xFF) == 0x03 && (pkt[4] & 0xFF) == 0x04) { + foundFunctionsIs = true; + } + } + assertTrue(foundFunctionsIs, "Expected FUNCTIONS IS reply from client upon receiving FUNCTIONS REQUEST"); + } + + @Test + public void testPlainDataStreamFallbackInTn3270eMode() { + config.setTn3270eEnabled(true); + fsm.onConnected(); + + // Negotiate TN3270E with empty functions + feedBytes(TelnetConstants.IAC, TelnetConstants.DO, TelnetConstants.TELOPT_TN3270E); + byte[] devTypeIs = new byte[]{ + (byte) TelnetConstants.IAC, (byte) TelnetConstants.SB, (byte) TelnetConstants.TELOPT_TN3270E, + 0x02, 0x04, 'I', 'B', 'M', '-', '3', '2', '7', '9', '-', '4', '-', 'E', + (byte) TelnetConstants.IAC, (byte) TelnetConstants.SE + }; + for (byte b : devTypeIs) fsm.feedByte(b & 0xFF); + + byte[] funcsReq = new byte[]{ + (byte) TelnetConstants.IAC, (byte) TelnetConstants.SB, (byte) TelnetConstants.TELOPT_TN3270E, + 0x03, 0x07, (byte) TelnetConstants.IAC, (byte) TelnetConstants.SE + }; + for (byte b : funcsReq) fsm.feedByte(b & 0xFF); + assertEquals(ConnectionState.CONNECTED_TN3270E, fsm.getConnectionState()); + + // Now host sends raw 3270 data stream without 5-byte TN3270E header (e.g. EraseWrite 0xF5) + // 0xF5 = EraseWrite, 0xC3 = WCC, 0x11 = SBA, 0x40 0x40 = Pos 0, "HELLO" + EbcdicTranslator trans = new EbcdicTranslator(); + String msg = "HELLO"; + byte[] rawStream = new byte[3 + 2 + msg.length() + 2]; + rawStream[0] = (byte) 0xF5; // EraseWrite + rawStream[1] = (byte) 0xC3; // WCC + rawStream[2] = 0x11; // SBA + rawStream[3] = 0x40; rawStream[4] = 0x40; // addr 0 + for (int i = 0; i < msg.length(); i++) { + rawStream[5 + i] = (byte) trans.unicodeToEbcdic(msg.charAt(i)); + } + rawStream[rawStream.length - 2] = (byte) TelnetConstants.IAC; + rawStream[rawStream.length - 1] = (byte) TelnetConstants.EOR; + + for (byte b : rawStream) fsm.feedByte(b & 0xFF); + + // State must have automatically switched to CONNECTED_3270 and screen updated + assertEquals(ConnectionState.CONNECTED_3270, fsm.getConnectionState()); + assertEquals('H', trans.ebcdicToUnicode(screenBuffer.getCellEC(0))); + assertEquals('E', trans.ebcdicToUnicode(screenBuffer.getCellEC(1))); + assertEquals('L', trans.ebcdicToUnicode(screenBuffer.getCellEC(2))); + assertEquals('L', trans.ebcdicToUnicode(screenBuffer.getCellEC(3))); + assertEquals('O', trans.ebcdicToUnicode(screenBuffer.getCellEC(4))); + } +}