Ryan Malloy 9b1fd8af2c Phase 1: pure-Python SQLI login works end-to-end
This commit takes informix-db from documentation-only (Phase 0 spike)
to a functional connect() / close() against a real Informix server.
To our knowledge, this is the first pure-socket Informix client in any
language — no CSDK, no JVM, no native libraries.

Layered architecture per the plan, mirroring PyMySQL's shape:

  src/informix_db/
    __init__.py        — PEP 249 surface (connect, exceptions, paramstyle="numeric")
    exceptions.py      — full PEP 249 hierarchy declared up front
    _socket.py         — raw socket I/O (read_exact, write_all, timeouts)
    _protocol.py       — IfxStreamReader / IfxStreamWriter framing primitives
                         (big-endian, 16-bit-aligned variable payloads,
                         length-prefixed nul-terminated strings)
    _messages.py       — SQ_* tags from IfxMessageTypes + ASF/login markers
    _auth.py           — pluggable auth handlers; plain-password is the
                         only Phase-1 implementation
    connections.py     — Connection class: builds the binary login PDU
                         (SLheader + PFheader byte-for-byte per
                         PROTOCOL_NOTES.md §3), sends it, parses the
                         server response, wires up close()

Phase 1 design decisions locked in DECISION_LOG.md:
  - paramstyle = "numeric" (matches Informix ESQL/C convention)
  - Python >= 3.10
  - autocommit defaults to off (PEP 249 implicit)
  - License: MIT
  - Distribution name: informix-db (verified PyPI-available)

Test coverage: 34 unit tests (codec round-trips against synthetic byte
streams; observed login-PDU values from the spike captures asserted as
exact byte literals) + 6 integration tests (connect, idempotent close,
context manager, bad-password → OperationalError, bad-host →
OperationalError, cursor() raises NotImplementedError).

  pytest                 — runs 34 unit tests, no Docker needed
  pytest -m integration  — runs 6 integration tests against the
                           Developer Edition container (pinned by digest
                           in tests/docker-compose.yml)
  pytest -m ""           — runs everything

ruff is clean across src/ and tests/.

One bug found during smoke testing: threading.get_ident() can exceed
signed 32-bit on some processes, overflowing struct.pack("!i"). Fixed
the same way the JDBC reference does — clamp to signed 32-bit, fall
back to 0 if out of range. The field is diagnostic only.

One protocol-level observation that AMENDED the JDBC source reading:
the "capability section" in the login PDU is three independently
negotiated 4-byte ints (Cap_1=1, Cap_2=0x3c000000, Cap_3=0), not one
int + 8 reserved zero bytes as my CFR decompile read suggested. The
server echoes them back identically. Trust the wire over the
decompiler.

Phase 1 verification matrix (from PROTOCOL_NOTES.md §12):
  - Login byte layout: confirmed (server accepts our pure-Python PDU)
  - Disconnection: confirmed (SQ_EXIT round-trip works)
  - Framing primitives: confirmed (34 unit tests)
  - Error path: bad password → OperationalError, bad host → OperationalError

Phase 2 (Cursor / SELECT / basic types) is the next phase. The hard
unknowns there — exact column-descriptor layout, statement-time error
format — were called out as bounded gaps in Phase 0 and have existing
captures (02-select-1.socat.log, 02-dml-cycle.socat.log) to characterize
against.
2026-05-02 19:10:24 -06:00

59 lines
1.9 KiB
Python

"""Authentication handlers for the SQLI binary login PDU.
Auth in modern Informix has several mechanisms (plain password, password
obfuscation, PAM challenge/response, GSSAPI/Kerberos, trusted context).
For Phase 1 we ship the simplest one — plain password — and expose a
pluggable shape so later phases add new methods without touching
``connections.py``.
Each handler contributes the username/password section to the login PDU.
The rest of the PDU (markers, capabilities, env vars, process info) is
assembled by ``connections.py`` and is auth-method-independent.
"""
from __future__ import annotations
from collections.abc import Callable
from ._protocol import IfxStreamWriter
# Type alias: an auth handler appends its credentials section to the writer.
AuthHandler = Callable[[IfxStreamWriter, str, str | None], None]
def write_plain_password(
writer: IfxStreamWriter,
username: str,
password: str | None,
) -> None:
"""Write the username + password section of the login PDU.
Layout (matches ``Connection.encodeAscBinary`` lines that emit
username and password):
``[short username.len+1][bytes username][nul]``
then either ``[short 0]`` (no password) or
``[short password.len+1][bytes password][nul]``.
Plain password is sent inline; the server compares it directly to its
user database. There is no salt, no hash, no challenge round-trip.
Use TLS (Phase 6+) if the network is untrusted.
"""
writer.write_string_with_nul(username)
if password is None:
writer.write_short(0)
else:
writer.write_string_with_nul(password)
# Dispatch table — Phase 6+ adds: "obfuscate" (SHA-256+nonstd-base64),
# "pam" (challenge/response), "gssapi" (Kerberos).
HANDLERS: dict[str, AuthHandler] = {
"plain": write_plain_password,
}
def get_handler(method: str) -> AuthHandler:
"""Look up an auth handler by name. Raises KeyError if unknown."""
return HANDLERS[method]