diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5bac75b --- /dev/null +++ b/Makefile @@ -0,0 +1,91 @@ +# informix-db — common dev commands +# +# uv-managed; run `make help` for the full target list. + +# Image digest pinned in tests/docker-compose.yml; mirrored here for +# tab-complete-able commands like `make ifx-logs` that don't go through +# docker-compose. +IFX_CONTAINER ?= informix-db-test + +.PHONY: help install lint format test test-integration test-all test-pdu \ + ifx-up ifx-down ifx-logs ifx-shell ifx-status \ + capture clean + +help: ## Show this help + @grep -E '^[a-zA-Z_-]+:.*## ' $(MAKEFILE_LIST) \ + | awk -F':.*## ' '{printf " %-20s %s\n", $$1, $$2}' + +# ---------------------------------------------------------------------------- +# Python / dev workflow +# ---------------------------------------------------------------------------- + +install: ## Sync dev dependencies (uv sync --extra dev) + uv sync --extra dev + +lint: ## Run ruff + uv run ruff check src/ tests/ + +format: ## Auto-format with ruff + uv run ruff format src/ tests/ + uv run ruff check src/ tests/ --fix + +test: ## Run unit tests (no Docker required) + uv run pytest + +test-integration: ## Run integration tests (needs Informix container; see `make ifx-up`) + uv run pytest -m integration + +test-all: ## Run unit + integration tests + uv run pytest -m "" + +test-pdu: ## Run only the JDBC-vs-Python PDU regression test + uv run pytest tests/test_pdu_match.py -v + +# ---------------------------------------------------------------------------- +# Informix dev container +# ---------------------------------------------------------------------------- + +ifx-up: ## Start the Informix dev container (pinned by digest) + docker compose -f tests/docker-compose.yml up -d + @echo " Container: $(IFX_CONTAINER)" + @echo " Listener: 127.0.0.1:9088 (SQLI native)" + @echo " Login: informix / in4mix on database sysmaster" + +ifx-down: ## Stop and remove the Informix container + docker compose -f tests/docker-compose.yml down + +ifx-logs: ## Tail the container logs + docker logs -f $(IFX_CONTAINER) + +ifx-shell: ## Drop into a shell inside the container + docker exec -it $(IFX_CONTAINER) bash + +ifx-status: ## Check container health and listener readiness + @docker ps --filter name=$(IFX_CONTAINER) --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' + @nc -zv 127.0.0.1 9088 2>&1 | head -1 + +# ---------------------------------------------------------------------------- +# Phase 0 spike: re-capture wire traffic against the dev container +# ---------------------------------------------------------------------------- + +capture: ## Re-capture all three reference scenarios (JDBC) under socat + @for s in connect-only select-1 dml-cycle; do \ + echo "=== $$s ==="; \ + socat -d -d -x TCP-LISTEN:9090,reuseaddr TCP:127.0.0.1:9088 \ + 2>"docs/CAPTURES/$$s.socat.log" & \ + SOCAT_PID=$$!; \ + sleep 0.4; \ + IFX_PORT=9090 java -cp "build/ifxjdbc.jar:build/" tests.reference.RefClient $$s; \ + sleep 0.3; \ + kill $$SOCAT_PID 2>/dev/null; \ + wait 2>/dev/null; \ + echo " → docs/CAPTURES/$$s.socat.log"; \ + done + +# ---------------------------------------------------------------------------- +# Cleanup +# ---------------------------------------------------------------------------- + +clean: ## Remove build artifacts and caches (keeps captures and decompiled JDBC source) + rm -rf dist/ .pytest_cache/ .ruff_cache/ .mypy_cache/ + find src tests -name __pycache__ -type d -exec rm -rf {} + diff --git a/docs/CAPTURES/03-py-connect-only.socat.log b/docs/CAPTURES/03-py-connect-only.socat.log new file mode 100644 index 0000000..85dab77 --- /dev/null +++ b/docs/CAPTURES/03-py-connect-only.socat.log @@ -0,0 +1,16 @@ +2026/05/02 20:10:06 socat[3937281] N listening on AF=2 0.0.0.0:9090 +2026/05/02 20:10:07 socat[3937281] N accepting connection from AF=2 127.0.0.1:59562 on AF=2 127.0.0.1:9090 +2026/05/02 20:10:07 socat[3937281] N opening connection to 127.0.0.1:9088 +2026/05/02 20:10:07 socat[3937281] N opening connection to AF=2 127.0.0.1:9088 +2026/05/02 20:10:07 socat[3937281] N successfully connected from local address AF=2 127.0.0.1:39822 +2026/05/02 20:10:07 socat[3937281] N successfully connected to 127.0.0.1:9088 +2026/05/02 20:10:07 socat[3937281] N starting data transfer loop with FDs [6,6] and [5,5] +> 2026/05/02 20:10:07.218292 length=395 from=0 to=394 + 01 8b 01 3c 00 00 00 64 00 65 00 00 00 3d 00 06 49 45 45 45 4d 00 00 6c 73 71 6c 65 78 65 63 00 00 00 00 00 00 06 39 2e 32 38 30 00 00 0c 52 44 53 23 52 30 30 30 30 30 30 00 00 05 73 71 6c 69 00 00 00 00 01 3c 00 00 00 00 00 00 00 00 01 00 09 69 6e 66 6f 72 6d 69 78 00 00 07 69 6e 34 6d 69 78 00 6f 6c 00 00 00 00 00 00 00 00 00 3d 74 6c 69 74 63 70 00 00 00 00 00 01 00 68 00 0b 00 00 00 03 00 09 69 6e 66 6f 72 6d 69 78 00 00 0a 73 79 73 6d 61 73 74 65 72 00 00 00 00 00 00 00 00 00 00 6a 00 06 00 07 44 42 50 41 54 48 00 00 02 2e 00 00 0e 43 4c 49 45 4e 54 5f 4c 4f 43 41 4c 45 00 00 0d 65 6e 5f 55 53 2e 38 38 35 39 2d 31 00 00 11 43 4c 4e 54 5f 50 41 4d 5f 43 41 50 41 42 4c 45 00 00 02 31 00 00 07 44 42 44 41 54 45 00 00 06 59 34 4d 44 2d 00 00 0c 49 46 58 5f 55 50 44 44 45 53 43 00 00 02 31 00 00 09 4e 4f 44 45 46 44 41 43 00 00 03 6e 6f 00 00 6b 00 00 00 00 00 3c 14 0d 00 00 00 00 00 0b 72 70 6d 2d 62 75 6c 6c 65 74 00 00 00 00 29 2f 68 6f 6d 65 2f 72 70 6d 2f 63 6c 61 75 64 65 2f 69 6e 66 6f 72 6d 69 78 2f 70 79 74 68 6f 6e 2d 6c 69 62 72 61 72 79 00 00 74 00 21 00 00 00 00 00 00 00 00 00 17 69 6e 66 6f 72 6d 69 78 2d 64 62 40 70 69 64 33 39 33 37 32 39 33 00 00 7f +< 2026/05/02 20:10:07.221921 length=276 from=0 to=275 + 01 14 02 3c 10 00 00 64 00 65 00 00 00 3d 00 06 49 45 45 45 49 00 00 6c 73 72 76 69 6e 66 78 00 00 00 00 00 00 2f 49 42 4d 20 49 6e 66 6f 72 6d 69 78 20 44 79 6e 61 6d 69 63 20 53 65 72 76 65 72 20 56 65 72 73 69 6f 6e 20 31 35 2e 30 2e 31 2e 30 2e 33 00 00 07 73 65 72 69 61 6c 00 00 09 69 6e 66 6f 72 6d 69 78 00 00 00 01 3c 00 00 00 00 00 00 00 00 00 00 00 00 00 00 6f 6e 00 00 00 00 00 00 00 00 00 3d 73 6f 63 74 63 70 00 00 00 00 00 00 00 66 00 00 00 00 00 00 00 00 00 00 00 1d 00 00 00 6b 00 00 00 00 00 00 03 1a 00 00 00 00 00 0d 32 33 32 37 63 34 33 35 34 65 61 38 00 00 00 00 0f 2f 68 6f 6d 65 2f 69 6e 66 6f 72 6d 69 78 00 00 6e 00 04 00 00 00 00 00 74 00 33 00 00 00 c8 00 00 00 c8 00 29 2f 6f 70 74 2f 69 62 6d 2f 69 6e 66 6f 72 6d 69 78 2f 76 31 35 2e 30 2e 31 2e 30 2e 33 2f 62 69 6e 2f 6f 6e 69 6e 69 74 00 00 7f +> 2026/05/02 20:10:07.222169 length=2 from=395 to=396 + 00 38 +2026/05/02 20:10:07 socat[3937281] N socket 2 (fd 5) is at EOF +2026/05/02 20:10:07 socat[3937281] N socket 1 (fd 6) is at EOF +2026/05/02 20:10:07 socat[3937281] N exiting with status 0 diff --git a/docs/CAPTURES/04-py-no-database.socat.log b/docs/CAPTURES/04-py-no-database.socat.log new file mode 100644 index 0000000..f484787 --- /dev/null +++ b/docs/CAPTURES/04-py-no-database.socat.log @@ -0,0 +1,16 @@ +2026/05/02 20:11:28 socat[3940306] N listening on AF=2 0.0.0.0:9090 +2026/05/02 20:11:29 socat[3940306] N accepting connection from AF=2 127.0.0.1:47930 on AF=2 127.0.0.1:9090 +2026/05/02 20:11:29 socat[3940306] N opening connection to 127.0.0.1:9088 +2026/05/02 20:11:29 socat[3940306] N opening connection to AF=2 127.0.0.1:9088 +2026/05/02 20:11:29 socat[3940306] N successfully connected from local address AF=2 127.0.0.1:38044 +2026/05/02 20:11:29 socat[3940306] N successfully connected to 127.0.0.1:9088 +2026/05/02 20:11:29 socat[3940306] N starting data transfer loop with FDs [6,6] and [5,5] +> 2026/05/02 20:11:29.010247 length=385 from=0 to=384 + 01 81 01 3c 00 00 00 64 00 65 00 00 00 3d 00 06 49 45 45 45 4d 00 00 6c 73 71 6c 65 78 65 63 00 00 00 00 00 00 06 39 2e 32 38 30 00 00 0c 52 44 53 23 52 30 30 30 30 30 30 00 00 05 73 71 6c 69 00 00 00 00 01 3c 00 00 00 00 00 00 00 00 01 00 09 69 6e 66 6f 72 6d 69 78 00 00 07 69 6e 34 6d 69 78 00 6f 6c 00 00 00 00 00 00 00 00 00 3d 74 6c 69 74 63 70 00 00 00 00 00 01 00 68 00 0b 00 00 00 03 00 09 69 6e 66 6f 72 6d 69 78 00 00 00 00 00 00 00 00 00 00 00 00 6a 00 06 00 07 44 42 50 41 54 48 00 00 02 2e 00 00 0e 43 4c 49 45 4e 54 5f 4c 4f 43 41 4c 45 00 00 0d 65 6e 5f 55 53 2e 38 38 35 39 2d 31 00 00 11 43 4c 4e 54 5f 50 41 4d 5f 43 41 50 41 42 4c 45 00 00 02 31 00 00 07 44 42 44 41 54 45 00 00 06 59 34 4d 44 2d 00 00 0c 49 46 58 5f 55 50 44 44 45 53 43 00 00 02 31 00 00 09 4e 4f 44 45 46 44 41 43 00 00 03 6e 6f 00 00 6b 00 00 00 00 00 3c 1f de 00 00 00 00 00 0b 72 70 6d 2d 62 75 6c 6c 65 74 00 00 00 00 29 2f 68 6f 6d 65 2f 72 70 6d 2f 63 6c 61 75 64 65 2f 69 6e 66 6f 72 6d 69 78 2f 70 79 74 68 6f 6e 2d 6c 69 62 72 61 72 79 00 00 74 00 21 00 00 00 00 00 00 00 00 00 17 69 6e 66 6f 72 6d 69 78 2d 64 62 40 70 69 64 33 39 34 30 33 31 38 00 00 7f +< 2026/05/02 20:11:29.022216 length=276 from=0 to=275 + 01 14 02 3c 10 00 00 64 00 65 00 00 00 3d 00 06 49 45 45 45 49 00 00 6c 73 72 76 69 6e 66 78 00 00 00 00 00 00 2f 49 42 4d 20 49 6e 66 6f 72 6d 69 78 20 44 79 6e 61 6d 69 63 20 53 65 72 76 65 72 20 56 65 72 73 69 6f 6e 20 31 35 2e 30 2e 31 2e 30 2e 33 00 00 07 73 65 72 69 61 6c 00 00 09 69 6e 66 6f 72 6d 69 78 00 00 00 01 3c 00 00 00 00 00 00 00 00 00 00 00 00 00 00 6f 6e 00 00 00 00 00 00 00 00 00 3d 73 6f 63 74 63 70 00 00 00 00 00 00 00 66 00 00 00 00 00 00 00 00 00 00 00 1c 00 00 00 6b 00 00 00 00 00 00 03 1a 00 00 00 00 00 0d 32 33 32 37 63 34 33 35 34 65 61 38 00 00 00 00 0f 2f 68 6f 6d 65 2f 69 6e 66 6f 72 6d 69 78 00 00 6e 00 04 00 00 00 00 00 74 00 33 00 00 00 c8 00 00 00 c8 00 29 2f 6f 70 74 2f 69 62 6d 2f 69 6e 66 6f 72 6d 69 78 2f 76 31 35 2e 30 2e 31 2e 30 2e 33 2f 62 69 6e 2f 6f 6e 69 6e 69 74 00 00 7f +> 2026/05/02 20:11:29.022563 length=2 from=385 to=386 + 00 38 +2026/05/02 20:11:29 socat[3940306] N socket 2 (fd 5) is at EOF +2026/05/02 20:11:29 socat[3940306] N socket 1 (fd 6) is at EOF +2026/05/02 20:11:29 socat[3940306] N exiting with status 0 diff --git a/docs/CAPTURES/05-py-fixed-caps.socat.log b/docs/CAPTURES/05-py-fixed-caps.socat.log new file mode 100644 index 0000000..06fe8da --- /dev/null +++ b/docs/CAPTURES/05-py-fixed-caps.socat.log @@ -0,0 +1,16 @@ +2026/05/02 20:15:11 socat[3950551] N listening on AF=2 0.0.0.0:9090 +2026/05/02 20:15:11 socat[3950551] N accepting connection from AF=2 127.0.0.1:50082 on AF=2 127.0.0.1:9090 +2026/05/02 20:15:11 socat[3950551] N opening connection to 127.0.0.1:9088 +2026/05/02 20:15:11 socat[3950551] N opening connection to AF=2 127.0.0.1:9088 +2026/05/02 20:15:11 socat[3950551] N successfully connected from local address AF=2 127.0.0.1:44592 +2026/05/02 20:15:11 socat[3950551] N successfully connected to 127.0.0.1:9088 +2026/05/02 20:15:11 socat[3950551] N starting data transfer loop with FDs [6,6] and [5,5] +> 2026/05/02 20:15:11.643448 length=385 from=0 to=384 + 01 81 01 3c 00 00 00 64 00 65 00 00 00 3d 00 06 49 45 45 45 4d 00 00 6c 73 71 6c 65 78 65 63 00 00 00 00 00 00 06 39 2e 32 38 30 00 00 0c 52 44 53 23 52 30 30 30 30 30 30 00 00 05 73 71 6c 69 00 00 00 01 3c 00 00 00 00 00 00 00 00 00 01 00 09 69 6e 66 6f 72 6d 69 78 00 00 07 69 6e 34 6d 69 78 00 6f 6c 00 00 00 00 00 00 00 00 00 3d 74 6c 69 74 63 70 00 00 00 00 00 01 00 68 00 0b 00 00 00 03 00 09 69 6e 66 6f 72 6d 69 78 00 00 00 00 00 00 00 00 00 00 00 00 6a 00 06 00 07 44 42 50 41 54 48 00 00 02 2e 00 00 0e 43 4c 49 45 4e 54 5f 4c 4f 43 41 4c 45 00 00 0d 65 6e 5f 55 53 2e 38 38 35 39 2d 31 00 00 11 43 4c 4e 54 5f 50 41 4d 5f 43 41 50 41 42 4c 45 00 00 02 31 00 00 07 44 42 44 41 54 45 00 00 06 59 34 4d 44 2d 00 00 0c 49 46 58 5f 55 50 44 44 45 53 43 00 00 02 31 00 00 09 4e 4f 44 45 46 44 41 43 00 00 03 6e 6f 00 00 6b 00 00 00 00 00 3c 47 e2 00 00 00 00 00 0b 72 70 6d 2d 62 75 6c 6c 65 74 00 00 00 00 29 2f 68 6f 6d 65 2f 72 70 6d 2f 63 6c 61 75 64 65 2f 69 6e 66 6f 72 6d 69 78 2f 70 79 74 68 6f 6e 2d 6c 69 62 72 61 72 79 00 00 74 00 21 00 00 00 00 00 00 00 00 00 17 69 6e 66 6f 72 6d 69 78 2d 64 62 40 70 69 64 33 39 35 30 35 36 32 00 00 7f +< 2026/05/02 20:15:11.655455 length=276 from=0 to=275 + 01 14 02 3c 10 00 00 64 00 65 00 00 00 3d 00 06 49 45 45 45 49 00 00 6c 73 72 76 69 6e 66 78 00 00 00 00 00 00 2f 49 42 4d 20 49 6e 66 6f 72 6d 69 78 20 44 79 6e 61 6d 69 63 20 53 65 72 76 65 72 20 56 65 72 73 69 6f 6e 20 31 35 2e 30 2e 31 2e 30 2e 33 00 00 07 73 65 72 69 61 6c 00 00 09 69 6e 66 6f 72 6d 69 78 00 00 00 01 3c 00 00 00 00 00 00 00 00 00 00 00 00 00 00 6f 6e 00 00 00 00 00 00 00 00 00 3d 73 6f 63 74 63 70 00 00 00 00 00 00 00 66 00 00 00 00 00 00 00 00 00 00 00 14 00 00 00 6b 00 00 00 00 00 00 03 1a 00 00 00 00 00 0d 32 33 32 37 63 34 33 35 34 65 61 38 00 00 00 00 0f 2f 68 6f 6d 65 2f 69 6e 66 6f 72 6d 69 78 00 00 6e 00 04 00 00 00 00 00 74 00 33 00 00 00 c8 00 00 00 c8 00 29 2f 6f 70 74 2f 69 62 6d 2f 69 6e 66 6f 72 6d 69 78 2f 76 31 35 2e 30 2e 31 2e 30 2e 33 2f 62 69 6e 2f 6f 6e 69 6e 69 74 00 00 7f +> 2026/05/02 20:15:11.655744 length=2 from=385 to=386 + 00 38 +2026/05/02 20:15:11 socat[3950551] N socket 2 (fd 5) is at EOF +2026/05/02 20:15:11 socat[3950551] N socket 1 (fd 6) is at EOF +2026/05/02 20:15:11 socat[3950551] N exiting with status 0 diff --git a/docs/DECISION_LOG.md b/docs/DECISION_LOG.md index 813ab12..76c012b 100644 --- a/docs/DECISION_LOG.md +++ b/docs/DECISION_LOG.md @@ -158,6 +158,16 @@ DATETIME / INTERVAL / DECIMAL / NUMERIC / MONEY remain in Phase 6+ — their enc --- +## 2026-05-02 — Capability ints: corrected after PDU diff caught misread + +**Status**: active (corrects an earlier same-day entry) +**Decision**: Send `Cap_1 = 0x0000013c, Cap_2 = 0, Cap_3 = 0` in the binary login PDU. These are the values IBM's JDBC driver sends; the server echoes them back identically. +**Why this is a correction**: An earlier read of the wire bytes (before we wrote the byte-for-byte PDU diff) decoded the capability section as `Cap_1=1, Cap_2=0x3c000000, Cap_3=0`. That was a misalignment — the `0x3c` byte interpreted as `Cap_2`'s high byte was actually `Cap_1`'s low byte. Real layout: a single int `0x0000013c` = `(capability_class << 8) | PF_PROT_SQLI_0600 (60 = 0x3c)`. +**How we caught it**: `tests/test_pdu_match.py` — captures our generated PDU via a monkey-patched socket and asserts byte-for-byte equality against `docs/CAPTURES/01-connect-only.socat.log` for offsets 2..280 (the structural prefix). The connection still worked with the wrong values because the dev image is permissive, but the PDU was structurally non-identical. **Server-accepts ≠ structurally-correct.** +**Methodology takeaway**: For wire-protocol implementations, always diff against the reference vendor's PDU bytes, not just "it connected." Permissive servers mask real bugs. + +--- + ## (template — copy below this line for new entries) ``` diff --git a/docs/PROTOCOL_NOTES.md b/docs/PROTOCOL_NOTES.md index 2638617..2e38bb3 100644 Binary files a/docs/PROTOCOL_NOTES.md and b/docs/PROTOCOL_NOTES.md differ diff --git a/src/informix_db/connections.py b/src/informix_db/connections.py index 05be537..b37735b 100644 --- a/src/informix_db/connections.py +++ b/src/informix_db/connections.py @@ -38,12 +38,17 @@ from ._protocol import IfxStreamReader, IfxStreamWriter, ProtocolError, make_pdu from ._socket import IfxSocket from .exceptions import InterfaceError, OperationalError -# Default capability bits the JDBC reference sends. Captured from -# 01-connect-only.socat.log: Cap_1=1, Cap_2=0x3c000000, Cap_3=0. -# Server echoes these back. Their exact bit semantics are unmapped; for -# now we send what JDBC sends so the server treats us as a known peer. -_DEFAULT_CAP_1 = 1 -_DEFAULT_CAP_2 = 0x3C000000 +# Default capability bits the JDBC reference sends. Validated against +# 01-connect-only.socat.log via the PDU diff in tests/test_pdu_match.py: +# Cap_1 = 0x0000013c = 316 — appears to be (capability_class << 8) | protocol_version, +# where protocol_version = 0x3c = PF_PROT_SQLI_0600 (=60) +# Cap_2 = 0 +# Cap_3 = 0 +# Server echoes these back in DecodeAscBinary. The dev image is permissive +# and accepts other values too, but matching JDBC's reference protects us +# against subtle compatibility issues with stricter server configurations. +_DEFAULT_CAP_1 = 0x0000013C +_DEFAULT_CAP_2 = 0 _DEFAULT_CAP_3 = 0 # Default environment variables sent in the login PDU (SQ_ASCENV section). diff --git a/tests/test_pdu_match.py b/tests/test_pdu_match.py new file mode 100644 index 0000000..2713544 --- /dev/null +++ b/tests/test_pdu_match.py @@ -0,0 +1,157 @@ +"""Regression test: our generated login PDU is byte-identical to JDBC's. + +Phase 1 polish artifact. We monkeypatch ``IfxSocket`` with a fake that +captures the bytes we send, then compare those bytes to the captured +JDBC reference PDU in ``docs/CAPTURES/01-connect-only.socat.log``. + +Bytes 2..280 of the PDU are the *structural* prefix — SLheader (sans +length field), all login markers, the three capability ints, username, +password, protocol identifiers, and environment variables. These MUST +be byte-identical to JDBC's PDU; any divergence is a real bug (we +caught one this way already — the misaligned capability ints). + +Bytes 280+ contain process-specific fields (PID, thread ID, hostname, +cwd, AppName) that legitimately differ per Python process. The test +asserts only the structural prefix. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +import informix_db +from informix_db import connections + + +def _extract_first_client_pdu(log_path: Path) -> bytes: + """Pull the first '>' (client→server) hex dump out of a socat -x log.""" + text = log_path.read_text() + match = re.search(r"^> .*?length=\d+.*?\n (.*?)\n", text, re.MULTILINE | re.DOTALL) + assert match, f"no client→server message found in {log_path}" + return bytes.fromhex(match.group(1).strip().replace(" ", "")) + + +@pytest.fixture +def jdbc_reference_pdu() -> bytes: + """The IBM JDBC reference login PDU, captured under socat in Phase 0.""" + return _extract_first_client_pdu( + Path(__file__).parent.parent / "docs/CAPTURES/01-connect-only.socat.log" + ) + + +@pytest.fixture +def python_login_pdu(monkeypatch: pytest.MonkeyPatch) -> bytes: + """Capture the bytes our pure-Python client emits without touching the network.""" + captured = bytearray() + + class _CapturingSocket: + """Fake socket: captures writes, then raises to stop the connect flow.""" + + def __init__(self, *_args: object, **_kwargs: object) -> None: + self._closed = False + + @property + def closed(self) -> bool: + return self._closed + + def write_all(self, data: bytes) -> None: + captured.extend(data) + # Stop the connect flow before it tries to read a server response. + raise informix_db.OperationalError("stub: stop after login PDU") + + def read_exact(self, _n: int) -> bytes: + raise informix_db.OperationalError("stub: never reached") + + def close(self) -> None: + self._closed = True + + monkeypatch.setattr(connections, "IfxSocket", _CapturingSocket) + + with pytest.raises(informix_db.OperationalError, match="stub"): + informix_db.connect( + host="dont.care", port=9088, + user="informix", password="in4mix", + database=None, server="informix", + ) + + return bytes(captured) + + +# --------------------------------------------------------------------------- +# Structural-prefix tests +# --------------------------------------------------------------------------- + +# Offset where process-specific fields begin (PID/TID/hostname/cwd/AppName). +# Empirically determined by running the diff after fixing the caps ints +# (see DECISION_LOG.md). Anything before this MUST match byte-for-byte. +STRUCTURAL_PREFIX_END = 280 + + +def test_slheader_protocol_version_matches( + python_login_pdu: bytes, jdbc_reference_pdu: bytes +) -> None: + """The SLheader's protocol-version byte (offset 3) must be 60 (PF_PROT_SQLI_0600).""" + assert python_login_pdu[3] == jdbc_reference_pdu[3] == 0x3C + + +def test_slheader_type_byte_matches( + python_login_pdu: bytes, jdbc_reference_pdu: bytes +) -> None: + """The SLheader's slType byte (offset 2) must be 1 (SLTYPE_CONREQ).""" + assert python_login_pdu[2] == jdbc_reference_pdu[2] == 0x01 + + +def test_capability_ints_match_reference( + python_login_pdu: bytes, jdbc_reference_pdu: bytes +) -> None: + """Cap_1 / Cap_2 / Cap_3 (offsets 65..76) must be byte-identical to JDBC. + + This is the test that would have caught the original capability-int bug + (where we sent caps_1=1, caps_2=0x3c000000 instead of caps_1=0x13c, caps_2=0). + """ + assert python_login_pdu[65:77] == jdbc_reference_pdu[65:77] + + +def test_structural_prefix_matches( + python_login_pdu: bytes, jdbc_reference_pdu: bytes +) -> None: + """Everything from byte 2 to ``STRUCTURAL_PREFIX_END`` must match exactly. + + Skips: + * Bytes 0..1 (SLheader length): differs because Python sends fewer + env vars / shorter AppName, so total length differs. + * Bytes ``STRUCTURAL_PREFIX_END``..end: process-specific fields + (PID, TID, hostname, cwd, AppName). + """ + py_prefix = python_login_pdu[2:STRUCTURAL_PREFIX_END] + ja_prefix = jdbc_reference_pdu[2:STRUCTURAL_PREFIX_END] + + if py_prefix != ja_prefix: + # Find first divergence and report it with context. + for i, (a, b) in enumerate(zip(py_prefix, ja_prefix, strict=False)): + if a != b: + off = i + 2 + pytest.fail( + f"structural-prefix mismatch at offset {off}: " + f"Python={a:#04x} JDBC={b:#04x}\n" + f" Python[{off - 4}..{off + 4}]: " + f"{python_login_pdu[off - 4:off + 5].hex(' ')}\n" + f" JDBC [{off - 4}..{off + 4}]: " + f"{jdbc_reference_pdu[off - 4:off + 5].hex(' ')}" + ) + + assert py_prefix == ja_prefix + + +def test_pdu_is_correctly_length_prefixed(python_login_pdu: bytes) -> None: + """The SLheader's first 2 bytes must equal the total PDU length.""" + declared_length = int.from_bytes(python_login_pdu[0:2], "big", signed=False) + assert declared_length == len(python_login_pdu) + + +def test_pdu_ends_with_sq_asceot(python_login_pdu: bytes) -> None: + """Every login PDU must end with [short SQ_ASCEOT=127] (= 0x00 0x7f).""" + assert python_login_pdu[-2:] == b"\x00\x7f"