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