From ac27c1ed5c1e23a7f3a904300783b1c0cf40d7c5 Mon Sep 17 00:00:00 2001 From: Rudi Date: Mon, 19 Oct 2026 00:42:44 +0000 Subject: [PATCH] Patch memory hole cleaner, and bounce version --- README.md | 4 +- build.gradle | 2 +- .../java/haus/nightmare/j3270/Version.java | 4 +- .../j3270/Phase27BuildAndRenderTest.java | 14 ++--- .../j3270/Phase31AboutAndInterfaceTest.java | 16 ++--- .../nightmare/lib3270j/ConnectionConfig.java | 2 +- .../java/haus/nightmare/lib3270j/Version.java | 4 +- .../datastream/DataStreamProcessor.java | 62 ++++++++++++++----- .../lib3270j/datastream/FastByteBuffer.java | 2 +- .../nightmare/lib3270j/printer/PD3270.java | 16 ++++- .../lib3270j/printer/PrinterConfig.java | 2 +- .../lib3270j/printer/Telnet3270EP.java | 31 +++++++--- .../nightmare/lib3270j/telnet/TelnetFSM.java | 44 +++++++++---- .../Phase30LibraryDistributionTest.java | 16 ++--- .../datastream/FastByteBufferTest.java | 5 +- .../telnet/FramingBufferBoundedCapTest.java | 49 +++++++++------ setup.ps1 | 4 +- setup.sh | 4 +- 18 files changed, 183 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index 1ba3766..430a0ca 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ An **x3270-aligned and IBM Host On-Demand (HoD) compatible IBM 3270 mainframe te Ready-to-run executable JARs and standalone library packages are built and tested across all three Java LTS baselines (**Java 11, Java 17, and Java 21**) by Gitea Actions: - **[Download Latest Tagged Release (`j3270.jar`)](https://git.hugfreevikings.wtf/rudi/j3270/releases)** -- **Standalone Protocol Library (`lib3270j.jar`)**: Built by setup/build scripts and published to Maven (`haus.nightmare.j3270:lib3270j:1.2.4`). +- **Standalone Protocol Library (`lib3270j.jar`)**: Built by setup/build scripts and published to Maven (`haus.nightmare.j3270:lib3270j:1.3.0`). - **[Latest Nightly CI Artifacts](https://git.hugfreevikings.wtf/rudi/j3270/actions)**: Download standalone JARs tailored for each LTS runtime under the latest workflow run: - `j3270-java11-jar` (Java 11 LTS) - `j3270-java17-jar` (Java 17 LTS — primary Gitea runner baseline) @@ -126,7 +126,7 @@ java -jar j3270.jar -d mainframe.example.com - **Persistent Profiles**: Configuration profiles and session preferences saved in INI files (`j3270.ini`). ### 📦 Modular Zero-Dependency Core -- **`lib3270j`**: Standalone Java protocol and terminal engine with **zero third-party dependencies** (`java.base`, `java.desktop` only). Distributed as standalone JAR (`build/lib3270j.jar`) and published Maven coordinate `haus.nightmare.j3270:lib3270j:1.2.4`. +- **`lib3270j`**: Standalone Java protocol and terminal engine with **zero third-party dependencies** (`java.base`, `java.desktop` only). Distributed as standalone JAR (`build/lib3270j.jar`) and published Maven coordinate `haus.nightmare.j3270:lib3270j:1.3.0`. - **`j3270`**: Standalone desktop Swing terminal application. --- diff --git a/build.gradle b/build.gradle index 956032e..b498c43 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ allprojects { group = 'haus.nightmare.j3270' - version = '1.2.4' + version = '1.3.0' repositories { mavenCentral() diff --git a/j3270/src/main/java/haus/nightmare/j3270/Version.java b/j3270/src/main/java/haus/nightmare/j3270/Version.java index 63ef4f2..bf1070f 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/Version.java +++ b/j3270/src/main/java/haus/nightmare/j3270/Version.java @@ -8,7 +8,7 @@ public final class Version { /** * Hardcoded fallback release version matching build.gradle and pom coordinates. */ - public static final String VERSION = "1.2.4"; + public static final String VERSION = "1.3.0"; /** * Canonical application name. @@ -22,7 +22,7 @@ public final class Version { * Inspects the package implementation version first (populated when loaded from a JAR), * and falls back to the compile-time {@link #VERSION} constant if null or blank. * - * @return the version string (e.g. "1.2.4") + * @return the version string (e.g. "1.3.0") */ public static String getVersion() { Package pkg = Version.class.getPackage(); diff --git a/j3270/src/test/java/haus/nightmare/j3270/Phase27BuildAndRenderTest.java b/j3270/src/test/java/haus/nightmare/j3270/Phase27BuildAndRenderTest.java index bb0ea03..0605749 100644 --- a/j3270/src/test/java/haus/nightmare/j3270/Phase27BuildAndRenderTest.java +++ b/j3270/src/test/java/haus/nightmare/j3270/Phase27BuildAndRenderTest.java @@ -29,33 +29,33 @@ import static org.junit.jupiter.api.Assertions.*; public class Phase27BuildAndRenderTest { @Test - @DisplayName("Phase 27.1 & 27.2: Release version 1.2.4 synchronized across build files and android.useAndroidX removed") + @DisplayName("Phase 27.1 & 27.2: Release version 1.3.0 synchronized across build files and android.useAndroidX removed") public void testVersionSynchronizationAndGradleProperties() throws Exception { File projectRoot = new File(".").getCanonicalFile(); if (!new File(projectRoot, "settings.gradle").exists() && projectRoot.getParentFile() != null) { projectRoot = projectRoot.getParentFile(); } - // 1. Verify build.gradle version = '1.2.4' + // 1. Verify build.gradle version = '1.3.0' File buildGradle = new File(projectRoot, "build.gradle"); assertTrue(buildGradle.exists(), "build.gradle must exist"); String buildGradleContent = Files.readString(buildGradle.toPath()); - assertTrue(buildGradleContent.contains("version = '1.2.4'"), "build.gradle must define version = '1.2.4'"); + assertTrue(buildGradleContent.contains("version = '1.3.0'"), "build.gradle must define version = '1.3.0'"); assertFalse(buildGradleContent.contains("0.1.0"), "build.gradle must not contain 0.1.0"); - // 3. Verify setup.ps1 Implementation-Version: 1.2.4 + // 3. Verify setup.ps1 Implementation-Version: 1.3.0 File setupPs1 = new File(projectRoot, "setup.ps1"); assertTrue(setupPs1.exists(), "setup.ps1 must exist"); String setupPs1Content = Files.readString(setupPs1.toPath()); - assertTrue(setupPs1Content.contains("Implementation-Version: 1.2.4"), "setup.ps1 must specify Implementation-Version: 1.2.4"); + assertTrue(setupPs1Content.contains("Implementation-Version: 1.3.0"), "setup.ps1 must specify Implementation-Version: 1.3.0"); assertFalse(setupPs1Content.contains("Implementation-Version: 0.1.0"), "setup.ps1 must not contain 0.1.0"); - // 4. Verify setup.sh Implementation-Version: 1.2.4 + // 4. Verify setup.sh Implementation-Version: 1.3.0 File setupSh = new File(projectRoot, "setup.sh"); assertTrue(setupSh.exists(), "setup.sh must exist"); String setupShContent = Files.readString(setupSh.toPath()); - assertTrue(setupShContent.contains("Implementation-Version: 1.2.4"), "setup.sh must specify Implementation-Version: 1.2.4"); + assertTrue(setupShContent.contains("Implementation-Version: 1.3.0"), "setup.sh must specify Implementation-Version: 1.3.0"); assertFalse(setupShContent.contains("Implementation-Version: 0.1.0"), "setup.sh must not contain 0.1.0"); // 5. Verify gradle.properties android.useAndroidX removed diff --git a/j3270/src/test/java/haus/nightmare/j3270/Phase31AboutAndInterfaceTest.java b/j3270/src/test/java/haus/nightmare/j3270/Phase31AboutAndInterfaceTest.java index 7670ee2..79bb23c 100644 --- a/j3270/src/test/java/haus/nightmare/j3270/Phase31AboutAndInterfaceTest.java +++ b/j3270/src/test/java/haus/nightmare/j3270/Phase31AboutAndInterfaceTest.java @@ -20,28 +20,28 @@ import static org.junit.jupiter.api.Assertions.*; public class Phase31AboutAndInterfaceTest { @Test - @DisplayName("Phase 31.1: Dedicated Version classes report v1.2.4 single source of truth") + @DisplayName("Phase 31.1: Dedicated Version classes report v1.3.0 single source of truth") public void testVersionClasses() { // lib3270j - assertEquals("1.2.4", haus.nightmare.lib3270j.Version.VERSION); + assertEquals("1.3.0", haus.nightmare.lib3270j.Version.VERSION); assertEquals("lib3270j", haus.nightmare.lib3270j.Version.NAME); - assertEquals("1.2.4", haus.nightmare.lib3270j.Version.getVersion()); + assertEquals("1.3.0", haus.nightmare.lib3270j.Version.getVersion()); // j3270 - assertEquals("1.2.4", haus.nightmare.j3270.Version.VERSION); + assertEquals("1.3.0", haus.nightmare.j3270.Version.VERSION); assertEquals("j3270", haus.nightmare.j3270.Version.NAME); - assertEquals("1.2.4", haus.nightmare.j3270.Version.getVersion()); + assertEquals("1.3.0", haus.nightmare.j3270.Version.getVersion()); } @Test - @DisplayName("Phase 31.2: Help > About dialogue text properly reports j3270 v1.2.4 and lib3270j v1.2.4") + @DisplayName("Phase 31.2: Help > About dialogue text properly reports j3270 v1.3.0 and lib3270j v1.3.0") public void testAboutTextContent() { String aboutText = J3270App.getAboutText(); assertNotNull(aboutText, "About text must not be null"); // Verify version reports - assertTrue(aboutText.contains("j3270 v1.2.4"), "About text must report j3270 v1.2.4"); - assertTrue(aboutText.contains("lib3270j v1.2.4"), "About text must report lib3270j v1.2.4"); + assertTrue(aboutText.contains("j3270 v1.3.0"), "About text must report j3270 v1.3.0"); + assertTrue(aboutText.contains("lib3270j v1.3.0"), "About text must report lib3270j v1.3.0"); // Verify stale pre-release versions are eradicated assertFalse(aboutText.contains("0.2.0"), "About text must not contain stale version 0.2.0"); diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/ConnectionConfig.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/ConnectionConfig.java index 8b0f0d8..0680aa2 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/ConnectionConfig.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/ConnectionConfig.java @@ -70,7 +70,7 @@ public class ConnectionConfig { 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) + public static final int DEFAULT_MAX_FRAMING_BUFFER_SIZE = 131072; // 128KB (2x max 64KB structured field; covers multi-SF GDDM + PS loads) private int maxFramingBufferSize = DEFAULT_MAX_FRAMING_BUFFER_SIZE; // Host operating system type (TSO, CMS, CICS) diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/Version.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/Version.java index eab9e1a..10f5e0d 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/Version.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/Version.java @@ -8,7 +8,7 @@ public final class Version { /** * Hardcoded fallback release version matching build.gradle and pom coordinates. */ - public static final String VERSION = "1.2.4"; + public static final String VERSION = "1.3.0"; /** * Canonical library name. @@ -22,7 +22,7 @@ public final class Version { * Inspects the package implementation version first (populated when loaded from a JAR), * and falls back to the compile-time {@link #VERSION} constant if null or blank. * - * @return the version string (e.g. "1.2.4") + * @return the version string (e.g. "1.3.0") */ public static String getVersion() { Package pkg = Version.class.getPackage(); diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/DataStreamProcessor.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/DataStreamProcessor.java index 19f0208..7820bb3 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/DataStreamProcessor.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/DataStreamProcessor.java @@ -41,10 +41,12 @@ public class DataStreamProcessor { private haus.nightmare.lib3270j.input.InputProcessor inputProcessor; // Graphics & Programmed Symbols + private static final int MAX_GOCA_ACCUMULATOR_SIZE = 262144; // 256KB cap (4x advertised 63KB QR_SEGMENT limit) private final haus.nightmare.lib3270j.graphics.ProgramSymbolManager programSymbolManager = new haus.nightmare.lib3270j.graphics.ProgramSymbolManager(); private final haus.nightmare.lib3270j.graphics.GraphicsPlane graphicsPlane = new haus.nightmare.lib3270j.graphics.GraphicsPlane(720, 384); private final haus.nightmare.lib3270j.graphics.GocaDecoder gocaDecoder = new haus.nightmare.lib3270j.graphics.GocaDecoder(graphicsPlane); - private final java.io.ByteArrayOutputStream gocaAccumulator = new java.io.ByteArrayOutputStream(); + private final FastByteBuffer gocaAccumulator = new FastByteBuffer(8192, MAX_GOCA_ACCUMULATOR_SIZE); + private boolean gocaDiscardMode = false; private int currentGocaSubtype = 0; // Phase 10: Auto-Unlock & Contention Resolution State @@ -1539,32 +1541,58 @@ public class DataStreamProcessor { if (flags == 0x80) { // SPAN_FIRST gocaAccumulator.reset(); + gocaDiscardMode = false; currentGocaSubtype = sfSubId; if (orderLen > 0) { - gocaAccumulator.write(data, orderOffset, orderLen); + try { + gocaAccumulator.write(data, orderOffset, orderLen); + } catch (FramingBufferOverflowException e) { + log.warning("GOCA accumulator exceeded " + MAX_GOCA_ACCUMULATOR_SIZE + " bytes on SPAN_FIRST; discarding spanned object"); + gocaAccumulator.reset(); + gocaDiscardMode = true; + } } } else if (flags == 0x00) { // SPAN_MIDDLE - if (orderLen > 0) { - gocaAccumulator.write(data, orderOffset, orderLen); + if (orderLen > 0 && !gocaDiscardMode) { + try { + gocaAccumulator.write(data, orderOffset, orderLen); + } catch (FramingBufferOverflowException e) { + log.warning("GOCA accumulator exceeded " + MAX_GOCA_ACCUMULATOR_SIZE + " bytes on SPAN_MIDDLE; discarding spanned object"); + gocaAccumulator.reset(); + gocaDiscardMode = true; + } } } else if (flags == 0x40) { // SPAN_LAST - if (orderLen > 0) { - gocaAccumulator.write(data, orderOffset, orderLen); + if (!gocaDiscardMode) { + if (orderLen > 0) { + try { + gocaAccumulator.write(data, orderOffset, orderLen); + } catch (FramingBufferOverflowException e) { + log.warning("GOCA accumulator exceeded " + MAX_GOCA_ACCUMULATOR_SIZE + " bytes on SPAN_LAST; discarding spanned object"); + gocaAccumulator.reset(); + gocaDiscardMode = true; + } + } + if (!gocaDiscardMode) { + byte[] fullStream = gocaAccumulator.toByteArray(); + gocaAccumulator.reset(); + log.info(String.format("GOCA stream SPAN_LAST assembled: %d bytes", fullStream.length)); + try { + java.nio.file.Files.write(java.nio.file.Paths.get("/Users/rudi/Projects/j3270/captured_goca.bin"), fullStream); + } catch (Exception ignored) {} + if (currentGocaSubtype == haus.nightmare.lib3270j.graphics.GocaConstants.SF_OBJCNTL_SUB) { + gocaDecoder.processProcedureOrders(fullStream, 0, fullStream.length); + } else { + gocaDecoder.decodeStream(fullStream, 0, fullStream.length); + } + notifyScreenUpdated(); + } } - byte[] fullStream = gocaAccumulator.toByteArray(); gocaAccumulator.reset(); - log.info(String.format("GOCA stream SPAN_LAST assembled: %d bytes", fullStream.length)); - try { - java.nio.file.Files.write(java.nio.file.Paths.get("/Users/rudi/Projects/j3270/captured_goca.bin"), fullStream); - } catch (Exception ignored) {} - if (currentGocaSubtype == haus.nightmare.lib3270j.graphics.GocaConstants.SF_OBJCNTL_SUB) { - gocaDecoder.processProcedureOrders(fullStream, 0, fullStream.length); - } else { - gocaDecoder.decodeStream(fullStream, 0, fullStream.length); - } - notifyScreenUpdated(); + gocaDiscardMode = false; } else { // SPAN_ONLY (0xC0) gocaAccumulator.reset(); + gocaDiscardMode = false; log.info(String.format("GOCA stream SPAN_ONLY: %d bytes", orderLen)); if (sfSubId == haus.nightmare.lib3270j.graphics.GocaConstants.SF_OBJCNTL_SUB) { gocaDecoder.processProcedureOrders(data, orderOffset, orderLen); 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 0d8d431..707c63a 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/FastByteBuffer.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/FastByteBuffer.java @@ -18,7 +18,7 @@ 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) + public static final int DEFAULT_MAX_CAPACITY = 131072; // 128KB (2x max 64KB structured field; covers multi-SF GDDM + PS loads) private final int initialCapacity; private int maxCapacity; diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/printer/PD3270.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/printer/PD3270.java index 34e9336..e66e15f 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/printer/PD3270.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/printer/PD3270.java @@ -24,6 +24,9 @@ public class PD3270 { private String destination; private boolean open = false; + /** Maximum size of in-memory print capture buffer (1 MB) to prevent unbounded heap growth. */ + public static final int MAX_MEMORY_CAPTURE_SIZE = 1048576; + private OutputStream targetOutputStream; private Writer targetWriter; private Process pipeProcess; @@ -33,6 +36,13 @@ public class PD3270 { private int pageCount = 0; private Charset outputCharset = StandardCharsets.UTF_8; + private void appendToMemoryStream(byte[] data, int offset, int length) { + int remaining = MAX_MEMORY_CAPTURE_SIZE - memoryStream.size(); + if (remaining > 0) { + memoryStream.write(data, offset, Math.min(length, remaining)); + } + } + public PD3270() { this(new PrinterConfig()); } @@ -102,7 +112,7 @@ public class PD3270 { } try { - memoryStream.write(data, offset, length); + appendToMemoryStream(data, offset, length); byteCount += length; if (targetOutputStream != null) { @@ -129,7 +139,7 @@ public class PD3270 { } try { byte[] b = String.valueOf(c).getBytes(outputCharset); - memoryStream.write(b); + appendToMemoryStream(b, 0, b.length); byteCount += b.length; if (targetWriter != null) { @@ -158,7 +168,7 @@ public class PD3270 { } try { byte[] b = s.getBytes(outputCharset); - memoryStream.write(b); + appendToMemoryStream(b, 0, b.length); byteCount += b.length; if (targetWriter != null) { 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 c402953..3c06690 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/printer/PrinterConfig.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/printer/PrinterConfig.java @@ -54,7 +54,7 @@ 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 + public static final int DEFAULT_MAX_FRAMING_BUFFER_SIZE = 131072; // 128KB private int maxFramingBufferSize = DEFAULT_MAX_FRAMING_BUFFER_SIZE; public PrinterConfig() { 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 6830a66..1d95cca 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/printer/Telnet3270EP.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/printer/Telnet3270EP.java @@ -61,14 +61,16 @@ public class Telnet3270EP implements Runnable { private static final int MAX_PRINTER_SUBNEG_SIZE = 8192; private final int maxFramingBufferSize; private boolean recordDiscardMode = false; - private final ByteArrayOutputStream recordBuffer; + private boolean subnegDiscardMode = false; + private final haus.nightmare.lib3270j.datastream.FastByteBuffer 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.recordBuffer = new haus.nightmare.lib3270j.datastream.FastByteBuffer( + Math.min(32768, this.maxFramingBufferSize), 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); @@ -79,7 +81,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.recordBuffer = new haus.nightmare.lib3270j.datastream.FastByteBuffer( + Math.min(32768, this.maxFramingBufferSize), 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); @@ -384,21 +387,30 @@ public class Telnet3270EP implements Runnable { if (idx < end && (data[idx] & 0xFF) == TelnetConstants.SE) { idx++; inSubnegotiation = false; - processSubnegotiation(subnegBuffer.toByteArray()); + if (!subnegDiscardMode) { + processSubnegotiation(subnegBuffer.toByteArray()); + } subnegBuffer.reset(); + subnegDiscardMode = false; } else if (idx < end && (data[idx] & 0xFF) == TelnetConstants.IAC) { - if (subnegBuffer.size() < MAX_PRINTER_SUBNEG_SIZE) { - subnegBuffer.write(TelnetConstants.IAC); + if (!subnegDiscardMode) { + if (subnegBuffer.size() < MAX_PRINTER_SUBNEG_SIZE) { + subnegBuffer.write(TelnetConstants.IAC); + } else { + log.warning("Printer subnegotiation exceeded " + MAX_PRINTER_SUBNEG_SIZE + " bytes; discarding until IAC SE"); + subnegBuffer.reset(); + subnegDiscardMode = true; + } } idx++; } - } else { + } else if (!subnegDiscardMode) { if (subnegBuffer.size() < MAX_PRINTER_SUBNEG_SIZE) { subnegBuffer.write(b); } else { - log.warning("Printer subnegotiation exceeded " + MAX_PRINTER_SUBNEG_SIZE + " bytes; aborting SB"); + log.warning("Printer subnegotiation exceeded " + MAX_PRINTER_SUBNEG_SIZE + " bytes; discarding until IAC SE"); subnegBuffer.reset(); - inSubnegotiation = false; + subnegDiscardMode = true; } } continue; @@ -414,6 +426,7 @@ public class Telnet3270EP implements Runnable { break; case TelnetConstants.SB: inSubnegotiation = true; + subnegDiscardMode = false; subnegBuffer.reset(); break; case TelnetConstants.EOR: 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 6b20bcd..4f389cf 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetFSM.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/telnet/TelnetFSM.java @@ -54,6 +54,7 @@ public class TelnetFSM { // Sanity cap for Telnet subnegotiation parameters (RFC 1091, RFC 1572, RFC 2355) private static final int MAX_SUBNEG_BUFFER_SIZE = 8192; + private boolean sbDiscardMode = false; // Reusable single-byte scratch buffer for plain NVT data dispatch (Phase 27.5 / T1-C) private final byte[] nvtSingleByteScratch = new byte[1]; @@ -229,6 +230,7 @@ public class TelnetFSM { ttypeIndex = 0; luIndex = 0; recordDiscardMode = false; + sbDiscardMode = false; ibuf.reset(); sbbuf.reset(); @@ -401,7 +403,13 @@ public class TelnetFSM { private void processIAC(int c) { switch (c) { case IAC: // Escaped IAC — literal 0xFF - appendInboundByte(c); + if ((connectionState == ConnectionState.CONNECTED_NVT || connectionState == ConnectionState.CONNECTED_NVT_CHAR) + && !(hisOpts[TELOPT_BINARY] && hisOpts[TELOPT_EOR])) { + nvtSingleByteScratch[0] = (byte) c; + nvtProcessor.processNVTData(nvtSingleByteScratch, 0, 1); + } else { + appendInboundByte(c); + } state = TNS_DATA; break; case EOR: // End of record — process accumulated 3270 data @@ -419,6 +427,7 @@ public class TelnetFSM { case DONT: state = TNS_DONT; break; case SB: sbbuf.reset(); + sbDiscardMode = false; state = TNS_SB; break; case GA: @@ -585,14 +594,14 @@ public class TelnetFSM { private void processSB(int c) { if (c == IAC) { state = TNS_SB_IAC; - } else { + } else if (!sbDiscardMode) { 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"); + " bytes without IAC SE; discarding until IAC SE"); sbbuf.reset(); - state = TNS_DATA; + sbDiscardMode = true; } } } @@ -602,21 +611,34 @@ public class TelnetFSM { private void processSBIAC(int c) { if (c == SE) { // Sub-negotiation complete - byte[] sbData = sbbuf.toByteArray(); - sbbuf.reset(); - processSubNegotiation(sbData); + if (!sbDiscardMode) { + byte[] sbData = sbbuf.toByteArray(); + sbbuf.reset(); + processSubNegotiation(sbData); + } else { + sbbuf.reset(); + sbDiscardMode = false; + } state = TNS_DATA; } else if (c == IAC) { // Escaped IAC within sub-negotiation - if (sbbuf.size() < MAX_SUBNEG_BUFFER_SIZE) { - sbbuf.write(IAC); + if (!sbDiscardMode) { + if (sbbuf.size() < MAX_SUBNEG_BUFFER_SIZE) { + sbbuf.write(IAC); + } else { + log.warning("Telnet subnegotiation exceeded " + MAX_SUBNEG_BUFFER_SIZE + + " bytes without IAC SE; discarding until IAC SE"); + sbbuf.reset(); + sbDiscardMode = true; + } } state = TNS_SB; } else { - // Shouldn't happen, but recover + // Unexpected command after IAC inside SB (e.g. missing SE): abort SB and process command log.warning("Unexpected byte " + c + " after IAC in SB"); sbbuf.reset(); - state = TNS_DATA; + sbDiscardMode = false; + processIAC(c); } } diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/Phase30LibraryDistributionTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/Phase30LibraryDistributionTest.java index f547657..8188bbf 100644 --- a/lib3270j/src/test/java/haus/nightmare/lib3270j/Phase30LibraryDistributionTest.java +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/Phase30LibraryDistributionTest.java @@ -66,7 +66,7 @@ public class Phase30LibraryDistributionTest { String setupShContent = Files.readString(setupSh.toPath()); assertTrue(setupShContent.contains("lib3270j.jar"), "setup.sh must package lib3270j.jar"); assertTrue(setupShContent.contains("Implementation-Title: lib3270j"), "setup.sh must set Implementation-Title: lib3270j"); - assertTrue(setupShContent.contains("Implementation-Version: 1.2.4"), "setup.sh must set Implementation-Version: 1.2.4"); + assertTrue(setupShContent.contains("Implementation-Version: 1.3.0"), "setup.sh must set Implementation-Version: 1.3.0"); // 3. setup.ps1 File setupPs1 = new File(projectRoot, "setup.ps1"); @@ -74,7 +74,7 @@ public class Phase30LibraryDistributionTest { String setupPs1Content = Files.readString(setupPs1.toPath()); assertTrue(setupPs1Content.contains("lib3270j.jar"), "setup.ps1 must package lib3270j.jar"); assertTrue(setupPs1Content.contains("Implementation-Title: lib3270j"), "setup.ps1 must set Implementation-Title: lib3270j"); - assertTrue(setupPs1Content.contains("Implementation-Version: 1.2.4"), "setup.ps1 must set Implementation-Version: 1.2.4"); + assertTrue(setupPs1Content.contains("Implementation-Version: 1.3.0"), "setup.ps1 must set Implementation-Version: 1.3.0"); } @Test @@ -112,12 +112,12 @@ public class Phase30LibraryDistributionTest { @DisplayName("Phase 30.2: Maven local published artifacts exist and validate if maven-publish was executed") public void testMavenLocalPublishedArtifacts() throws Exception { String userHome = System.getProperty("user.home"); - File m2ArtifactDir = new File(userHome, ".m2/repository/haus/nightmare/j3270/lib3270j/1.2.4"); + File m2ArtifactDir = new File(userHome, ".m2/repository/haus/nightmare/j3270/lib3270j/1.3.0"); if (m2ArtifactDir.exists()) { - File pomFile = new File(m2ArtifactDir, "lib3270j-1.2.4.pom"); - File jarFile = new File(m2ArtifactDir, "lib3270j-1.2.4.jar"); - File sourcesFile = new File(m2ArtifactDir, "lib3270j-1.2.4-sources.jar"); - File javadocFile = new File(m2ArtifactDir, "lib3270j-1.2.4-javadoc.jar"); + File pomFile = new File(m2ArtifactDir, "lib3270j-1.3.0.pom"); + File jarFile = new File(m2ArtifactDir, "lib3270j-1.3.0.jar"); + File sourcesFile = new File(m2ArtifactDir, "lib3270j-1.3.0-sources.jar"); + File javadocFile = new File(m2ArtifactDir, "lib3270j-1.3.0-javadoc.jar"); assertTrue(pomFile.exists() && pomFile.length() > 0, "POM file must exist and be non-empty"); assertTrue(jarFile.exists() && jarFile.length() > 0, "JAR file must exist and be non-empty"); @@ -127,7 +127,7 @@ public class Phase30LibraryDistributionTest { String pomXml = Files.readString(pomFile.toPath()); assertTrue(pomXml.contains("haus.nightmare.j3270"), "POM must contain groupId"); assertTrue(pomXml.contains("lib3270j"), "POM must contain artifactId"); - assertTrue(pomXml.contains("1.2.4"), "POM must contain version 1.2.4"); + assertTrue(pomXml.contains("1.3.0"), "POM must contain version 1.3.0"); assertTrue(pomXml.contains("The Unlicense"), "POM must contain Unlicense"); assertFalse(pomXml.contains("Apache"), "lib3270j POM must not contain Apache license"); assertFalse(pomXml.contains(""), "lib3270j POM must have zero external dependencies"); 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 6f50504..3dd6180 100644 --- a/lib3270j/src/test/java/haus/nightmare/lib3270j/datastream/FastByteBufferTest.java +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/datastream/FastByteBufferTest.java @@ -165,6 +165,7 @@ public class FastByteBufferTest { buf.write(chunk, 0, 64); assertEquals(128, buf.size()); + assertEquals(128, buf.buffer().length); // Writing one more byte must throw FramingBufferOverflowException assertThrows(FramingBufferOverflowException.class, () -> buf.write(0xFF)); @@ -180,10 +181,10 @@ public class FastByteBufferTest { } @Test - @DisplayName("FastByteBuffer default maxCapacity is 1048576 bytes (1MB)") + @DisplayName("FastByteBuffer default maxCapacity is 131072 bytes (128KB)") public void testDefaultMaxCapacity() { FastByteBuffer buf = new FastByteBuffer(); assertEquals(FastByteBuffer.DEFAULT_MAX_CAPACITY, buf.getMaxCapacity()); - assertEquals(1048576, buf.getMaxCapacity()); + assertEquals(131072, 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 index 577289f..ea7d6e0 100644 --- a/lib3270j/src/test/java/haus/nightmare/lib3270j/telnet/FramingBufferBoundedCapTest.java +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/telnet/FramingBufferBoundedCapTest.java @@ -72,7 +72,7 @@ public class FramingBufferBoundedCapTest { } @Test - @DisplayName("Framing buffer caps at 1MB and enters discard mode if stream lacks IAC EOR") + @DisplayName("Framing buffer caps at 128KB and enters discard mode if stream lacks IAC EOR") public void testFramingBufferOverflowDiscardsUntilEor() { fsm.onConnected(); @@ -82,19 +82,19 @@ public class FramingBufferBoundedCapTest { feedBytes(IAC, DO, TELOPT_BINARY); feedBytes(IAC, WILL, TELOPT_BINARY); - assertEquals(1048576, fsm.getMaxFramingBufferSize(), "Default framing buffer cap should be 1MB (1048576 bytes)"); + assertEquals(131072, fsm.getMaxFramingBufferSize(), "Default framing buffer cap should be 128KB (131072 bytes)"); assertFalse(fsm.isRecordDiscardMode()); - // Feed chunks totaling over 1MB without any IAC EOR delimiter + // Feed chunks totaling over 128KB 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 + for (int i = 0; i < 16; i++) { + fsm.feedBytes(chunk, 0, chunk.length); // 16 * 8192 = 131,072 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 + // Now feed an additional chunk exceeding the 128KB cap without IAC EOR fsm.feedBytes(chunk, 0, 100); assertTrue(fsm.isRecordDiscardMode(), "FSM must enter recordDiscardMode when cap is exceeded without IAC EOR"); @@ -156,45 +156,56 @@ public class FramingBufferBoundedCapTest { } @Test - @DisplayName("Telnet subnegotiation buffer caps at 8KB if stream lacks IAC SE") + @DisplayName("Telnet subnegotiation buffer caps at 8KB if stream lacks IAC SE without leaking into data buffer") public void testSubnegotiationBufferCap() { fsm.onConnected(); + feedBytes(IAC, DO, TELOPT_EOR); + feedBytes(IAC, WILL, TELOPT_EOR); + feedBytes(IAC, DO, TELOPT_BINARY); + feedBytes(IAC, WILL, TELOPT_BINARY); // Begin subnegotiation: IAC SB TELOPT_TTYPE feedBytes(IAC, SB, TELOPT_TTYPE); // Feed > 8192 bytes without IAC SE - byte[] largeSb = new byte[8200]; + byte[] largeSb = new byte[9000]; 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); + // Overflowing SB bytes must NOT leak into the 3270 framing buffer while waiting for IAC SE + assertEquals(0, fsm.getCurrentFramingBufferSize(), "Overflowing SB bytes must not leak into ibuf"); + + // Terminate the subnegotiation with IAC SE + feedBytes(IAC, SE); + assertEquals(0, fsm.getCurrentFramingBufferSize()); + + // Subsequent 3270 data record should be processed cleanly + 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("Printer framing buffer caps at 1MB and enters discard mode without IAC EOR") + @DisplayName("Printer framing buffer caps at 128KB and enters discard mode without IAC EOR") public void testPrinterFramingBufferCap() throws Exception { PrinterConfig pcfg = new PrinterConfig("localhost", 23); - assertEquals(1048576, pcfg.getMaxFramingBufferSize()); + assertEquals(131072, pcfg.getMaxFramingBufferSize()); Telnet3270EP printer = new Telnet3270EP(pcfg); - assertEquals(1048576, printer.getMaxFramingBufferSize()); + assertEquals(131072, 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]; + byte[] chunk = new byte[8192]; 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 + for (int i = 0; i < 17; i++) { + processIncoming.invoke(printer, chunk, 0, chunk.length); // 17 * 8192 = 139,264 bytes > 131,072 } - assertTrue(printer.isRecordDiscardMode(), "Printer must enter discard mode when record exceeds 1MB"); + assertTrue(printer.isRecordDiscardMode(), "Printer must enter discard mode when record exceeds 128KB"); // Send IAC EOR byte[] eor = new byte[] { (byte) IAC, (byte) EOR }; diff --git a/setup.ps1 b/setup.ps1 index 7b885b1..3244527 100644 --- a/setup.ps1 +++ b/setup.ps1 @@ -417,7 +417,7 @@ if (Test-Path $libResDir) { # ------------------------------------------------------------------------------ Write-LogInfo "Packaging standalone lib3270j library JAR..." $libManifestFile = Join-Path $BuildDir "LIB_MANIFEST.MF" -$libManifestText = "Manifest-Version: 1.0`r`nImplementation-Title: lib3270j`r`nImplementation-Version: 1.2.4`r`nCreated-By: j3270 setup.ps1`r`n`r`n" +$libManifestText = "Manifest-Version: 1.0`r`nImplementation-Title: lib3270j`r`nImplementation-Version: 1.3.0`r`nCreated-By: j3270 setup.ps1`r`n`r`n" [System.IO.File]::WriteAllText($libManifestFile, $libManifestText) $libJarFile = Join-Path $BuildDir "lib3270j.jar" @@ -425,7 +425,7 @@ $libJarFile = Join-Path $BuildDir "lib3270j.jar" Write-LogInfo "Packaging standalone executable JAR..." $manifestFile = Join-Path $BuildDir "MANIFEST.MF" -$manifestText = "Manifest-Version: 1.0`r`nMain-Class: haus.nightmare.j3270.J3270App`r`nImplementation-Title: j3270`r`nImplementation-Version: 1.2.4`r`nCreated-By: j3270 setup.ps1`r`n`r`n" +$manifestText = "Manifest-Version: 1.0`r`nMain-Class: haus.nightmare.j3270.J3270App`r`nImplementation-Title: j3270`r`nImplementation-Version: 1.3.0`r`nCreated-By: j3270 setup.ps1`r`n`r`n" [System.IO.File]::WriteAllText($manifestFile, $manifestText) $jarFile = Join-Path $BuildDir "j3270.jar" diff --git a/setup.sh b/setup.sh index 745447b..05e4688 100755 --- a/setup.sh +++ b/setup.sh @@ -376,7 +376,7 @@ log_info "Packaging standalone lib3270j library JAR..." cat << 'EOF' > "$BUILD_DIR/LIB_MANIFEST.MF" Manifest-Version: 1.0 Implementation-Title: lib3270j -Implementation-Version: 1.2.4 +Implementation-Version: 1.3.0 Created-By: j3270 setup.sh EOF @@ -390,7 +390,7 @@ cat << 'EOF' > "$BUILD_DIR/MANIFEST.MF" Manifest-Version: 1.0 Main-Class: haus.nightmare.j3270.J3270App Implementation-Title: j3270 -Implementation-Version: 1.2.4 +Implementation-Version: 1.3.0 Created-By: j3270 setup.sh EOF