diff --git a/j3270/src/main/java/haus/nightmare/j3270/J3270App.java b/j3270/src/main/java/haus/nightmare/j3270/J3270App.java index e15401f..7bfe765 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/J3270App.java +++ b/j3270/src/main/java/haus/nightmare/j3270/J3270App.java @@ -28,6 +28,9 @@ import java.util.logging.*; public class J3270App extends JFrame implements ConnectionListener, ScreenUpdateListener { private static final Logger log = Logger.getLogger(J3270App.class.getName()); + private static final Logger PKG_LOG_ROOT = Logger.getLogger("haus.nightmare"); + private static final Logger PKG_LOG_APP = Logger.getLogger("haus.nightmare.j3270"); + private static final Logger PKG_LOG_LIB = Logger.getLogger("haus.nightmare.lib3270j"); // Active open windows tracking private static final java.util.List openWindows = new java.util.concurrent.CopyOnWriteArrayList<>(); @@ -1462,9 +1465,9 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate consoleHandler.setFilter(appFilter); globalRoot.addHandler(consoleHandler); - Logger.getLogger("haus.nightmare").setLevel(Level.ALL); - Logger.getLogger("haus.nightmare.j3270").setLevel(Level.ALL); - Logger.getLogger("haus.nightmare.lib3270j").setLevel(Level.ALL); + PKG_LOG_ROOT.setLevel(Level.ALL); + PKG_LOG_APP.setLevel(Level.ALL); + PKG_LOG_LIB.setLevel(Level.ALL); try { java.util.logging.FileHandler fileHandler = new java.util.logging.FileHandler("j3270.log", 10 * 1024 * 1024, 1, false) { diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ConnectionConfig.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ConnectionConfig.java index 3c644da..8b0f0d8 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/ConnectionConfig.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ConnectionConfig.java @@ -69,6 +69,10 @@ public class ConnectionConfig { private int reconnectMaxRetries = 5; private int tcpUserTimeoutMs = 0; + // Framing buffer sanity limits (RFC 885 / RFC 2355 EOR protection) + public static final int DEFAULT_MAX_FRAMING_BUFFER_SIZE = 1048576; // 1MB (supports dense GDDM graphics, symbol sets, and DFT transfers) + private int maxFramingBufferSize = DEFAULT_MAX_FRAMING_BUFFER_SIZE; + // Host operating system type (TSO, CMS, CICS) private String hostType = "TSO"; private boolean savedConnection = false; @@ -549,6 +553,11 @@ public class ConnectionConfig { public String getAssociatedPrinterLu() { return associatedPrinterLu; } public void setAssociatedPrinterLu(String printerLu) { this.associatedPrinterLu = printerLu; } + public int getMaxFramingBufferSize() { return maxFramingBufferSize; } + public void setMaxFramingBufferSize(int maxFramingBufferSize) { + this.maxFramingBufferSize = maxFramingBufferSize > 0 ? maxFramingBufferSize : DEFAULT_MAX_FRAMING_BUFFER_SIZE; + } + public haus.nightmare.lib3270j.printer.PrinterConfig toPrinterConfig() { haus.nightmare.lib3270j.printer.PrinterConfig pcfg = new haus.nightmare.lib3270j.printer.PrinterConfig(host, port); pcfg.setUseTls(useTls); @@ -559,6 +568,7 @@ public class ConnectionConfig { pcfg.setPrinterLuName(associatedPrinterLu); pcfg.setAssociatedDisplayLuName(luName); pcfg.setCodePage(codePage); + pcfg.setMaxFramingBufferSize(maxFramingBufferSize); return pcfg; } } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/FastByteBuffer.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/FastByteBuffer.java index 752c9e8..0d8d431 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/FastByteBuffer.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/FastByteBuffer.java @@ -17,21 +17,49 @@ import java.util.Arrays; */ public class FastByteBuffer extends OutputStream { + public static final int DEFAULT_INITIAL_CAPACITY = 32768; + public static final int DEFAULT_MAX_CAPACITY = 1048576; // 1MB (matches modern x3270 PDS_MAX for dense GDDM / GOCA vector graphics) + + private final int initialCapacity; + private int maxCapacity; private byte[] buf; private int count; public FastByteBuffer() { - this(32768); + this(DEFAULT_INITIAL_CAPACITY, DEFAULT_MAX_CAPACITY); } public FastByteBuffer(int initialCapacity) { - this.buf = new byte[Math.max(32, initialCapacity)]; + this(initialCapacity, Math.max(initialCapacity, DEFAULT_MAX_CAPACITY)); + } + + public FastByteBuffer(int initialCapacity, int maxCapacity) { + this.initialCapacity = Math.max(32, initialCapacity); + this.maxCapacity = Math.max(this.initialCapacity, maxCapacity); + this.buf = new byte[this.initialCapacity]; this.count = 0; } + public int getMaxCapacity() { + return maxCapacity; + } + + public void setMaxCapacity(int maxCapacity) { + this.maxCapacity = Math.max(this.initialCapacity, maxCapacity); + } + + public int getInitialCapacity() { + return initialCapacity; + } + private void ensureCapacity(int minCapacity) { + if (minCapacity > maxCapacity) { + throw new FramingBufferOverflowException( + "FastByteBuffer exceeded maximum capacity of " + maxCapacity + " bytes (requested: " + minCapacity + ")" + ); + } if (minCapacity > buf.length) { - int newCap = Math.max(buf.length << 1, minCapacity); + int newCap = Math.min(maxCapacity, Math.max(buf.length << 1, minCapacity)); buf = Arrays.copyOf(buf, newCap); } } @@ -64,6 +92,9 @@ public class FastByteBuffer extends OutputStream { public void reset() { count = 0; + if (buf.length > maxCapacity) { + buf = new byte[maxCapacity]; + } } /** diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/FramingBufferOverflowException.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/FramingBufferOverflowException.java new file mode 100644 index 0000000..5bb386d --- /dev/null +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/FramingBufferOverflowException.java @@ -0,0 +1,16 @@ +package haus.nightmare.lib3270j.datastream; + +/** + * Thrown when an inbound framing buffer exceeds its maximum configured capacity + * (typically 64KB) without encountering an end-of-record delimiter (IAC EOR). + */ +public class FramingBufferOverflowException extends RuntimeException { + + public FramingBufferOverflowException(String message) { + super(message); + } + + public FramingBufferOverflowException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTDft.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTDft.java index f9d595f..391a705 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTDft.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ft/FTDft.java @@ -50,6 +50,7 @@ public class FTDft { private int pendingByte = -1; // Record formatting buffers and SO/SI tracking + private static final int MAX_DFT_RECORD_BUFFER_SIZE = 131072; // 128KB sanity cap private final ByteArrayOutputStream downloadRecordBuffer = new ByteArrayOutputStream(); private boolean dbcsMode = false; private boolean hasPendingByteInDBCS = false; @@ -549,9 +550,17 @@ public class FTDft { int lrecl = (config != null && config.getLrecl() > 0) ? config.getLrecl() : hostLrecl; if (isRecfmV) { + if (downloadRecordBuffer.size() + length > MAX_DFT_RECORD_BUFFER_SIZE) { + downloadRecordBuffer.reset(); + throw new IOException("FTDft download record buffer exceeded sanity limit of " + MAX_DFT_RECORD_BUFFER_SIZE + " bytes"); + } downloadRecordBuffer.write(data, offset, length); processVariableDownloadRecords(config); } else if (isRecfmF && lrecl > 0) { + if (downloadRecordBuffer.size() + length > MAX_DFT_RECORD_BUFFER_SIZE) { + downloadRecordBuffer.reset(); + throw new IOException("FTDft download record buffer exceeded sanity limit of " + MAX_DFT_RECORD_BUFFER_SIZE + " bytes"); + } downloadRecordBuffer.write(data, offset, length); processFixedDownloadRecords(lrecl, config); } else { diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/nvt/NvtProcessor.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/nvt/NvtProcessor.java index a966e0b..14c85c8 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/nvt/NvtProcessor.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/nvt/NvtProcessor.java @@ -38,6 +38,7 @@ public class NvtProcessor { private static final int STATE_OSC = 4; private static final int STATE_OSC_ESC = 5; + private static final int MAX_ESC_BUFFER_SIZE = 4096; private int parseState = STATE_NORMAL; private final ByteArrayOutputStream escBuffer = new ByteArrayOutputStream(); @@ -470,32 +471,51 @@ public class NvtProcessor { } else if (parseState == STATE_OSC) { if (b == 0x07) { // BEL terminator handleOSC(escBuffer.toByteArray()); + escBuffer.reset(); parseState = STATE_NORMAL; } else if (b == 0x1B) { // Possible ESC \ (ST) parseState = STATE_OSC_ESC; } else if (b == 0x9C) { // 8-bit ST handleOSC(escBuffer.toByteArray()); + escBuffer.reset(); parseState = STATE_NORMAL; } else { - escBuffer.write(b); + if (escBuffer.size() < MAX_ESC_BUFFER_SIZE) { + escBuffer.write(b); + } else { + escBuffer.reset(); + parseState = STATE_NORMAL; + } } } else if (parseState == STATE_OSC_ESC) { if (b == '\\') { // ESC \ (ST) terminator handleOSC(escBuffer.toByteArray()); + escBuffer.reset(); parseState = STATE_NORMAL; } else { // False alarm on ST, put ESC and byte into OSC buffer - escBuffer.write(0x1B); - escBuffer.write(b); - parseState = STATE_OSC; + if (escBuffer.size() + 2 <= MAX_ESC_BUFFER_SIZE) { + escBuffer.write(0x1B); + escBuffer.write(b); + parseState = STATE_OSC; + } else { + escBuffer.reset(); + parseState = STATE_NORMAL; + } } } else if (parseState == STATE_CSI) { - escBuffer.write(b); + if (escBuffer.size() < MAX_ESC_BUFFER_SIZE) { + escBuffer.write(b); + } // CSI final bytes are in the range 0x40..0x7E if (b >= 0x40 && b <= 0x7E) { byte[] seq = escBuffer.toByteArray(); + escBuffer.reset(); curAddr = processAnsiEscapeSequence(seq, curAddr, rows, cols); parseState = STATE_NORMAL; + } else if (escBuffer.size() >= MAX_ESC_BUFFER_SIZE) { + escBuffer.reset(); + parseState = STATE_NORMAL; } } } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/printer/PrinterConfig.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/printer/PrinterConfig.java index e6629d1..c402953 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/printer/PrinterConfig.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/printer/PrinterConfig.java @@ -54,6 +54,8 @@ public class PrinterConfig { private long autoFlushTimeoutMs = 2000; private int graphicErrorAction = PrinterConstants.GEA_NO_SUBSTITUTION; private char graphicErrorReplacementChar = '?'; + public static final int DEFAULT_MAX_FRAMING_BUFFER_SIZE = 1048576; // 1MB + private int maxFramingBufferSize = DEFAULT_MAX_FRAMING_BUFFER_SIZE; public PrinterConfig() { initDefaultChannelTable(); @@ -227,4 +229,9 @@ public class PrinterConfig { public int[] getChannelTable() { return channelTable.clone(); } + + public int getMaxFramingBufferSize() { return maxFramingBufferSize; } + public void setMaxFramingBufferSize(int maxFramingBufferSize) { + this.maxFramingBufferSize = maxFramingBufferSize > 0 ? maxFramingBufferSize : DEFAULT_MAX_FRAMING_BUFFER_SIZE; + } } diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/printer/Telnet3270EP.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/printer/Telnet3270EP.java index 2b92dd9..6830a66 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/printer/Telnet3270EP.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/printer/Telnet3270EP.java @@ -58,12 +58,17 @@ public class Telnet3270EP implements Runnable { private int lastResponseRequired = TN3270EConstants.RSF_NO_RESPONSE; // Buffer for Telnet streaming - private final ByteArrayOutputStream recordBuffer = new ByteArrayOutputStream(); + private static final int MAX_PRINTER_SUBNEG_SIZE = 8192; + private final int maxFramingBufferSize; + private boolean recordDiscardMode = false; + private final ByteArrayOutputStream recordBuffer; private final ByteArrayOutputStream subnegBuffer = new ByteArrayOutputStream(); private boolean inSubnegotiation = false; public Telnet3270EP(PrinterConfig config) { this.config = config != null ? config : new PrinterConfig(); + this.maxFramingBufferSize = this.config.getMaxFramingBufferSize(); + this.recordBuffer = new ByteArrayOutputStream(Math.min(32768, this.maxFramingBufferSize)); this.pd = new PD3270(this.config); this.translator = new EbcdicTranslator(this.config.getCodePage()); this.scs = new PrintSCS3270(this.config, this.pd, this.translator); @@ -73,6 +78,8 @@ public class Telnet3270EP implements Runnable { public Telnet3270EP(PrinterConfig config, PD3270 pd, EbcdicTranslator translator) { this.config = config != null ? config : new PrinterConfig(); + this.maxFramingBufferSize = this.config.getMaxFramingBufferSize(); + this.recordBuffer = new ByteArrayOutputStream(Math.min(32768, this.maxFramingBufferSize)); this.pd = pd != null ? pd : new PD3270(this.config); this.translator = translator != null ? translator : new EbcdicTranslator(this.config.getCodePage()); this.scs = new PrintSCS3270(this.config, this.pd, this.translator); @@ -191,6 +198,7 @@ public class Telnet3270EP implements Runnable { this.recordBuffer.reset(); this.subnegBuffer.reset(); this.inSubnegotiation = false; + this.recordDiscardMode = false; scs.resetSCSFormatDefaults(); printPs.erasePrintBuffer(); @@ -379,11 +387,19 @@ public class Telnet3270EP implements Runnable { processSubnegotiation(subnegBuffer.toByteArray()); subnegBuffer.reset(); } else if (idx < end && (data[idx] & 0xFF) == TelnetConstants.IAC) { - subnegBuffer.write(TelnetConstants.IAC); + if (subnegBuffer.size() < MAX_PRINTER_SUBNEG_SIZE) { + subnegBuffer.write(TelnetConstants.IAC); + } idx++; } } else { - subnegBuffer.write(b); + if (subnegBuffer.size() < MAX_PRINTER_SUBNEG_SIZE) { + subnegBuffer.write(b); + } else { + log.warning("Printer subnegotiation exceeded " + MAX_PRINTER_SUBNEG_SIZE + " bytes; aborting SB"); + subnegBuffer.reset(); + inSubnegotiation = false; + } } continue; } @@ -394,15 +410,18 @@ public class Telnet3270EP implements Runnable { switch (cmd) { case TelnetConstants.IAC: - recordBuffer.write(TelnetConstants.IAC); + appendPrinterByte(TelnetConstants.IAC); break; case TelnetConstants.SB: inSubnegotiation = true; subnegBuffer.reset(); break; case TelnetConstants.EOR: - processCompleteRecord(recordBuffer.toByteArray()); + if (!recordDiscardMode) { + processCompleteRecord(recordBuffer.toByteArray()); + } recordBuffer.reset(); + recordDiscardMode = false; break; case TelnetConstants.DO: if (idx < end) handleTelnetDo(data[idx++] & 0xFF); @@ -420,11 +439,33 @@ public class Telnet3270EP implements Runnable { break; } } else { - recordBuffer.write(b); + appendPrinterByte(b); } } } + private void appendPrinterByte(int b) { + if (recordDiscardMode) { + return; + } + if (recordBuffer.size() >= maxFramingBufferSize) { + log.warning("Printer record buffer exceeded maximum capacity (" + maxFramingBufferSize + + " bytes) without IAC EOR; discarding until next EOR"); + recordDiscardMode = true; + recordBuffer.reset(); + return; + } + recordBuffer.write(b); + } + + public boolean isRecordDiscardMode() { + return recordDiscardMode; + } + + public int getMaxFramingBufferSize() { + return maxFramingBufferSize; + } + private void handleTelnetDo(int option) { try { if (option == TelnetConstants.TELOPT_TN3270E) { diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetFSM.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetFSM.java index 1f4663e..6b20bcd 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetFSM.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetFSM.java @@ -46,8 +46,14 @@ public class TelnetFSM { private final boolean[] myOpts = new boolean[256]; // options we have enabled private final boolean[] hisOpts = new boolean[256]; // options the host has enabled - // 3270 input buffer (accumulated between telnet framing) - private final haus.nightmare.lib3270j.datastream.FastByteBuffer ibuf = new haus.nightmare.lib3270j.datastream.FastByteBuffer(32768); + // 3270 input buffer (accumulated between telnet framing, bounded at maxFramingBufferSize) + private final haus.nightmare.lib3270j.datastream.FastByteBuffer ibuf; + + // Discard mode if framing buffer overflows prior to receiving IAC EOR + private boolean recordDiscardMode = false; + + // Sanity cap for Telnet subnegotiation parameters (RFC 1091, RFC 1572, RFC 2355) + private static final int MAX_SUBNEG_BUFFER_SIZE = 8192; // Reusable single-byte scratch buffer for plain NVT data dispatch (Phase 27.5 / T1-C) private final byte[] nvtSingleByteScratch = new byte[1]; @@ -165,6 +171,8 @@ public class TelnetFSM { this.config = config; this.screenBuffer = screenBuffer; this.dsProcessor = dsProcessor; + int maxFraming = (config != null) ? config.getMaxFramingBufferSize() : ConnectionConfig.DEFAULT_MAX_FRAMING_BUFFER_SIZE; + this.ibuf = new haus.nightmare.lib3270j.datastream.FastByteBuffer(Math.min(32768, maxFraming), maxFraming); this.nvtProcessor = new haus.nightmare.lib3270j.nvt.NvtProcessor(screenBuffer, new haus.nightmare.lib3270j.charset.EbcdicTranslator()); this.nvtProcessor.setOutputSender(this::sendNvtData); } @@ -220,6 +228,7 @@ public class TelnetFSM { deferredWillTtype = false; ttypeIndex = 0; luIndex = 0; + recordDiscardMode = false; ibuf.reset(); sbbuf.reset(); @@ -256,7 +265,7 @@ public class TelnetFSM { if (isPlainNvt) { nvtProcessor.processNVTData(buf, start, i - start); } else if (connectionState.is3270() || connectionState.isTn3270e() || connectionState == ConnectionState.TELNET_PENDING || hisOpts[TELOPT_BINARY] || hisOpts[TELOPT_EOR]) { - ibuf.write(buf, start, i - start); + appendInboundData(buf, start, i - start); } } if (i < end) { @@ -334,24 +343,74 @@ public class TelnetFSM { // Accumulate data for 3270, TN3270E (including CONNECTED_E_NVT, SSCP-LU and unbound states, and pending) if (connectionState.is3270() || connectionState.isTn3270e() || connectionState == ConnectionState.TELNET_PENDING || hisOpts[TELOPT_BINARY] || hisOpts[TELOPT_EOR]) { - ibuf.write(c); + appendInboundByte(c); } } + private void appendInboundByte(int c) { + if (recordDiscardMode) { + return; + } + if (ibuf.size() >= ibuf.getMaxCapacity()) { + handleFramingOverflow(); + return; + } + try { + ibuf.write(c); + } catch (haus.nightmare.lib3270j.datastream.FramingBufferOverflowException e) { + handleFramingOverflow(); + } + } + + private void appendInboundData(byte[] buf, int offset, int len) { + if (recordDiscardMode || buf == null || len <= 0) { + return; + } + if (ibuf.size() + len > ibuf.getMaxCapacity()) { + handleFramingOverflow(); + return; + } + try { + ibuf.write(buf, offset, len); + } catch (haus.nightmare.lib3270j.datastream.FramingBufferOverflowException e) { + handleFramingOverflow(); + } + } + + private void handleFramingOverflow() { + log.warning("Inbound 3270 framing buffer exceeded maximum capacity (" + ibuf.getMaxCapacity() + + " bytes) without IAC EOR; discarding record until next EOR"); + recordDiscardMode = true; + ibuf.reset(); + } + + public boolean isRecordDiscardMode() { + return recordDiscardMode; + } + + public int getMaxFramingBufferSize() { + return ibuf.getMaxCapacity(); + } + + public int getCurrentFramingBufferSize() { + return ibuf.size(); + } + // ========== TNS_IAC ========== private void processIAC(int c) { switch (c) { case IAC: // Escaped IAC — literal 0xFF - ibuf.write(c); + appendInboundByte(c); state = TNS_DATA; break; case EOR: // End of record — process accumulated 3270 data log.fine("RCVD EOR"); - if (connectionState.is3270() || connectionState.isTn3270e() || connectionState == ConnectionState.TELNET_PENDING) { + if (!recordDiscardMode && (connectionState.is3270() || connectionState.isTn3270e() || connectionState == ConnectionState.TELNET_PENDING)) { processEndOfRecord(); } ibuf.reset(); + recordDiscardMode = false; state = TNS_DATA; break; case WILL: state = TNS_WILL; break; @@ -527,7 +586,14 @@ public class TelnetFSM { if (c == IAC) { state = TNS_SB_IAC; } else { - sbbuf.write(c); + if (sbbuf.size() < MAX_SUBNEG_BUFFER_SIZE) { + sbbuf.write(c); + } else { + log.warning("Telnet subnegotiation exceeded " + MAX_SUBNEG_BUFFER_SIZE + + " bytes without IAC SE; discarding and aborting SB"); + sbbuf.reset(); + state = TNS_DATA; + } } } @@ -542,7 +608,9 @@ public class TelnetFSM { state = TNS_DATA; } else if (c == IAC) { // Escaped IAC within sub-negotiation - sbbuf.write(IAC); + if (sbbuf.size() < MAX_SUBNEG_BUFFER_SIZE) { + sbbuf.write(IAC); + } state = TNS_SB; } else { // Shouldn't happen, but recover @@ -1634,6 +1702,7 @@ public class TelnetFSM { state = TNS_DATA; java.util.Arrays.fill(myOpts, false); java.util.Arrays.fill(hisOpts, false); + recordDiscardMode = false; ibuf.reset(); sbbuf.reset(); tn3270eNegotiated = false; diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/datastream/FastByteBufferTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/datastream/FastByteBufferTest.java index 25a08c5..6f50504 100644 --- a/lib3270j/src/test/java/haus/nightmare/lib3270j/datastream/FastByteBufferTest.java +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/datastream/FastByteBufferTest.java @@ -151,4 +151,39 @@ public class FastByteBufferTest { executor.shutdown(); assertEquals(0, failures.get(), "No failures occurred during concurrent pool operations"); } + + @Test + @DisplayName("FastByteBuffer enforces maxCapacity and throws FramingBufferOverflowException") + public void testFastByteBufferMaxCapacityEnforcement() { + FastByteBuffer buf = new FastByteBuffer(64, 128); + assertEquals(128, buf.getMaxCapacity()); + assertEquals(64, buf.getInitialCapacity()); + + byte[] chunk = new byte[64]; + buf.write(chunk, 0, 64); + assertEquals(64, buf.size()); + + buf.write(chunk, 0, 64); + assertEquals(128, buf.size()); + + // Writing one more byte must throw FramingBufferOverflowException + assertThrows(FramingBufferOverflowException.class, () -> buf.write(0xFF)); + + // Writing a chunk that exceeds capacity must also throw + assertThrows(FramingBufferOverflowException.class, () -> buf.write(chunk, 0, 10)); + + // After reset, buffer is ready for use again + buf.reset(); + assertEquals(0, buf.size()); + buf.write(chunk, 0, 64); + assertEquals(64, buf.size()); + } + + @Test + @DisplayName("FastByteBuffer default maxCapacity is 1048576 bytes (1MB)") + public void testDefaultMaxCapacity() { + FastByteBuffer buf = new FastByteBuffer(); + assertEquals(FastByteBuffer.DEFAULT_MAX_CAPACITY, buf.getMaxCapacity()); + assertEquals(1048576, buf.getMaxCapacity()); + } } diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/telnet/FramingBufferBoundedCapTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/telnet/FramingBufferBoundedCapTest.java new file mode 100644 index 0000000..577289f --- /dev/null +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/telnet/FramingBufferBoundedCapTest.java @@ -0,0 +1,223 @@ +package haus.nightmare.lib3270j.telnet; + +import haus.nightmare.lib3270j.ConnectionConfig; +import haus.nightmare.lib3270j.ConnectionState; +import haus.nightmare.lib3270j.TerminalModel; +import haus.nightmare.lib3270j.charset.EbcdicTranslator; +import haus.nightmare.lib3270j.datastream.DataStreamProcessor; +import haus.nightmare.lib3270j.nvt.NvtProcessor; +import haus.nightmare.lib3270j.printer.PrinterConfig; +import haus.nightmare.lib3270j.printer.Telnet3270EP; +import haus.nightmare.lib3270j.protocol.TelnetConstants; +import haus.nightmare.lib3270j.screen.ScreenBuffer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static haus.nightmare.lib3270j.protocol.TelnetConstants.*; +import static org.junit.jupiter.api.Assertions.*; + +public class FramingBufferBoundedCapTest { + + 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); + haus.nightmare.lib3270j.input.InputProcessor inputProcessor = + new haus.nightmare.lib3270j.input.InputProcessor(screenBuffer, new EbcdicTranslator(), fsm); + dsProcessor.setInputProcessor(inputProcessor); + connection = new MockConnection(config, fsm); + fsm.setConnection(connection); + } + + private void feedBytes(int... bytes) { + byte[] b = new byte[bytes.length]; + for (int i = 0; i < bytes.length; i++) { + b[i] = (byte) bytes[i]; + } + fsm.feedBytes(b, 0, b.length); + } + + @Test + @DisplayName("Framing buffer caps at 1MB and enters discard mode if stream lacks IAC EOR") + public void testFramingBufferOverflowDiscardsUntilEor() { + fsm.onConnected(); + + // Negotiate BINARY and EOR to establish standard 3270 streaming regime + feedBytes(IAC, DO, TELOPT_EOR); + feedBytes(IAC, WILL, TELOPT_EOR); + feedBytes(IAC, DO, TELOPT_BINARY); + feedBytes(IAC, WILL, TELOPT_BINARY); + + assertEquals(1048576, fsm.getMaxFramingBufferSize(), "Default framing buffer cap should be 1MB (1048576 bytes)"); + assertFalse(fsm.isRecordDiscardMode()); + + // Feed chunks totaling over 1MB without any IAC EOR delimiter + byte[] chunk = new byte[8192]; + Arrays.fill(chunk, (byte) 0xC1); // EBCDIC 'A' + + for (int i = 0; i < 128; i++) { + fsm.feedBytes(chunk, 0, chunk.length); // 128 * 8192 = 1,048,576 bytes + } + assertFalse(fsm.isRecordDiscardMode(), "Buffer should be at exact cap without overflowing yet"); + + // Now feed an additional chunk exceeding the 1MB cap without IAC EOR + fsm.feedBytes(chunk, 0, 100); + + assertTrue(fsm.isRecordDiscardMode(), "FSM must enter recordDiscardMode when cap is exceeded without IAC EOR"); + assertEquals(0, fsm.getCurrentFramingBufferSize(), "Buffer must be reset to free memory upon overflow"); + + // Further incoming data without IAC EOR must be discarded without growing the buffer + fsm.feedBytes(chunk, 0, chunk.length); + assertEquals(0, fsm.getCurrentFramingBufferSize(), "Discard mode must drop subsequent data without accumulating memory"); + assertTrue(fsm.isRecordDiscardMode()); + + // Feeding IAC EOR must clear discard mode and reset buffer cleanly + feedBytes(IAC, EOR); + assertFalse(fsm.isRecordDiscardMode(), "IAC EOR must clear recordDiscardMode"); + assertEquals(0, fsm.getCurrentFramingBufferSize()); + + // A subsequent valid record must be processed normally + // 0xF5 (Erase/Write), 0xC2 (WCC: unlock keyboard), 0x11 (SBA), 0x00, 0x00, 0xC1 ('A'), IAC, EOR + byte[] valid3270 = new byte[] { (byte) 0xF5, (byte) 0xC2, (byte) 0x11, 0x00, 0x00, (byte) 0xC1, (byte) IAC, (byte) EOR }; + fsm.feedBytes(valid3270, 0, valid3270.length); + + assertEquals('A', screenBuffer.getDisplayCell(0).ucs4); + } + + @Test + @DisplayName("Custom maxFramingBufferSize on ConnectionConfig is respected by TelnetFSM") + public void testCustomMaxFramingBufferSize() { + ConnectionConfig customConfig = new ConnectionConfig("localhost", 23, TerminalModel.IBM_3279_4); + customConfig.setMaxFramingBufferSize(1024); // 1KB custom limit + + TelnetFSM customFsm = new TelnetFSM(customConfig, screenBuffer, dsProcessor); + customFsm.setConnection(connection); + customFsm.onConnected(); + + assertEquals(1024, customFsm.getMaxFramingBufferSize()); + + // Negotiate BINARY and EOR + byte[] neg = new byte[] { + (byte) IAC, (byte) DO, (byte) TELOPT_EOR, + (byte) IAC, (byte) WILL, (byte) TELOPT_EOR, + (byte) IAC, (byte) DO, (byte) TELOPT_BINARY, + (byte) IAC, (byte) WILL, (byte) TELOPT_BINARY + }; + customFsm.feedBytes(neg, 0, neg.length); + + byte[] payload500 = new byte[500]; + Arrays.fill(payload500, (byte) 0xC1); + customFsm.feedBytes(payload500, 0, 500); + assertEquals(500, customFsm.getCurrentFramingBufferSize()); + assertFalse(customFsm.isRecordDiscardMode()); + + customFsm.feedBytes(payload500, 0, 500); + assertEquals(1000, customFsm.getCurrentFramingBufferSize()); + assertFalse(customFsm.isRecordDiscardMode()); + + // Overflow over 1024 limit + customFsm.feedBytes(payload500, 0, 100); + assertTrue(customFsm.isRecordDiscardMode()); + assertEquals(0, customFsm.getCurrentFramingBufferSize()); + } + + @Test + @DisplayName("Telnet subnegotiation buffer caps at 8KB if stream lacks IAC SE") + public void testSubnegotiationBufferCap() { + fsm.onConnected(); + + // Begin subnegotiation: IAC SB TELOPT_TTYPE + feedBytes(IAC, SB, TELOPT_TTYPE); + + // Feed > 8192 bytes without IAC SE + byte[] largeSb = new byte[8200]; + Arrays.fill(largeSb, (byte) 'X'); + fsm.feedBytes(largeSb, 0, largeSb.length); + + // Feed a regular byte: should be back in TNS_DATA, not accumulating in SB + feedBytes(IAC, NOP); + // Ensure no exception and session remains operational + assertTrue(true); + } + + @Test + @DisplayName("Printer framing buffer caps at 1MB and enters discard mode without IAC EOR") + public void testPrinterFramingBufferCap() throws Exception { + PrinterConfig pcfg = new PrinterConfig("localhost", 23); + assertEquals(1048576, pcfg.getMaxFramingBufferSize()); + + Telnet3270EP printer = new Telnet3270EP(pcfg); + assertEquals(1048576, printer.getMaxFramingBufferSize()); + assertFalse(printer.isRecordDiscardMode()); + + Method processIncoming = Telnet3270EP.class.getDeclaredMethod("processIncomingBytes", byte[].class, int.class, int.class); + processIncoming.setAccessible(true); + + byte[] chunk = new byte[32768]; + Arrays.fill(chunk, (byte) 0x40); + + for (int i = 0; i < 33; i++) { + processIncoming.invoke(printer, chunk, 0, chunk.length); // 33 * 32768 = 1,081,344 bytes > 1,048,576 + } + + assertTrue(printer.isRecordDiscardMode(), "Printer must enter discard mode when record exceeds 1MB"); + + // Send IAC EOR + byte[] eor = new byte[] { (byte) IAC, (byte) EOR }; + processIncoming.invoke(printer, eor, 0, eor.length); + + assertFalse(printer.isRecordDiscardMode(), "Printer must clear discard mode on IAC EOR"); + } + + @Test + @DisplayName("NvtProcessor caps escape sequence buffer at 4KB if terminator is missing") + public void testNvtProcessorEscapeBufferCap() { + NvtProcessor nvt = fsm.getNvtProcessor(); + + // Stream ESC [ followed by 5000 digits without terminating byte (0x40..0x7E) + byte[] escStart = new byte[] { 0x1B, '[' }; + nvt.processNVTData(escStart, 0, escStart.length); + + byte[] digits = new byte[5000]; + Arrays.fill(digits, (byte) '1'); + nvt.processNVTData(digits, 0, digits.length); + + // After escBuffer reaches 4096 bytes, it aborts escape parsing and returns to STATE_NORMAL. + // Subsequent digits are rendered as plain text in the screen buffer starting at cell 0. + assertEquals('1', screenBuffer.getCell(0).ucs4); + } +}