Phase 0: capture wire traffic and cross-reference against JDBC

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.
This commit is contained in:
Ryan Malloy 2026-05-02 16:10:25 -06:00
parent 1a149074d4
commit 2f3cababfa
2 changed files with 154 additions and 0 deletions

Binary file not shown.

View File

@ -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 <scenario>
//
// 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.
}
}
}