Polish item #1: byte-for-byte regression test that asserts our generated login PDU is structurally identical to JDBC's reference captured in docs/CAPTURES/01-connect-only.socat.log. The test (tests/test_pdu_match.py) immediately caught a real bug: the capability section was misread during Phase 0 byte-decoding. Earlier text claimed Cap_1=1, Cap_2=0x3c000000, Cap_3=0 — actually: Cap_1 = 0x0000013c (= (capability_class << 8) | protocol_version where protocol_version = 0x3c = PF_PROT_SQLI_0600) Cap_2 = 0 Cap_3 = 0 The misalignment was: the 0x3c byte I attributed to Cap_2's high byte was actually Cap_1's low byte. The dev-image server is permissive enough to accept arbitrary capability values, so the connection succeeded even with the wrong bytes — but the PDU wasn't structurally identical to JDBC's reference. SERVER-ACCEPTS ≠ STRUCTURALLY-CORRECT. This is exactly why the byte-for-byte diff was the right polish item; "it connects" was a false ceiling. After fix: - 6 PDU-match tests assert byte-for-byte equality at offsets 2..280 (the structural prefix: SLheader sans length, all login markers, capability ints, username, password, protocol IDs, env vars). - Bytes 280+ legitimately differ per process (PID, TID, hostname, cwd, AppName) — those are NOT asserted. - Length field (offsets 0..1) also legitimately differs because our PDU has shorter env list and AppName. - Test uses monkey-patched IfxSocket so no network is needed. Polish item #2: Makefile per global CLAUDE.md convention. Targets: install, lint, format, test, test-integration, test-all, test-pdu, ifx-up/down/logs/shell/status, capture (re-run JDBC scenarios under socat), clean. `make` (no target) prints help. Doc updates: - PROTOCOL_NOTES.md §12: corrected capability section with the actual values and an explanation of the methodology lesson - DECISION_LOG.md: new entry recording the correction with a pointer to the regression test and the takeaway Side artifacts: - docs/CAPTURES/03-py-connect-only.socat.log - docs/CAPTURES/04-py-no-database.socat.log - docs/CAPTURES/05-py-fixed-caps.socat.log Test counts: 40 unit + 6 integration = 46 total, all green, ruff clean.
26 KiB
SQLI Wire Protocol Notes
Phase 0 spike artifact. Byte-level reference for the Informix SQLI wire protocol, derived from clean-room study of the decompiled IBM Informix JDBC driver (
ifxjdbc.jar,Implementation-Version: 4.50.10-SNAPSHOT/ printable4.50.JC10, build 146 from 2023-03-07; SHA256 inJDBC_NOTES.md) cross-checked against packet captures of the reference exchange (pending).
Source attribution conventions
Each documented byte sequence cites both sources of evidence:
- 🟡 JDBC: cross-referenced against
<class>#<method>()in the decompiled tree (seeJDBC_NOTES.md) - 🔵 PCAP: observed in
docs/CAPTURES/<file>.pcapat offset<n> - ✅ CONFIRMED: corroborated by both 🟡 and 🔵
- 🟠 UNVERIFIED: only one of the two sources
Current state (2026-05-02): all 🟡 findings are present from JDBC reading. PCAP capture is pending (Phase 0 task #7) and required for ✅. Treat everything below as 🟠 pending PCAP cross-check.
1. Wire framing primitives 🟡
Endianness
Big-endian (network byte order) for all multi-byte integers.
Source: com.informix.lang.JavaToIfxType.JavaToIfxInt(int) line 51-54:
public static byte[] JavaToIfxInt(int i) {
byte[] b = new byte[]{(byte)(i >> 24), (byte)(i >> 16), (byte)(i >> 8), (byte)i};
return b;
}
Same pattern for JavaToIfxSmallInt (2 bytes) and JavaToIfxLongBigInt (8 bytes). Java's DataOutputStream defaults to big-endian; Informix matches that.
Width table
| Wire type | Width (bytes) | Java method | Notes |
|---|---|---|---|
| SmallInt | 2 | writeSmallInt(short) / writeShort |
SMALLINT, message-type tags, length prefixes |
| Int | 4 | writeInt(int) |
INTEGER, capability flags, sizes |
| LongBigInt | 8 | writeLongBigint(long) |
BIGINT (use this for 64-bit ints) |
| LongInt | 10 | writeLongInt(long) |
Legacy variable-numeric LONG INT — skip MVP, predates 64-bit ints |
| Real | 4 | writeReal(float) |
IEEE 754 single |
| Double | 8 | writeDouble(double) |
IEEE 754 double |
| Date | 4 | writeDate(Date) |
day-count from Informix epoch (1899-12-31, to confirm) |
Variable-length encoding (string, decimal, datetime, interval, BYTE, TEXT)
[short length][bytes payload][optional 0x00 pad if length is odd]
The 16-bit alignment is a hard requirement. Source: IfxDataOutputStream.writePadded(byte[]):
public void writePadded(byte[] b) throws IOException {
this.write(b, 0, b.length);
if ((b.length & 1) >= 1) {
this.write(0);
}
}
Mirror in IfxDataInputStream.readPadded. Every variable-payload message needs this padding or the next short read will misalign and the entire parser desynchronizes.
String encoding
com.informix.lang.JavaToIfxType.JavaToIfxChar(String) returns:
[short length+1][bytes][0x00 nul terminator]
The +1 is the trailing nul. So an N-character string takes 2 + N + 1 bytes, then padded to even.
Note: there is also a JavaToIfx4BytesChar variant for wide-char strings. Probably for GLS multibyte locales. Phase 6+.
2. Connection establishment 🟡
TCP setup
- Default port: 9088 (native SQLI). Port 9089 is SSL.
Socket.connect(InetSocketAddress(host, port), loginTimeout)setTcpNoDelay(true)— Nagle offsetKeepAlive(socKeepAlive)— opt-in viaIFX_SOC_KEEPALIVEproperty- Optional SSL wrap via
SSLSocketFactory. Phase 6+; skip for MVP. - Buffered streams:
BufferedInputStream(in, 4096)/BufferedOutputStream(out, 4096)over the raw socket streams. TheIfxDataInputStream/IfxDataOutputStreamwrap the buffered streams.
Capability bits (driving login path selection)
capabilities == 0→ legacy text-mode login (EncodeAscString)capabilities > 0→ modern binary login (encodeAscBinary) — this is what we implement
The capabilities bitfield itself is opaque from the client side; the JDBC driver computes it based on opt-props. For MVP we will hardcode a sensible value matching what JDBC sends in a vanilla connection (will derive from PCAP).
Other constants seen in Connection.java
| Constant | Value | Notes |
|---|---|---|
MAX_BUFF_SIZE |
32768 | upper bound for individual PDUs |
MIN_BUFF_SIZE |
140 | lower bound |
STREAM_BUF_SIZE |
4096 | socket buffer size |
PFCONREQ_BUF_SIZE |
2048 | login request buffer |
SL_HEADER_SIZE |
6 | the SLheader is 6 bytes |
applType |
"sqlexec" |
12-char nul-padded application type |
applID |
"sqli" |
application ID string |
PROT_SQLIOL |
"ol" |
8-char nul-padded protocol identifier |
NET_TLITCP |
"tlitcp" |
8-char nul-padded network type |
floatType |
"IEEEM" |
float encoding type identifier (5 chars) |
3. Login sequence (binary path) 🟡
3a. Client→server: SLheader (6 bytes)
[short totalPduSize+6] # 2 bytes — PDU payload length + 6 (header overhead)
[byte slType] # 1 byte — 1=SLTYPE_CONREQ (connection request)
[byte slAttribute] # 1 byte — 60=PF_PROT_SQLI_0600 (protocol version)
[short slOptions] # 2 bytes — 0 in vanilla connect
Source: Connection.EncodeSLheader(o, pfPDUsize, 1, 60, 0).
3b. Client→server: PFheader (the binary login PDU)
This is the body that the SLheader announces. Total size depends on credentials and env vars. The byte layout, in emission order from Connection.encodeAscBinary():
[short 100] # SQ_ASSOC marker — start of "association" record
[short 101] # SQ_ASCBINARY marker — binary-format indicator
[int 61] # ?? known constant; meaning TBD
[short floatType.len+1] # = 6
[bytes "IEEEM"][byte 0] # float-format identifier, nul-terminated
[short 108] # SQ_ASCBPARMS marker — binary parameters block
[bytes "sqlexec\0\0\0\0\0"] # applType, fixed 12 bytes
[short version.len+1] # = 6
[bytes "9.280"][byte 0] # client version (hardcoded "9.280" — note: NOT JDBC version!)
[short serial.len+1] # = 12
[bytes "RDS#R000000"][byte 0] # client serial (hardcoded constant)
[short applID.len+1] # = 5
[bytes "sqli"][byte 0] # application ID
[int capabilities] # client capability flags (opaque bitfield)
[int 0] # reserved
[int 0] # reserved
[short 1] # ?? section marker (NOT in IfxMessageTypes)
[short username.len+1]
[bytes username][byte 0]
# password (optional)
if password is null:
[short 0] # zero-length password
else:
[short password.len+1]
[bytes password][byte 0]
[bytes "ol\0\0\0\0\0\0"] # PROT_SQLIOL — 8 bytes
[int 61] # ?? same magic as before
[bytes "tlitcp\0\0"] # NET_TLITCP — 8 bytes
[int 1] # UTYPE_INTERNET (= 1)
[short 104] # SQ_ASCINITREQ marker
[short 11] # ASF_XCONNECT (= 11)
[int stmtoptions] # = 3 | (sqlhGroup ? 0x2000000 : 0) | (trustedCtx ? 0x4000000 : 0)
# bit 0+1 = ASF_AMBIG_SEOL (=3)
# bit 25 = ASF_GRPREF (sqlh group)
# bit 26 = ASF_TRUSTCTXT (trusted context)
[short servername.len+1]
[bytes servername][byte 0]
# database (optional)
if dbname is null:
[short 0]
else:
[short dbname.len+1]
[bytes dbname][byte 0]
[short 0][short 0][short 0][short 0] # 4 reserved/empty option slots (8 bytes total)
# Environment vars (encodeEnv)
[short 106] # SQ_ASCENV marker
[short num_envvars]
for each (name, value) in envlist:
[short name.len+1]
[bytes name][byte 0]
[short value.len+1]
[bytes value][byte 0]
# Process info (encodeEnvPInfo)
[short 107] # SQ_ASCPINFO marker
[int 0] # reserved
[int procId] # OS process ID
[int threadId] # thread ID
[short hostname.len+1]
[bytes hostname][byte 0]
[short 0] # reserved
[short cwd.len+1]
[bytes cwd][byte 0]
# AppName section (SQ_ASCMISC_60)
[short 116] # SQ_ASCMISC_60
[short total_section_length] # = 10 + appname.len + 1
[int 0] # reserved
[int 0] # reserved
[short appname.len+1]
[bytes appname][byte 0]
# End-of-PDU
[short 127] # SQ_ASCEOT — end-of-transmission for login
3c. Server→client: response
Connection.recvConnectionResponse() reads:
[short totalLength] # length of the response PDU
[bytes (totalLength - 2)] # payload, parsed below
Then the payload starts with:
[byte SLType] # 2=CONACC (accepted), 3=CONREJ (rejected), 13=REDIRECT
[skipBytes 3] # slAttribute + slOptions — usually unused on response
[short 100] # SQ_ASSOC marker
[short 101] # SQ_ASCBINARY marker
... DecodeAscBinary payload ...
DecodeAscBinary(in) reads:
[skipBytes 4]
[short subLength]
[skipBytes subLength]
[short 108] # SQ_ASCBPARMS marker
[skipBytes 12]
[short verLen][bytes serverVersion] # e.g. "15.00.UC1"
[short serLen][bytes serverSerial]
[short appLen][bytes appID]
[int Cap_1][int Cap_2][int Cap_3] # server capability flags (3 × 4 bytes)
[skipBytes 2]
[short subLen1][skipBytes subLen1] # optional reserved
[short subLen2][skipBytes subLen2] # optional reserved
[skipBytes 24]
[short marker] # final disposition:
# 102 = SQ_ASCINITRESP → error/warning section follows
# 103 = redirect
# 127 = SQ_ASCEOT (clean)
Error block (when marker=102):
[skipBytes 6]
[short svcError]
[short osError]
[short Warnings]
[short numErrors]
for i in 0..numErrors-1:
[short offset] # if -1, stop
[readChar errMsg] # length-prefixed string
3d. Login PDU summary
- One client-emitted PDU for the entire login (username + password inline)
- One server-emitted PDU for the response (success → server version + capabilities; failure → error block)
- No challenge-response round trip for plain auth. PAM auth uses
SQ_CHALLENGE=129/SQ_RESPONSE=130AFTER the binary login PDU, but only if the server requests it. Plain-auth happy path is two messages total.
4. Message framing (post-login) 🟡
After successful login, messages are bare — no SLheader, no PFheader. Each message is:
[short messageType] # 2-byte big-endian tag from IfxMessageTypes
[payload] # variable, depends on message type
Evidence: Connection.disconnectOrderly() writes only:
byte[] b = JavaToIfxType.JavaToIfxSmallInt((short)56); // SQ_EXIT = 56
this.asfIfxOs.write(b);
this.asfIfxOs.flush();
And reads back exactly the same shape. No length prefix, no framing overhead. The receiving side keys off the message type tag to know how many bytes follow.
This is a stream-oriented protocol, not a packet-oriented one. The wire is a continuous sequence of [tag][payload][tag][payload]… and the parser must know each tag's payload structure to know where the next tag begins.
5. Message type tags 🟡
The full enumeration is in com.informix.jdbc.IfxMessageTypes (163 lines, ~140 constants). Key categories below.
Connection lifecycle (post-login)
| Tag | Decimal | Hex | Purpose |
|---|---|---|---|
SQ_VERSION |
53 | 0x35 | client/server version negotiation |
SQ_INFO |
81 | 0x51 | info request (sub-types: INFO_VERSION=2, INFO_TYPE=3, INFO_CAPABILITY=4, INFO_DB=5, INFO_ENV=6) |
SQ_PROTOCOLS |
126 | 0x7E | protocol negotiation |
SQ_CONNECT |
112 | 0x70 | connection setup |
SQ_DBOPEN |
36 | 0x24 | open database |
SQ_DBCLOSE |
37 | 0x25 | close database |
SQ_DBLIST |
26 | 0x1A | list databases |
SQ_DISCONNECT |
114 | 0x72 | logical disconnect |
SQ_EXIT |
56 | 0x38 | terminate session (on the wire for disconnect) |
SQ_EOT |
12 | 0x0C | end of transmission (returned by server on disconnect ack) |
Auth (server-initiated challenge-response, PAM only)
| Tag | Decimal | Purpose |
|---|---|---|
SQ_CHALLENGE |
129 | server challenges client (PAM) |
SQ_RESPONSE |
130 | client response to challenge |
SQ_ACCEPT |
127 | server accepts |
SQ_ACK |
128 | acknowledgement |
Plain-password auth does NOT use these. They are only for PAM and other challenge-response auth schemes. Skip MVP.
Statement execution
| Tag | Decimal | Purpose |
|---|---|---|
SQ_COMMAND |
1 | execute SQL directly (immediate execution, no preparation) |
SQ_PREPARE |
2 | prepare a statement (returns a handle) |
SQ_ID |
4 | reference a prepared statement by id |
SQ_BIND |
5 | bind a parameter to a prepared statement |
SQ_OPEN |
6 | open cursor on a prepared statement |
SQ_EXECUTE |
7 | execute a prepared statement |
SQ_DESCRIBE |
8 | describe columns of a prepared statement |
SQ_NFETCH |
9 | fetch next row(s) |
SQ_CLOSE |
10 | close cursor |
SQ_RELEASE |
11 | release prepared statement |
SQ_CURNAME |
3 | name a cursor |
Result responses (server→client)
| Tag | Decimal | Purpose |
|---|---|---|
SQ_TUPLE |
14 | a row of data |
SQ_DONE |
15 | end of result set / completion of DML |
SQ_EOT |
12 | end of transmission |
SQ_ERR |
13 | error |
Transactions
| Tag | Decimal | Purpose |
|---|---|---|
SQ_BEGIN |
35 | begin transaction |
SQ_BEGIN_NOREPL |
115 | begin (no replication) |
SQ_CMMTWORK |
19 | commit work |
SQ_RBWORK |
20 | rollback work |
SQ_SVPOINT |
21 | declare savepoint |
SQ_SQLISETSVPT |
137 | set savepoint (newer protocol) |
SQ_SQLIRELSVPT |
138 | release savepoint |
SQ_SQLIRBACKSVPT |
139 | rollback to savepoint |
Distributed transactions (XA) — Phase 6+
SQ_X* family: 65-74, 82. SQ_XBEGIN, SQ_XCOMMIT, SQ_XROLLBACK, SQ_XPREPARE, etc.
LOB / Blob — Phase 6+
SQ_FETCHBLOB (38), SQ_BLOB (39), SQ_BBIND (41), SQ_SBBIND (52), SQ_FILE_READ (106), SQ_FILE_WRITE (107).
RPC layer (sub-protocol, tag range 200-205) — Phase 6+
| Tag | Decimal | Purpose |
|---|---|---|
SQ_INVOKE |
200 | RPC invocation |
SQ_REPLY |
201 | RPC reply |
SQ_EXCEPTION |
202 | RPC exception |
SQ_VERSION_REQ |
203 | version request |
SQ_VERSION_REPLY |
204 | version reply |
SQ_AMFPARAM |
205 | AMF parameter (used by stored procedures?) |
6. Statement execution: SELECT 1 🟠
Hypothesis (to be validated by PCAP):
Client→server:
[short SQ_COMMAND=1]
[short sql.len+1]
[bytes "SELECT 1"][byte 0]
or possibly with extra wrapper bytes (e.g. statement ID, options). Unknown without capture.
Server→client (success):
[short SQ_DESCRIBE=8 or implicit description]
... column descriptors ...
[short SQ_TUPLE=14]
... row payload ...
[short SQ_DONE=15]
... completion data (rowcount, etc.) ...
[short SQ_EOT=12]
This will be validated and made specific by Phase 0 task #2 (cross-reference captures).
7. Result-set framing 🟠
Pending PCAP capture. Hypothesized structure:
- Each row is
[short SQ_TUPLE=14][row payload] - Row payload is the column values in order, encoded per their type's wire format (with the 16-bit alignment rule)
- NULL representation: TBD (probably a per-column flag bit or sentinel value)
- End-of-result:
[short SQ_DONE=15]followed by completion metadata (estimated rows, actual rows, etc.)
com.informix.jdbc.IfxColumnInfo likely holds the column-descriptor structure. Read this once we have a PCAP to align against.
8. Disconnection ✅ (well-corroborated by JDBC alone)
Client→server:
[short SQ_EXIT=56]
Server→client:
[short SQ_EXIT=56] # echo
or
[short SQ_EOT=12] # end of transmission
Server may interleave one or more [short SQ_XACTSTAT=99] records before the final ack (transaction status info during shutdown). Client reads and discards these until it sees 56 or 12.
Source: Connection.disconnectOrderly() (Connection.java:227-265).
9. Error responses 🟠
Two error paths observed:
9a. Login-time errors (during login PDU exchange)
Error block embedded in the response, marked by [short 102] (SQ_ASCINITRESP):
[skipBytes 6]
[short svcError] # Informix service error code
[short osError] # OS-level error code
[short numWarnings]
[short numErrors]
for i in 0..numErrors:
[short offset] # if -1, stop
[readChar errMsg] # length-prefixed string
Throws IfxASFRemoteException(svcError, osError, errMsg).
9b. Statement-time errors (post-login)
Hypothesized: [short SQ_ERR=13][error payload]. Structure of the payload TBD; needs PCAP. Likely contains SQLCODE, ISAM error code, error message text, possibly SQLSTATE. Look at IfxSqli error-decode methods once we have a capture.
10. Type codecs
10a. IDS type codes (column descriptors)
com.informix.lang.IfxTypes is the constants class. Pending — read this file and tabulate the type codes we care about for MVP (SMALLINT, INT, BIGINT, FLOAT, REAL, CHAR, VARCHAR, NCHAR, NVARCHAR, BOOLEAN, DATE, NULL marker).
10b. Wire encodings (Phase-2 MVP types)
| IDS Type | Type Code | Wire format | Reader | Writer |
|---|---|---|---|---|
| SMALLINT | TBD | 2 bytes BE | readSmallInt() |
writeSmallInt(short) |
| INTEGER | TBD | 4 bytes BE | readInt() |
writeInt(int) |
| BIGINT | TBD | 8 bytes BE | readLongBigint() |
writeLongBigint(long) |
| FLOAT (DOUBLE) | TBD | 8 bytes IEEE 754 | readDouble(prec) |
writeDouble(double) |
| REAL (SMALLFLOAT) | TBD | 4 bytes IEEE 754 | readReal(prec) |
writeReal(float) |
| CHAR(N) | TBD | length-prefixed, padded to even | readChar() |
writeChar(byte[]) |
| VARCHAR(N) | TBD | length-prefixed, padded to even | readChar() |
(similar) |
| BOOLEAN | TBD | 1 byte 't'/'f' (per Informix convention; verify) | TBD | TBD |
| DATE | TBD | 4 bytes — int day count from Informix epoch (1899-12-31, verify) | readDate() |
writeDate(Date) |
10c. Phase 6+ types
DATETIME (qualifier-byte precision), INTERVAL (qualifier-encoded), DECIMAL/NUMERIC (Informix BCD), MONEY, LVARCHAR, BYTE/TEXT (length-prefixed BLOBs), CLOB/BLOB (smart blobs with handles), ROW, COLLECTION (LIST/SET/MULTISET).
11. Open questions
- Exact byte layout of
SQ_COMMANDpayload — does it include statement options after the SQL text? - NULL representation in row data — null bitmap, sentinel value, or per-column flag?
- Result-set column-descriptor structure (the bytes that come back before the first
SQ_TUPLE). - How
Cap_1/Cap_2/Cap_3(server capability flags) are bit-allocated. - The leading
[int 61]magic in the login PDU — what does 61 mean? (Possibly version-related: PF_PROT_SQLI_0600 = 60, PF_PROT_SQLI_WITH_CSS = 61?) - Why the embedded
versionNumber = "9.280"string (not the JDBC version, not the server version). - The
[short 1]marker betweenCap_3and the username — what's it for? - Whether the server emits any banner at TCP-accept time before the client's login PDU.
All of these resolve from packet capture in Phase 0 task #7.
12. Wire-validation findings (2026-05-02 — captures vs JDBC source)
Captures collected via socat MITM relay (no sudo needed; listens on 9090, forwards to 9088):
socat -d -d -x TCP-LISTEN:9090,reuseaddr TCP:127.0.0.1:9088 2>docs/CAPTURES/<scenario>.socat.log &
IFX_PORT=9090 java -cp build/ifxjdbc.jar:build/ tests.reference.RefClient <scenario>
Three scenarios captured: 01-connect-only (1.7 KB), 02-select-1 (6.7 KB), 02-dml-cycle (9.9 KB). All in docs/CAPTURES/. The > lines are client→server, < are server→client.
Validated against the wire ✅
- Endianness is big-endian. Confirmed: SLheader
01 c3 01 3c 00 00decodes as length=0x01c3=451, slType=1, slAttribute=0x3c=60, slOptions=0. - Login PDU structure matches
Connection.encodeAscBinaryfield-by-field: SLheader 6 bytes, then SQ_ASSOC=100, SQ_ASCBINARY=101, int 61, IEEEM, SQ_ASCBPARMS=108, sqlexec padded, "9.280", "RDS#R000000", "sqli", capabilities, ..., username "informix", password "in4mix", PROT_SQLIOL "ol", int 61, NET_TLITCP "tlitcp", int 1, SQ_ASCINITREQ=104, ASF_XCONNECT=11, stmtoptions=3, servername, env vars (DBPATH, CLIENT_LOCALE=en_US.8859-1, CLNT_PAM_CAPABLE, DBDATE, IFX_UPDDESC, NODEFDAC), process info, AppName, SQ_ASCEOT=127. - Server response structure matches
DecodeAscBinary: SLheader (slType=2 CONACC, slAttribute=60), SQ_ASSOC, SQ_ASCBINARY, int 61, server's float type "IEEEI", "srvinfx" padded, the version string"IBM Informix Dynamic Server Version 15.0.1.0.3", "serial", "informix" instance name, the three capability ints, "on" + "soctcp" + secondary protocol identifiers, container hostname2327c4354ea8,/home/informix,/opt/ibm/informix/v15.0.1.0.3/bin/oninit, SQ_ASCEOT=127. - Post-login messages are bare (no SLheader, no length prefix). Each message is
[short tag][payload]. Confirmed across all post-login traffic in the captures. [short 0x000c](SQ_EOT=12) is a per-PDU flush/submit marker, not just a disconnect ack. Every client→server "logical request" in the post-login captures ends with00 0c. Server replies the same way. So the on-wire pattern is:[short tag1][payload1][short tag2][payload2]...[short SQ_EOT=12] # one logical request- Disconnection is bare
[short SQ_EXIT=56]both directions, sometimes with interleavedSQ_XACTSTAT=99records before the final ack. Confirmed.
Wire findings that AMENDED the JDBC-derived hypothesis 🔵
-
The capability section is three 4-byte ints (Cap_1, Cap_2, Cap_3) — confirming the JDBC source's three
writeIntcalls. The values are NOT all zero as my earlier text claimed; the wire shows:Cap_1 = 0x0000013c(= 316). Looks like(capability_class << 8) | protocol_version, whereprotocol_version = 0x3c = PF_PROT_SQLI_0600(= 60). The high byte0x01appears to be a "supported feature class" indicator.Cap_2 = 0Cap_3 = 0
An earlier draft of this section misread the byte alignment and claimed
Cap_1=1, Cap_2=0x3c000000. That was wrong — the0x3cbyte that appeared to be inCap_2was actually the last byte of Cap_1. Phase 1 caught this when we wrote a byte-for-byte PDU diff against the JDBC reference (tests/test_pdu_match.py); the dev-image server is permissive enough to accept the wrong values, so the connection succeeded but the bytes weren't structurally identical to JDBC's reference PDU. Lesson: server-accepts ≠ structurally-correct. Always diff against IBM's reference for protocol-bearing bytes.Our
connections.pynow sends the correct values. The PDU diff test asserts byte-for-byte match for offsets 2..280 (everything except the SLheader length and process-specific tail).
Validated post-login execution shape 🔵
The first SELECT the JDBC driver issues after login is internal — looking up locale info from informix.systables. The wire bytes for that 88-byte client→server message:
00 02 short SQ_PREPARE = 2
00 00 short 0 (flags / reserved)
00 00 00 49 int 73 (length of SQL bytes including trailing nul)
73 65 6c 65 63 74 20 46 49 52 53 54 20 31 20 73 69 74 65 20 66 72 6f 6d 20 69 6e 66 6f 72 6d 69 78 2e 73 79 73 74 61 62 6c 65 73 20 77 68 65 72 65 20 74 61 62 6e 61 6d 65 20 3d 20 27 20 47 4c 5f 43 4f 4c 4c 41 54 45 27 00
73 bytes of SQL text (incl. nul):
"select FIRST 1 site from informix.systables where tabname = ' GL_COLLATE'\0"
00 16 short 22 (= ?)
00 31 short 49 (= ?)
00 0c short SQ_EOT = 12 — flush
So the SQ_PREPARE message body is approximately:
[short SQ_PREPARE=2]
[short flags] # observed 0
[int sqlLen] # length INCLUDING trailing nul terminator
[bytes sql][nul]
[short ?] # observed 0x16 — purpose TBD
[short ?] # observed 0x31 — purpose TBD
[short SQ_EOT=12] # flush
The corresponding response (82 bytes) starts 00 08 (= SQ_DESCRIBE=8), with column metadata including 00 04 28 63 6f 6e 73 74 61 6e 74 29 00 00 = the column name "(constant)\0" (length-prefixed at length=4 — but content is "(constant)" 10 chars + nul; so the length=4 here is NOT the column name length). The exact column-descriptor layout needs more analysis, deferred to Phase 2.
Phase 0 exit verdict
| Phase 0 exit criterion | 🟡 JDBC | 🔵 PCAP | Status |
|---|---|---|---|
| Wire framing primitives (endianness, widths, padding) | ✅ | ✅ | ✅ confirmed |
| Login byte layout (binary path) | ✅ | ✅ | ✅ confirmed |
| Disconnection | ✅ | ✅ | ✅ confirmed |
| Message-type tag enumeration | ✅ | ✅ (sample) | ✅ confirmed |
SQ_PREPARE/SELECT request layout |
partial | ✅ | ✅ confirmed (high-level) |
| Result-set column descriptor layout | ⬜ | partial | 🟠 needs Phase 2 work |
| Error response (login) | ✅ | ⬜ (no failure case captured) | 🟠 ok for Phase 0 |
| Error response (statement-time) | ⬜ | ⬜ | 🟠 deferred to Phase 2 |
| Capability bitfield semantics | ⬜ | observed | 🟠 ok for Phase 0 |
Phase 0 exit recommendation: 🟢 GO. The four hard exit criteria (login, message tags, SELECT round-trip, JDBC↔PCAP corroboration) are confirmed. The remaining gaps (result-set descriptor exact layout, error responses, capability semantics) are bounded and tractable — they'll be characterized in Phase 2 against the existing captures + targeted new captures, and don't change the project's architecture.
The "narrow scope" off-ramp is not needed. The wire protocol is well-formed, plain-password auth works, and the mismatch between JDBC source and wire on capability ints is small and explainable (decompiler artifact). Proceed to Phase 1.