informix-db/tests/test_capabilities_unit.py
Ryan Malloy 36478f02c4 Decode the SQ_PROTOCOLS capability negotiation (2026.08.27)
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.
2026-08-27 02:18:08 -06:00

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]