Data corruption affecting every Informix version including 15. Two of the
three silently corrupt columns AFTER the offending one, so the damage
shows up far from its cause.
BOOLEAN: described as UDTFIXED(41) with encoded_length=1, but carries the
full UDT envelope on the wire — [1-byte indicator][4-byte length][data],
six bytes for a one-byte value. We read one byte and left five behind.
Captured payload for (INT, BOOLEAN 't', INT, VARCHAR 'tail'):
00 01 b2 07 | 00 00 00 00 01 74 | 00 03 64 0e | 04 74 61 69 6c
Before: (111111, b'\x00', 1, '\x00\x03d\x0e\x04tail')
After: (111111, True, 222222, 'tail')
NCHAR: fixed-width and space-padded like CHAR, but we had it grouped with
the byte-length-prefixed types. NCHAR(10) holding 'nch' read 0x6E ('n') as
a 110-byte length. Silent truncation alone, struct.error with any column
following. NVARCHAR really is length-prefixed and is unchanged; there's a
test guarding both sides now.
INT8/SERIAL8: absent from FIXED_WIDTHS, fell through to the unknown-type
path and surfaced as raw bytes. INT8 is not BIGINT — 10 bytes,
sign-magnitude, halves stored high-last:
bytes 0-1 sign word (0=NULL, 1=pos, 0xFFFF=neg)
bytes 2-5 LOW 32 bits, bytes 6-9 HIGH 32 bits
+n and -n share magnitude bytes, so a two's-complement read is wrong for
every negative while looking correct for every positive.
Not a version bug. Reported against Informix 12, but the same 28-type
round-trip against 12.10.FC12W1DE and 15.0.1.0.3DE produced byte-identical
wire output. It read as version-specific only because INT8/SERIAL8 dominate
12-era schemas while our fixtures use BIGINT.
251 tests missed all three because no fixture used these types. Added
tests/test_type_framing.py (20 integration) and tests/test_int8_unit.py
(14 unit, wire vectors from both servers). Each type is tested twice —
alone, and with trailing columns, since only the latter catches desync.
Verified: 271/271 integration on 15; 241/241 non-smart-LOB on 12.10 (the
LOB tests need an sbspace that image lacks); 20/20 framing tests on both.
Also corrected README and _fastpath docstring, which asserted 12.10
compatibility that had never been tested. The claims held up, but they
were guesses when written and cost a user debugging time.
72 lines
2.7 KiB
Python
72 lines
2.7 KiB
Python
"""Unit tests for the INT8 / SERIAL8 codec — no server required.
|
|
|
|
The byte vectors below were captured off the wire from both Informix
|
|
12.10.FC12W1DE and 15.0.1.0.3DE, which emit byte-identical encodings.
|
|
|
|
INT8 is sign-magnitude across two 32-bit halves, stored high-half-last:
|
|
|
|
bytes 0-1 sign word: 0 = NULL, 1 = positive, 0xFFFF = negative
|
|
bytes 2-5 LOW 32 bits, big-endian unsigned
|
|
bytes 6-9 HIGH 32 bits, big-endian unsigned
|
|
|
|
The trap: +n and -n have *identical* magnitude bytes. Anything that
|
|
treats this as a two's-complement integer is wrong for every negative
|
|
value while looking correct for every positive one.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from informix_db._types import IfxType
|
|
from informix_db.converters import FIXED_WIDTHS, _decode_int8
|
|
|
|
# (hex bytes, expected value) — captured from the wire.
|
|
WIRE_VECTORS = [
|
|
("0001be991a140000001c", 123456789012),
|
|
("ffffbe991a140000001c", -123456789012),
|
|
("00010000002a00000000", 42),
|
|
("ffff0000002a00000000", -42),
|
|
("00010000000000000000", 0),
|
|
("00000000000000000000", None), # sign word 0 -> NULL
|
|
("0001ffffffffffffffff", 2**64 - 1), # both halves saturated
|
|
("00010000000100000000", 1),
|
|
("00010000000000000001", 1 << 32), # high half only
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(("hexbytes", "expected"), WIRE_VECTORS)
|
|
def test_decode_int8_wire_vectors(hexbytes: str, expected: int | None) -> None:
|
|
assert _decode_int8(bytes.fromhex(hexbytes)) == expected
|
|
|
|
|
|
def test_positive_and_negative_share_magnitude_bytes() -> None:
|
|
"""The whole reason a naive 8-byte read gets negatives wrong."""
|
|
pos = bytes.fromhex("0001be991a140000001c")
|
|
neg = bytes.fromhex("ffffbe991a140000001c")
|
|
assert pos[2:] == neg[2:], "magnitude bytes should be identical"
|
|
assert _decode_int8(pos) == -_decode_int8(neg)
|
|
|
|
|
|
def test_null_sign_word_beats_nonzero_magnitude() -> None:
|
|
"""Sign word 0 means NULL regardless of what the magnitude bytes hold."""
|
|
assert _decode_int8(bytes.fromhex("0000be991a140000001c")) is None
|
|
|
|
|
|
def test_short_payload_raises() -> None:
|
|
with pytest.raises(ValueError, match="too short"):
|
|
_decode_int8(bytes.fromhex("0001be991a14")) # 6 bytes, need 10
|
|
|
|
|
|
def test_int8_registered_as_ten_bytes() -> None:
|
|
"""A width of 8 here would desync every row containing an INT8."""
|
|
assert FIXED_WIDTHS[IfxType.INT8] == 10
|
|
assert FIXED_WIDTHS[IfxType.SERIAL8] == 10
|
|
|
|
|
|
def test_int8_width_differs_from_bigint() -> None:
|
|
"""INT8 (17) and BIGINT (52) are different types with different
|
|
widths — conflating them is the easy mistake."""
|
|
assert FIXED_WIDTHS[IfxType.INT8] != FIXED_WIDTHS[IfxType.BIGINT]
|
|
assert FIXED_WIDTHS[IfxType.BIGINT] == 8
|