Closes the last open item from the Informix 12 field report. The driver
hardcodes several wire-framing choices that SQLI actually negotiates.
Those choices are correct on every server we've measured, but "correct
as far as we know" and "checked" are different things, and a framing
mismatch corrupts rows silently.
We were already sending SQ_PROTOCOLS with the same 8-byte client offer
IBM's JDBC driver uses, and discarding the reply. Now we decode it.
New informix_db.ServerCapabilities, reachable from any connection:
conn.server_capabilities.four_byte_offset
conn.server_capabilities.varchar_var_len
conn.server_capabilities.violated_assumptions() # [] when we agree
conn.server_version
violated_assumptions() names each place we emit or parse a fixed wire
shape that is actually capability-gated. Non-empty at connect time logs
a warning naming the bit, so an untested server produces a diagnosable
complaint instead of quiet corruption.
Nothing branches on these bits yet — this is observation and validation
only. The hardcoded framing is correct on all three supported servers,
and rewriting working parse paths to be conditional without a server
that needs it trades certainty for risk.
The measurement, and why 12/14/15 are interchangeable:
15.0.1.0.3 bdbe9ffe7fb7ffef ff
14.10.FC7W1 bdbe9ffe7fb7ffef f8
12.10.FC12W1DE bdbe9ffe7fb7ffef f0
^^^^^^^^^^^^^^^^ identical
The first 64 bits are byte-identical. Those releases don't merely behave
alike, they negotiate exactly the same capability set.
Two details worth recording. The reply is NINE bytes; JDBC's
enhancedProtocolMechanism switches on case 0..7 and drops the ninth, so
its BitSet(64) never sees it — yet that dropped byte is the only part
that differs between releases. And Cap_1 in the login response is not a
server version, it's the client's declared protocol level echoed back,
which is why JDBC tests == 316 rather than >=. The version string there
is the internal one: 12.10 reports 9.56, 14.10 reports 9.59. At the
protocol level both really are 9.x servers.
This also retires isUSVER as a red herring: it's one of six bits JDBC
pre-sets for any non-zero Cap_1, and Java's BitSet.set never clears, so
it is true on every modern server regardless of the mask.
Separately, fixed __version__. The distribution was renamed informix-db
-> informix-driver on 2026-05-08 but __init__ kept looking up the old
name. importlib.metadata needs the distribution name, not the module
name, and the miss fails silently — so every install of the renamed
package has reported "0.0.0+local". It escaped notice because a stale
informix-db distribution lingered in the dev venv and answered the
query. tests/test_package_metadata.py now pins the name against
[project].name; verified it catches the bug by reintroducing it.
247/247 integration on 15, 14.10, and 12.10. 120 unit tests.
201 lines
7.4 KiB
Python
201 lines
7.4 KiB
Python
"""Unit tests for SQ_PROTOCOLS capability decoding — no server required.
|
|
|
|
The masks below were captured from live servers on 2026-08-27. Their most
|
|
interesting property is that the first eight bytes are identical across
|
|
Informix 12.10, 14.10, and 15 — which is why those three releases speak an
|
|
indistinguishable SQLI dialect.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
|
|
import pytest
|
|
|
|
from informix_db._capabilities import (
|
|
BIT_FOUR_BYTE_OFFSET,
|
|
BIT_REMOVE_64K_LIMIT,
|
|
BIT_USVER,
|
|
BIT_VARCHAR_VAR_LEN,
|
|
CLIENT_PROTOCOLS_MASK,
|
|
ENHANCED_PROTOCOL_CAP,
|
|
ServerCapabilities,
|
|
_decode_bits,
|
|
)
|
|
|
|
MASK_15 = bytes.fromhex("bdbe9ffe7fb7ffefff")
|
|
MASK_1410 = bytes.fromhex("bdbe9ffe7fb7ffeff8")
|
|
MASK_1210 = bytes.fromhex("bdbe9ffe7fb7ffeff0")
|
|
|
|
ALL_MASKS = [
|
|
pytest.param(MASK_15, id="15.0.1.0.3"),
|
|
pytest.param(MASK_1410, id="14.10.FC7W1"),
|
|
pytest.param(MASK_1210, id="12.10.FC12W1DE"),
|
|
]
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Bit expansion
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_bit_numbering_is_msb_first() -> None:
|
|
assert _decode_bits(b"\x80") == {0}
|
|
assert _decode_bits(b"\x01") == {7}
|
|
assert _decode_bits(b"\x00\x80") == {8}
|
|
assert _decode_bits(b"\x00\x01") == {15}
|
|
|
|
|
|
def test_bit_expansion_of_first_captured_byte() -> None:
|
|
"""0xBD is 1011 1101 -> bits 0, 2, 3, 4, 5, 7."""
|
|
assert _decode_bits(b"\xbd") == {0, 2, 3, 4, 5, 7}
|
|
|
|
|
|
def test_empty_mask_yields_no_bits() -> None:
|
|
assert _decode_bits(b"") == set()
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# The captured masks
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_first_eight_bytes_identical_across_versions() -> None:
|
|
"""The finding this whole module exists to record: 12.10, 14.10, and 15
|
|
negotiate exactly the same 64-bit capability set. Only the 9th byte —
|
|
which IBM's own JDBC driver discards — differs."""
|
|
assert MASK_15[:8] == MASK_1410[:8] == MASK_1210[:8]
|
|
assert len({MASK_15[8], MASK_1410[8], MASK_1210[8]}) == 3
|
|
|
|
|
|
@pytest.mark.parametrize("mask", ALL_MASKS)
|
|
def test_framing_bits_set_on_every_tested_server(mask: bytes) -> None:
|
|
"""Every wire-framing choice this driver hardcodes is one the server
|
|
actually negotiated. If this ever fails, the row decoder is wrong."""
|
|
caps = ServerCapabilities.from_wire(mask, cap_1=ENHANCED_PROTOCOL_CAP)
|
|
assert caps.four_byte_offset, "describe offsets would be misparsed"
|
|
assert caps.varchar_var_len, "VARCHAR framing would be misparsed"
|
|
assert caps.remove_64k_limit, "fast-path length prefix would be wrong"
|
|
assert caps.bigint
|
|
assert caps.long_id
|
|
assert caps.lvarchar_gt_2k
|
|
assert caps.gls
|
|
|
|
|
|
@pytest.mark.parametrize("mask", ALL_MASKS)
|
|
def test_no_violated_assumptions_on_tested_servers(mask: bytes) -> None:
|
|
caps = ServerCapabilities.from_wire(mask, cap_1=ENHANCED_PROTOCOL_CAP)
|
|
assert caps.violated_assumptions() == []
|
|
|
|
|
|
@pytest.mark.parametrize("mask", ALL_MASKS)
|
|
def test_ninth_byte_is_decoded(mask: bytes) -> None:
|
|
"""JDBC drops bits 64+; we keep them because they're the only part
|
|
that varies between releases."""
|
|
caps = ServerCapabilities.from_wire(mask, cap_1=ENHANCED_PROTOCOL_CAP)
|
|
assert any(b >= 64 for b in caps.bits)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Pre-set bits — the reason isUSVER is a dead end
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_preset_bits_applied_when_cap1_nonzero() -> None:
|
|
"""JDBC pre-sets {0,2,3,4,49,51} for any non-zero Cap_1 and BitSet.set
|
|
never clears, so these are true on any modern server regardless of the
|
|
mask. That's why isUSVER never varies and can't explain version-specific
|
|
behaviour."""
|
|
caps = ServerCapabilities.from_wire(b"\x00" * 9, cap_1=ENHANCED_PROTOCOL_CAP)
|
|
assert {0, 2, 3, 4, 49, 51}.issubset(caps.bits)
|
|
assert caps.usver
|
|
|
|
|
|
def test_preset_bits_not_applied_when_cap1_zero() -> None:
|
|
caps = ServerCapabilities.from_wire(b"\x00" * 9, cap_1=0)
|
|
assert caps.bits == frozenset()
|
|
assert not caps.usver
|
|
|
|
|
|
def test_preset_cannot_clear_a_bit_the_mask_set() -> None:
|
|
caps = ServerCapabilities.from_wire(MASK_15, cap_1=ENHANCED_PROTOCOL_CAP)
|
|
assert caps.has(BIT_USVER)
|
|
assert caps.has(BIT_FOUR_BYTE_OFFSET)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Assumption violations — the diagnostic path
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_missing_four_byte_offset_is_reported() -> None:
|
|
# Everything set except bit 50.
|
|
bits = bytearray(b"\xff" * 9)
|
|
bits[BIT_FOUR_BYTE_OFFSET // 8] &= ~(0x80 >> (BIT_FOUR_BYTE_OFFSET % 8)) & 0xFF
|
|
caps = ServerCapabilities.from_wire(bytes(bits), cap_1=ENHANCED_PROTOCOL_CAP)
|
|
problems = caps.violated_assumptions()
|
|
assert len(problems) == 1
|
|
assert "4-byte describe offsets" in problems[0]
|
|
|
|
|
|
def test_missing_varchar_var_len_is_reported() -> None:
|
|
bits = bytearray(b"\xff" * 9)
|
|
bits[BIT_VARCHAR_VAR_LEN // 8] &= ~(0x80 >> (BIT_VARCHAR_VAR_LEN % 8)) & 0xFF
|
|
caps = ServerCapabilities.from_wire(bytes(bits), cap_1=ENHANCED_PROTOCOL_CAP)
|
|
problems = caps.violated_assumptions()
|
|
assert len(problems) == 1
|
|
assert "variable-length VARCHAR" in problems[0]
|
|
|
|
|
|
def test_missing_remove_64k_is_reported() -> None:
|
|
bits = bytearray(b"\xff" * 9)
|
|
bits[BIT_REMOVE_64K_LIMIT // 8] &= ~(0x80 >> (BIT_REMOVE_64K_LIMIT % 8)) & 0xFF
|
|
caps = ServerCapabilities.from_wire(bytes(bits), cap_1=ENHANCED_PROTOCOL_CAP)
|
|
problems = caps.violated_assumptions()
|
|
assert len(problems) == 1
|
|
assert "64K limit" in problems[0]
|
|
|
|
|
|
def test_all_zero_mask_reports_every_assumption() -> None:
|
|
caps = ServerCapabilities.from_wire(b"\x00" * 9, cap_1=ENHANCED_PROTOCOL_CAP)
|
|
assert len(caps.violated_assumptions()) == 3
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Misc
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_client_offer_matches_jdbc_reference() -> None:
|
|
"""IfxSqliConnect.clientProtocols, verbatim. Changing this changes what
|
|
the server negotiates, so it is pinned."""
|
|
jdbc_reference = bytes([0xFF, 0xFC, 0x7F, 0xFC, 0x3C, 0x8C, 0xAA, 0x97])
|
|
assert jdbc_reference == CLIENT_PROTOCOLS_MASK
|
|
assert len(CLIENT_PROTOCOLS_MASK) == 8
|
|
|
|
|
|
def test_enhanced_protocol_requires_exact_cap1() -> None:
|
|
"""JDBC tests == 316, not >=, because the server echoes the client's own
|
|
declared level rather than reporting its own version."""
|
|
assert ServerCapabilities.from_wire(MASK_15, cap_1=316).enhanced_protocol
|
|
assert not ServerCapabilities.from_wire(MASK_15, cap_1=315).enhanced_protocol
|
|
assert not ServerCapabilities.from_wire(MASK_15, cap_1=317).enhanced_protocol
|
|
|
|
|
|
def test_repr_is_readable_and_includes_mask() -> None:
|
|
caps = ServerCapabilities.from_wire(
|
|
MASK_15, cap_1=316, server_version="IBM Informix Dynamic Server Version 15"
|
|
)
|
|
text = repr(caps)
|
|
assert "bdbe9ffe7fb7ffefff" in text
|
|
assert "usver" in text
|
|
assert "cap_1=316" in text
|
|
|
|
|
|
def test_capabilities_are_frozen() -> None:
|
|
"""Immutable so a connection's negotiated state can't be edited after
|
|
the fact and quietly disagree with what's on the wire."""
|
|
caps = ServerCapabilities.from_wire(MASK_15, cap_1=316)
|
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
caps.cap_1 = 1 # type: ignore[misc]
|