informix-db/tests/test_int8_unit.py
Ryan Malloy 87b5b7c354 Fix three tuple-framing bugs: BOOLEAN, NCHAR, INT8/SERIAL8 (2026.05.08.2)
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.
2026-08-26 23:40:39 -06:00

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