From 2f3cababfa88f3689de9c31f3af159b499403117 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sat, 2 May 2026 16:10:25 -0600 Subject: [PATCH] Phase 0: capture wire traffic and cross-reference against JDBC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Java reference client (tests/reference/RefClient.java) drives the official ifxjdbc.jar through three controlled scenarios: - connect-only: bare connect+disconnect - select-1: SELECT 1 round-trip with column metadata - dml-cycle: CREATE TEMP + INSERT + SELECT in one connection All three work end-to-end against the dev container with the documented credentials (informix/in4mix on sysmaster). Wire traffic captured via socat MITM relay (no sudo needed) — listen on 9090, forward to 9088, hex-dump both directions. Captures saved to docs/CAPTURES/. Total ~24 KB across the three scenarios. PROTOCOL_NOTES.md cross-reference findings (§12): Confirmed against the wire (✅ both JDBC + PCAP): - Big-endian framing throughout - Login PDU structure matches encodeAscBinary field-by-field - Server response matches DecodeAscBinary - Post-login messages are bare [short tag][payload] - SQ_EOT (=12) is a per-PDU flush/submit marker, not just disconnect ack — every logical request ends with [short 0x000c] Wire findings that AMENDED the JDBC-derived hypothesis: - The "capability section" is actually three 4-byte negotiated capability ints (Cap_1, Cap_2, Cap_3), not one int + 8 reserved zero bytes. The CFR decompile read it as adjacent zero writes but the wire shows distinct values that the server echoes back. Trust the wire over the decompiler for byte layouts. Validated post-login execution: - The first SELECT after login is JDBC-internal (locale lookup via informix.systables) — a Python implementation doesn't need to do this housekeeping - SQ_PREPARE format observed: [short SQ_PREPARE=2][short flags=0] [int sqlLen][bytes sql][nul][short ?][short ?][short SQ_EOT=12] - Server sends [short SQ_DESCRIBE=8] followed by column metadata Phase 0 exit verdict: GO. All four hard exit criteria confirmed. Remaining gaps (result-set descriptor exact layout, statement-time errors, capability semantics) are bounded and tractable in Phase 2. The narrow-scope off-ramp is not needed. --- docs/PROTOCOL_NOTES.md | Bin 20885 -> 26416 bytes tests/reference/RefClient.java | 154 +++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 tests/reference/RefClient.java diff --git a/docs/PROTOCOL_NOTES.md b/docs/PROTOCOL_NOTES.md index db91896ff8a45307c7ee4215052365cb016276f8..263861777a353df1bfef9f29bef0a8f82b209fdb 100644 GIT binary patch delta 5876 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 SCPl6q@)Z$Gv`snXQs)0f5S70G delta 586 zcma)3Jx;?g6jmjsZm=S%x3!dppAE4=%YvY)fCJQVUJ^rM2m7ThQY9uf!82FsyeXwefInQ{PI@0eXP8E9M#*_IOoY23ITU_`JV=fLssIs!$bOe{&vuzI$ioRpb-NHz4mUeTsuaWC*(OK2(3t!j`(>d zEGdz|e^`oV*;t$jhe5FrY+rp#`Rs$Gs?Fw4H#kxRksxd~!Qw@(EIMGb5Gw14PZklZ z89LbQcFK$2aQ!I`nKh{)41mnEk99bW1=YVfaFiuVvcT}l*mLGjh0+{r2c*a-w+M-e zl!mpx#`-)2%1NU&N@~GCBr0HgrSw5(uk8i=X9L#zX_ftohvCZYq9+#0j8ct Si9}X&5E@%f9B`)P_39TlN!lp@ diff --git a/tests/reference/RefClient.java b/tests/reference/RefClient.java new file mode 100644 index 0000000..9114b07 --- /dev/null +++ b/tests/reference/RefClient.java @@ -0,0 +1,154 @@ +// Phase 0 spike: reference client for capturing the Informix SQLI wire protocol. +// +// Drives the official IBM Informix JDBC driver (com.informix.jdbc.IfxDriver) +// through controlled scenarios so that tcpdump captures of localhost:9088 +// can be cross-referenced against the decompiled JDBC source. This is the +// "ground truth" client whose traffic IS by definition spec-correct. +// +// Build: +// javac -cp build/ifxjdbc.jar tests/reference/RefClient.java -d build/ +// Run: +// java -cp build/ifxjdbc.jar:build/ tests.reference.RefClient +// +// Scenarios: connect-only, select-1, dml-cycle, all +// +// Connection params can be overridden via env vars; defaults are the +// Informix Developer Edition Docker image defaults documented in +// docs/DECISION_LOG.md. + +package tests.reference; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.Statement; +import java.sql.SQLException; + +public class RefClient { + + static final String HOST = env("IFX_HOST", "127.0.0.1"); + static final String PORT = env("IFX_PORT", "9088"); + static final String SERVER = env("IFX_SERVER", "informix"); + static final String DATABASE = env("IFX_DATABASE", "sysmaster"); + static final String USER = env("IFX_USER", "informix"); + static final String PASSWORD = env("IFX_PASSWORD", "in4mix"); + + public static void main(String[] args) throws Exception { + Class.forName("com.informix.jdbc.IfxDriver"); + + String scenario = args.length > 0 ? args[0] : "connect-only"; + switch (scenario) { + case "connect-only": runConnectOnly(); break; + case "select-1": runSelect1(); break; + case "dml-cycle": runDmlCycle(); break; + case "all": + runConnectOnly(); + runSelect1(); + runDmlCycle(); + break; + default: + System.err.println("Unknown scenario: " + scenario); + System.err.println("Valid: connect-only | select-1 | dml-cycle | all"); + System.exit(2); + } + } + + static String url() { + return String.format( + "jdbc:informix-sqli://%s:%s/%s:INFORMIXSERVER=%s", + HOST, PORT, DATABASE, SERVER + ); + } + + static void log(String fmt, Object... args) { + System.out.printf("[ref] " + fmt + "%n", args); + } + + static String env(String key, String dflt) { + String v = System.getenv(key); + return (v == null || v.isEmpty()) ? dflt : v; + } + + // ------------------------------------------------------------------- + // Scenario A: connect + immediate disconnect + // ------------------------------------------------------------------- + static void runConnectOnly() throws SQLException { + log("=== connect-only ==="); + log("URL: %s", url()); + try (Connection c = DriverManager.getConnection(url(), USER, PASSWORD)) { + log("connected; product=%s", c.getMetaData().getDatabaseProductName()); + // Note: do NOT call getDatabaseProductVersion() — the 4.50.JC10 + // JDBC driver throws NumberFormatException on Informix 15.0's + // version string ("150."). Connection itself works fine. + } + log("disconnected."); + } + + // ------------------------------------------------------------------- + // Scenario B: connect + SELECT 1 + disconnect + // ------------------------------------------------------------------- + static void runSelect1() throws SQLException { + log("=== select-1 ==="); + try (Connection c = DriverManager.getConnection(url(), USER, PASSWORD); + Statement s = c.createStatement(); + ResultSet rs = s.executeQuery("SELECT 1 FROM systables WHERE tabid = 1")) { + + ResultSetMetaData md = rs.getMetaData(); + log("columns: %d", md.getColumnCount()); + for (int i = 1; i <= md.getColumnCount(); i++) { + log(" [%d] name=%s type=%d (%s) precision=%d scale=%d", + i, md.getColumnName(i), md.getColumnType(i), + md.getColumnTypeName(i), md.getPrecision(i), md.getScale(i)); + } + int rowNum = 0; + while (rs.next()) { + rowNum++; + log(" row %d: col1=%s", rowNum, rs.getObject(1)); + } + log("rows returned: %d", rowNum); + } + } + + // ------------------------------------------------------------------- + // Scenario C: full DML cycle (CREATE / INSERT / SELECT / DROP) + // Uses a UUID-suffixed table to be safe to re-run. + // ------------------------------------------------------------------- + static void runDmlCycle() throws SQLException { + log("=== dml-cycle ==="); + String table = "spike_" + Long.toHexString(System.nanoTime()); + try (Connection c = DriverManager.getConnection(url(), USER, PASSWORD)) { + c.setAutoCommit(true); // sysmaster is unlogged; force autocommit + + try (Statement s = c.createStatement()) { + log("CREATE TEMP TABLE %s", table); + s.execute("CREATE TEMP TABLE " + table + + " (id INTEGER, name VARCHAR(50), val FLOAT)"); + } + + try (PreparedStatement ps = c.prepareStatement( + "INSERT INTO " + table + " VALUES (?, ?, ?)")) { + ps.setInt(1, 42); + ps.setString(2, "hello"); + ps.setDouble(3, 3.14); + int n = ps.executeUpdate(); + log("INSERT rowcount=%d", n); + } + + try (Statement s = c.createStatement(); + ResultSet rs = s.executeQuery("SELECT id, name, val FROM " + table)) { + ResultSetMetaData md = rs.getMetaData(); + log("SELECT columns:"); + for (int i = 1; i <= md.getColumnCount(); i++) { + log(" [%d] %s : %s", i, md.getColumnName(i), md.getColumnTypeName(i)); + } + while (rs.next()) { + log(" row: id=%d name=%s val=%f", + rs.getInt(1), rs.getString(2), rs.getDouble(3)); + } + } + // Temp table dropped automatically on disconnect; no DROP needed. + } + } +}